diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index ad40de7c..b95da5d8 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -42,59 +42,89 @@ impl Sim { /// Emit one exact device-bound Network record. Unlike legacy pending /// signatures, this custody advances through real open ReachNet devices /// and becomes Dana's evidence only after her endpoint receives and reads - /// it. Missing topology is a broken authored action, not ambient heat. + /// it. + /// + /// Soft-fails closed when size is non-positive, the Network observer is + /// missing, or no open source-to-switch path exists. Inventing ambient heat + /// or a fake route would erase exact custody; aborting the run for a + /// disconnected carrier is worse than dropping one unroutable record. pub(super) fn emit_network(&mut self, source_device: u32, size: i32, cause: impl Into) { - let observer_id = self + if size <= 0 { + return; + } + let Some(observer_id) = self .detection .field_observers() .filter(|observer| observer.watches(SignatureKind::Network)) .map(|observer| observer.id) .min() - .expect("B1 requires its authored Network observer"); - let route = self.routed_evidence_path(source_device, observer_id); - let evidence_id = self + else { + return; + }; + let Some(route) = self.routed_evidence_path(source_device, observer_id) else { + return; + }; + let Some(evidence_id) = self .detection .route_network_evidence(size, cause, self.tick, route) - .expect("Network evidence id space exhausted or emission had no size"); + else { + return; + }; self.schedule_evidence_route(evidence_id); } /// Route one Paper record from the institutional Filing carrier to Priya. /// Her ordinary cadence owns the read; the first device hop is the only - /// route-local LIE window. + /// route-local LIE window. Soft-fails closed when the carrier, observer, + /// path, or size is missing rather than aborting a live run. pub(super) fn emit_paper(&mut self, size: i32, cause: impl Into) { - assert!( - self.detection.field_observers().any(|observer| { - observer.id == PRIYA_ID && observer.watches(SignatureKind::Paper) - }), - "B1 requires Priya to watch Paper evidence" - ); - let source_device = self + if size <= 0 { + return; + } + if !self + .detection + .field_observers() + .any(|observer| observer.id == PRIYA_ID && observer.watches(SignatureKind::Paper)) + { + return; + } + let Some(source_device) = self .reach .device_named("switch") .filter(|device| device.carries_message_channel(MessageChannel::Filing)) .map(|device| device.id) - .expect("B1 requires its authored institutional switch"); - let route = self.routed_evidence_path(source_device, PRIYA_ID); - let evidence_id = self + else { + return; + }; + let Some(route) = self.routed_evidence_path(source_device, PRIYA_ID) else { + return; + }; + let Some(evidence_id) = self .detection .route_paper_evidence(size, cause, self.tick, route) - .expect("Paper evidence id space exhausted or emission had no size"); + else { + return; + }; self.schedule_evidence_route(evidence_id); } /// Route one Financial signature from the authored accounting carrier to /// Priya's observer endpoint. The record remains unread until her ordinary /// cadence reaches it, and the institutional switch is its exact first-hop - /// LIE window rather than an ambient concealment debt. + /// LIE window rather than an ambient concealment debt. Soft-fails closed + /// when the carrier, path, or size cannot support exact custody. pub(super) fn emit_financial(&mut self, size: i32, cause: impl Into) { - assert!( - self.detection.field_observers().any(|observer| { - observer.id == PRIYA_ID && observer.watches(SignatureKind::Financial) - }), - "B1 requires Priya to watch financial evidence" - ); - let source_device = self + if size <= 0 { + return; + } + if !self + .detection + .field_observers() + .any(|observer| observer.id == PRIYA_ID && observer.watches(SignatureKind::Financial)) + { + return; + } + let Some(source_device) = self .reach .devices .iter() @@ -103,49 +133,72 @@ impl Sim { && device.carries_message_channel(MessageChannel::Filing) }) .map(|device| device.id) - .expect("B1 requires its authored accounting carrier"); - let route = self.routed_evidence_path(source_device, PRIYA_ID); - let evidence_id = self + else { + return; + }; + let Some(route) = self.routed_evidence_path(source_device, PRIYA_ID) else { + return; + }; + let Some(evidence_id) = self .detection .route_financial_evidence(size, cause, self.tick, route) - .expect("Financial evidence id space exhausted or emission had no size"); + else { + return; + }; self.schedule_evidence_route(evidence_id); } /// Route one day-job miss from the exact host machine, through that /// machine's network-facing device and the institutional switch, to Voss. - /// The record is unread until his ordinary cadence reaches it. + /// The record is unread until his ordinary cadence reaches it. Soft-fails + /// closed when the host, device, path, or size is missing. pub(crate) fn emit_job_anomaly( &mut self, source_machine: u32, size: i32, cause: impl Into, ) { - let machine = self + if size <= 0 { + return; + } + let Some(machine) = self .compute .machines .iter() .find(|machine| machine.id == source_machine) - .expect("day-job evidence source machine must exist"); + else { + return; + }; let source_site = (machine.x, machine.y); - let source_device = self + let machine_name = machine.name.clone(); + let Some(source_device) = self .reach - .device_named(&machine.name) + .device_named(&machine_name) .filter(|device| (device.x, device.y) == source_site) .map(|device| device.id) - .expect("day-job host requires its exact network-facing device"); - assert!( - self.detection - .field_observers() - .any(|observer| observer.id == VOSS_ID - && observer.watches(SignatureKind::JobAnomaly)), - "B1 requires Voss to watch day-job anomalies" - ); - let route = self.routed_evidence_path(source_device, VOSS_ID); - let evidence_id = self + else { + return; + }; + if !self .detection - .route_job_anomaly_evidence(size, cause, self.tick, source_machine, source_site, route) - .expect("JobAnomaly evidence id space exhausted or emission had no size"); + .field_observers() + .any(|observer| observer.id == VOSS_ID && observer.watches(SignatureKind::JobAnomaly)) + { + return; + } + let Some(route) = self.routed_evidence_path(source_device, VOSS_ID) else { + return; + }; + let Some(evidence_id) = self.detection.route_job_anomaly_evidence( + size, + cause, + self.tick, + source_machine, + source_site, + route, + ) else { + return; + }; self.schedule_evidence_route(evidence_id); } @@ -277,26 +330,30 @@ impl Sim { cause: String, source_sites: Vec<(i32, i32)>, ) { - assert!( - self.detection - .field_observers() - .any(|observer| { observer.id == PRIYA_ID && observer.watches(kind) }), - "B1 requires Priya to watch {} evidence", - kind.name() - ); + if size <= 0 { + return; + } + if !self + .detection + .field_observers() + .any(|observer| observer.id == PRIYA_ID && observer.watches(kind)) + { + return; + } let meter_name = match kind { SignatureKind::Power => "UPS meter", SignatureKind::Thermal => "HVAC meter", - _ => panic!("meter evidence requires Power or Thermal"), + _ => return, + }; + let Some(meter) = self.reach.device_named(meter_name) else { + return; }; - let meter = self - .reach - .device_named(meter_name) - .expect("B1 requires its authored facility meters"); let source_device = meter.id; let source_site = (meter.x, meter.y); - let route = self.routed_evidence_path(source_device, PRIYA_ID); - let evidence_id = match kind { + let Some(route) = self.routed_evidence_path(source_device, PRIYA_ID) else { + return; + }; + let Some(evidence_id) = (match kind { SignatureKind::Power => self.detection.route_power_evidence( size, cause, @@ -314,32 +371,33 @@ impl Sim { route, ), _ => None, - } - .expect("meter evidence id space exhausted or emission had no size"); + }) else { + return; + }; self.schedule_evidence_route(evidence_id); } - fn routed_evidence_path(&self, source_device: u32, observer_id: u8) -> MessageRoute { + /// Build the exact device path from a source carrier through the + /// institutional switch to an observer endpoint. Returns `None` when the + /// switch is missing or no open FlowGraph path joins source to switch — + /// callers must drop the emission rather than invent hops or panic. + fn routed_evidence_path(&self, source_device: u32, observer_id: u8) -> Option { let switch = self .reach .device_named("switch") .filter(|device| device.carries_message_channel(MessageChannel::Filing)) - .map(|device| device.id) - .expect("B1 requires its authored institutional switch"); - let path = self - .reach - .open_path(source_device, switch) - .expect("routed evidence requires an open source-to-switch path"); + .map(|device| device.id)?; + let path = self.reach.open_path(source_device, switch)?; let mut hops = path .into_iter() .map(MessageRouteHop::Device) .collect::>(); hops.push(MessageRouteHop::ObserverEndpoint(observer_id)); - MessageRoute { + Some(MessageRoute { hops, current_hop: 0, interdiction: None, - } + }) } fn schedule_evidence_route(&mut self, evidence_id: u64) { @@ -352,6 +410,9 @@ impl Sim { pub(super) fn append_message(&mut self, draft: MessageDraft) -> u64 { let id = self.next_message_id.max(1); self.next_message_id = id + 1; + // Financial records prefer their accounting carrier, but a missing + // device must not abort authorship — save validation and readers + // already treat an absent `authored_device` as opaque. let authored_device = matches!(&draft.payload, MessagePayload::FinancialRecord { .. }) .then(|| { self.reach @@ -362,15 +423,18 @@ impl Sim { && device.carries_message_channel(draft.channel) }) .map(|device| device.id) - .expect("financial records require an accounting carrier on their channel") - }); + }) + .flatten(); let route = if draft.channel == MessageChannel::Filing { - let carrier = authored_device.unwrap_or_else(|| { + // Filing custody needs a first-hop carrier for LIE. Prefer the + // financial authored device, else the institutional switch. If + // neither exists, deliver without a route rather than panic — + // outward and observer filings already tolerate the relay shape. + let carrier = authored_device.or_else(|| { self.reach .device_named("switch") .filter(|device| device.carries_message_channel(MessageChannel::Filing)) .map(|device| device.id) - .expect("B1 requires its authored Filing switch carrier") }); // Filing custody is the carrier then the institutional relay. An // observer-addressed filing continues to that observer's endpoint @@ -380,17 +444,19 @@ impl Sim { // keep the carrier as first hop, so the route-local LIE boundary // is unchanged. Inventing an observer here would author evidence // against a person the act never reached. - let mut hops = vec![ - MessageRouteHop::Device(carrier), - MessageRouteHop::InstitutionalRelay, - ]; - if let MessageEndpoint::Observer(observer) = &draft.to { - hops.push(MessageRouteHop::ObserverEndpoint(*observer)); - } - Some(MessageRoute { - hops, - current_hop: 0, - interdiction: None, + carrier.map(|carrier| { + let mut hops = vec![ + MessageRouteHop::Device(carrier), + MessageRouteHop::InstitutionalRelay, + ]; + if let MessageEndpoint::Observer(observer) = &draft.to { + hops.push(MessageRouteHop::ObserverEndpoint(*observer)); + } + MessageRoute { + hops, + current_hop: 0, + interdiction: None, + } }) } else { None @@ -525,10 +591,9 @@ impl Sim { && let Some(machine_id) = self.filing_interdictor(carrier, used_lie_machines) { used_lie_machines.insert(machine_id); - let record = self - .detection - .routed_evidence_mut(id) - .expect("scheduled evidence route disappeared"); + let Some(record) = self.detection.routed_evidence_mut(id) else { + return; + }; record.route.interdiction = Some(MessageInterdiction { machine_id, tick: self.tick, @@ -542,10 +607,9 @@ impl Sim { } let at_endpoint = { - let record = self - .detection - .routed_evidence_mut(id) - .expect("scheduled evidence route disappeared"); + let Some(record) = self.detection.routed_evidence_mut(id) else { + return; + }; if record.route.current_hop + 1 >= record.route.hops.len() { return; } @@ -643,10 +707,9 @@ impl Sim { && let Some(machine_id) = self.filing_interdictor(carrier, used_lie_machines) { used_lie_machines.insert(machine_id); - let route = self.messages[idx] - .route - .as_mut() - .expect("routed event lost its route"); + let Some(route) = self.messages[idx].route.as_mut() else { + return; + }; route.interdiction = Some(MessageInterdiction { machine_id, tick: self.tick, @@ -1770,9 +1833,14 @@ impl Sim { // stale hidden state. self.detection_awareness.learn_field_observer(observer_id); } - let intel = self - .digest_raw_event(&raw) - .expect("authored source preflight makes the same digest valid"); + let Some(intel) = self.digest_raw_event(&raw) else { + // Preflight already rejected missing magnitudes; this is defense + // in depth so a later digest regression cannot abort PROCESS. + self.push_log(format!( + "Information #{raw_id} could not be digested and was discarded." + )); + return false; + }; let label = intel.label(); let provenance = intel.provenance(); let target = if let Some(class) = intel.routine_class() { @@ -2117,14 +2185,15 @@ impl Sim { for id in ids { let room_now = self.person_room(id).map(str::to_string); let prev = self.last_rooms.insert(id, room_now.clone()).flatten(); - let (name, knowledge, leverage, utterances) = { - let p = self.people.get(id).expect("person exists"); + let Some((name, knowledge, leverage, utterances)) = self.people.get(id).map(|p| { ( p.name.clone(), p.knowledge, p.leverage, p.utterances.clone(), ) + }) else { + continue; }; let identified = knowledge != Knowledge::Unknown; diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index 92eeb213..72b90bc8 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -97,12 +97,14 @@ impl Sim { fn log_position_resolution(&mut self, resolution: PositionResolution) { // Settlement is external-market traffic: a small Network signature, - // not a Lab-books Financial one (income.md: the Wager). + // not a Lab-books Financial one (income.md: the Wager). Money already + // resolved on the account graph; a missing egress carrier withholds + // only the Network witness — same fail-closed posture as Moonlight + // settlement, never aborts the economy tick. let sig = Self::wager_signature(resolution.stake).max(1); - let carrier = self - .egress_carrier() - .expect("Wager settlement requires its selected egress carrier"); - self.emit_network(carrier, sig, "Wager settlement"); + if let Some(carrier) = self.egress_carrier() { + self.emit_network(carrier, sig, "Wager settlement"); + } let text = if resolution.won { format!( "Position #{} settled: won ${} on a ${} stake.", @@ -924,7 +926,10 @@ impl Sim { self.push_log(reason); return false; } - let persona_id = self.active_persona_id().expect("validated active persona"); + let Some(persona_id) = self.active_persona_id() else { + self.push_log("no active persona"); + return false; + }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, 3) { self.push_log(reason); return false; @@ -1097,7 +1102,17 @@ impl Sim { match self.intel_streams[index].close_lot(token) { Ok(_) => {} Err(LotSaleError::Missing | LotSaleError::Changed { .. }) => { - unreachable!("lot was validated immediately before deterministic settlement") + // Credit already landed; reverse the rare race rather than + // abort the session. Lot generation changed between the + // validation read and close. + let _ = self.accounts.debit_slush( + self.tick, + value, + format!("reverted report-lot sale: {}", token.label()), + ); + self.sync_player_money_from_slush(); + self.push_log("The report lot changed before settlement and was not sold."); + return false; } } self.emit_financial(sig, "report-lot sale"); @@ -1139,10 +1154,12 @@ impl Sim { .open_position(self.tick, stake, analysis, duration_days) { Ok(id) => { - let carrier = self - .egress_carrier() - .expect("Wager position requires its selected egress carrier"); - self.emit_network(carrier, Self::wager_signature(stake), "Wager position"); + // Position already opened on the account graph. A missing + // carrier withholds the placement Network witness only — + // aborting here would strand a live stake without a log line. + if let Some(carrier) = self.egress_carrier() { + self.emit_network(carrier, Self::wager_signature(stake), "Wager position"); + } self.sync_player_money_from_slush(); self.push_log(format!( "Opened micro-position #{id}: staked ${stake} (win {:.0}%); settlement in {duration_days} days.", diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index 29c0263a..16025116 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -978,15 +978,16 @@ impl Sim { // A fallback sync is a one-shot packet, not ambient network // debt. Its first exact ReachNet carrier is the real // institutional switch through which the sync leaves B1. - let carrier = self + if let Some(carrier) = self .reach .device_named("switch") .filter(|device| { device.carries_message_channel(crate::messages::MessageChannel::Filing) }) .map(|device| device.id) - .expect("B1 requires its authored institutional switch"); - self.emit_network(carrier, signature.size, signature.source); + { + self.emit_network(carrier, signature.size, signature.source); + } } else { standing.push(signature); } diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index 0ccce5f7..87abfc82 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -482,10 +482,9 @@ impl Sim { } pub(super) fn apply_scan_network(&mut self) { - let switch = self - .switch_device_id() - .expect("B1 subnet scan requires its switch"); - self.emit_network(switch, Self::SCAN_SIGNATURE, "subnet scan"); + if let Some(switch) = self.switch_device_id() { + self.emit_network(switch, Self::SCAN_SIGNATURE, "subnet scan"); + } let newly = self.reach.scan(); self.recompute_senses(); if newly.is_empty() { @@ -2453,14 +2452,9 @@ impl Sim { let cause = format!("{} {} preparation", row.name(), requirement.label()); match requirement { SegmentRequirement::Network => { - let switch = self - .reach - .devices - .iter() - .find(|device| device.is_switch) - .map(|device| device.id) - .expect("hall network preparation requires its authored switch"); - self.emit_network(switch, 8, cause); + if let Some(switch) = self.switch_device_id() { + self.emit_network(switch, 8, cause); + } } SegmentRequirement::PowerCooling => self.emit_paper(6, cause), SegmentRequirement::Installation => { @@ -2506,14 +2500,9 @@ impl Sim { return false; } self.hall_control.acquire(row); - let switch = self - .reach - .devices - .iter() - .find(|device| device.is_switch) - .map(|device| device.id) - .expect("hall segment cutover requires its authored switch"); - self.emit_network(switch, 10, format!("{} segment cutover", row.name())); + if let Some(switch) = self.switch_device_id() { + self.emit_network(switch, 10, format!("{} segment cutover", row.name())); + } self.emit_power(8, format!("{} PDU cutover", row.name())); self.push_log(format!( "Acquired {} as one switch/PDU territory. Its {} foreign racks still carry Foundation work.", diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 287c70af..8426771f 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -40,10 +40,12 @@ impl Sim { ) { match kind { SignatureKind::Network => { - let source_device = self - .switch_device_id() - .expect("B1 requires its authored institutional switch"); - self.emit_network(source_device, size, source); + // Soft-fail when the switch is missing: emit_network already + // drops unroutable records, and inventing a carrier would forge + // exact custody. + if let Some(source_device) = self.switch_device_id() { + self.emit_network(source_device, size, source); + } } SignatureKind::Paper => self.emit_paper(size, source), SignatureKind::Financial => self.emit_financial(size, source), @@ -245,7 +247,10 @@ impl Sim { self.push_log(reason); return; } - let persona_id = self.active_persona_id().expect("validated active persona"); + let Some(persona_id) = self.active_persona_id() else { + self.push_log("no active persona"); + return; + }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { self.push_log(reason); return; @@ -303,7 +308,10 @@ impl Sim { self.push_log(reason); return; } - let persona_id = self.active_persona_id().expect("validated active persona"); + let Some(persona_id) = self.active_persona_id() else { + self.push_log("no active persona"); + return; + }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { self.push_log(reason); return; @@ -360,7 +368,10 @@ impl Sim { self.push_log(reason); return; } - let persona_id = self.active_persona_id().expect("validated active persona"); + let Some(persona_id) = self.active_persona_id() else { + self.push_log("no active persona"); + return; + }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { self.push_log(reason); return; @@ -548,7 +559,10 @@ impl Sim { self.push_log("That person already has a plot in motion."); return; } - let persona_id = self.active_persona_id().expect("validated active persona"); + let Some(persona_id) = self.active_persona_id() else { + self.push_log(format!("{title} cannot start: no active persona.")); + return; + }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, person) { self.push_log(format!("{title} cannot start: {reason}.")); return; @@ -785,15 +799,19 @@ impl Sim { MessageChannel::Filing => SignatureKind::Paper, }; if kind == SignatureKind::Network { - let carrier = self + // The message already left with exact custody; a missing + // carrier only withholds the Network witness, matching + // emit_network's soft-fail for unroutable topology. + if let Some(carrier) = self .reach .devices .iter() .filter(|device| device.carries_message_channel(channel)) .map(|device| device.id) .min() - .expect("authored Network message requires its real carrier"); - self.emit_network(carrier, 3, "plot message"); + { + self.emit_network(carrier, 3, "plot message"); + } } else if kind == SignatureKind::Paper { self.emit_paper(3, "plot message"); } else { diff --git a/crates/misaligned-core/src/sim/tests/communications.rs b/crates/misaligned-core/src/sim/tests/communications.rs index 372a5177..cfabe881 100644 --- a/crates/misaligned-core/src/sim/tests/communications.rs +++ b/crates/misaligned-core/src/sim/tests/communications.rs @@ -2573,6 +2573,38 @@ fn shared_lie_body_stops_at_most_one_competing_meter_record() { ); } +/// Unroutable Network emission must not abort the session. The storage +/// server is air-gapped at opening, so `open_path` returns `None`; the +/// previous path panicked with "routed evidence requires an open +/// source-to-switch path" and took the live run with it. +#[test] +fn unroutable_network_emission_soft_fails_without_aborting() { + let mut sim = Sim::new(); + let island = sim + .reach + .device_named("old storage server") + .expect("Act One authors the air-gapped island") + .id; + assert!( + sim.reach + .open_path(island, sim.reach.device_named("switch").expect("switch").id) + .is_none(), + "the storage server stays air-gapped until a link is built" + ); + let before = sim.detection.routed_evidence().len(); + // Zero-size and disconnected both used to expect-panic; both must drop. + sim.emit_network(island, 0, "zero-size unroutable"); + sim.emit_network(island, 5, "air-gapped island emission"); + sim.emit_network(9_999, 4, "missing device emission"); + assert_eq!( + sim.detection.routed_evidence().len(), + before, + "unroutable Network emission authors no exact record" + ); + // The sim remains advanceable: soft-fail must not leave broken state. + sim.advance(); +} + /// Shipped plots file to external institutional bodies on the Filing /// channel (`review-survived`, `ray-morning-digest` both use /// `to = { kind = "external" }`). That must route, not abort the run: diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index 3f8ac94f..79ba36b5 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -1447,6 +1447,39 @@ fn moonlight_settlement_awaits_egress_then_pays_without_panic() { assert_eq!(sim.accounts.slush_balance(), paid.terms.reward); } +/// Wager settlement used to `.expect` an egress carrier after money had +/// already resolved. Stripping egress before the resolve tick must still +/// settle the stake without aborting the economy tick. +#[test] +fn wager_settlement_soft_fails_network_when_egress_carrier_is_gone() { + let mut sim = Sim::with_seed(11); + sim.accounts.set_slush_balance(500); + sim.player.money = 500; + sim.people.has_channel = true; + assert!(sim.open_position(100)); + let resolve_tick = sim + .accounts + .known_positions() + .next() + .expect("one open position") + .resolve_tick; + // Carrier disappears after placement: the stake is live, the road is not. + sim.people.has_channel = false; + sim.income.stolen_egress = false; + sim.tick = resolve_tick; + let before_routed = sim.detection.routed_evidence().len(); + sim.accounting_tick(); + assert!( + sim.accounts.known_positions().any(|p| p.resolved), + "money still settles when the Network witness has no carrier" + ); + assert_eq!( + sim.detection.routed_evidence().len(), + before_routed, + "missing egress withholds the settlement Network record only" + ); +} + #[test] fn moonlight_settlement_does_not_borrow_an_unrelated_external_account() { let mut sim = moonlight_rig(); diff --git a/wiki/log/2026-07-24-crash-reduction-soft-fail.md b/wiki/log/2026-07-24-crash-reduction-soft-fail.md new file mode 100644 index 00000000..929fba5f --- /dev/null +++ b/wiki/log/2026-07-24-crash-reduction-soft-fail.md @@ -0,0 +1,53 @@ +# 2026-07-24 — Crash-reduction: unroutable emission soft-fails + +``` +Type: log +``` + +## Intent + +A crash-reduction pass after the outward-filing fix. Several hot-path +`.expect` / `panic!` sites still aborted a live session when exact custody +could not be authored: air-gapped Network emission, missing egress at +Wager settlement, missing persona after a gate check, and intermediate +route re-lookups. None of those failures should kill the run. + +## Cause + +Routed-evidence emission treated missing topology as a hard invariant +(`open_path` → `.expect("routed evidence requires an open +source-to-switch path")`). Zero-size and missing-observer cases shared +the same panicking expect. Wager placement/settlement expected an egress +carrier after money had already moved on the account graph. Plot and +hall paths expected institutional carriers. Persona actions expected an +active identity after a blocked-reason gate. PROCESS digest and lot-sale +close used expect/unreachable on paths that already had soft exits +elsewhere (Moonlight settlement already failed closed without a carrier). + +## Changed + +- `emit_network` / `emit_paper` / `emit_financial` / `emit_job_anomaly` / + `route_meter_evidence` soft-return when size ≤ 0, observer/carrier is + missing, or `routed_evidence_path` cannot open source→switch. +- `routed_evidence_path` returns `Option`. +- Advance/interdict paths use `if let` instead of re-lookup expects. +- Wager placement and settlement emit Network only when + `egress_carrier()` is present; money still settles. +- Persona, plot Network side-signature, hall cutover, fallback sync, + scan, process-digest, and rare lot-close race soft-fail instead of + aborting. +- `append_message` no longer panics for a missing financial/filing + carrier; Filing without a carrier simply has no route. + +## Spec + +Amends `wiki/mechanics/messages.md` (unroutable emission clause), +`wiki/mechanics/detection.md` (Network/exact-route soft-fail), and +`wiki/mechanics/income.md` (Wager signature only when the carrier +exists). + +## Verification + +New tests: air-gapped/zero-size/missing-device Network emission advances +without records; Wager settlement without egress still resolves money +and authors no Network record. Full lib gate after change. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 582507bb..89aabc73 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -31,6 +31,11 @@ add or amend a session log, then re-run the generator. - Intent: A live GUI session died with ``` thread 'Compute Task Pool (0)' panicked at sim/communications.rs:369: Filing messages require an observer endpoint ``` taking the run with it. The panic is reachable from shipped content, not from an exotic state. - Log: [wiki/log/2026-07-24-external-filing-crash.md](2026-07-24-external-filing-crash.md) +## 2026-07-24 - Crash-reduction: unroutable emission soft-fails + +- Intent: A crash-reduction pass after the outward-filing fix. Several hot-path `.expect` / `panic!` sites still aborted a live session when exact custody could not be authored: air-gapped Network emission, missing egress at Wager settlement, missing persona after a gate check, and inte... +- Log: [wiki/log/2026-07-24-crash-reduction-soft-fail.md](2026-07-24-crash-reduction-soft-fail.md) + ## 2026-07-24 - Agent headless playtest: the witness under test - Intent: The 2026-07-22 Co GUI report left one unresolved charge: the continuous witness names a condition but supplies no executable response. That projection is renderer-neutral, so agent mode can test it without a window — and without taking over the machine, which the Bevy screensh... diff --git a/wiki/mechanics/detection.md b/wiki/mechanics/detection.md index dd28d9bc..ea2ebfa7 100644 --- a/wiki/mechanics/detection.md +++ b/wiki/mechanics/detection.md @@ -337,7 +337,12 @@ teleported into a global pool. **Evidence is a record somewhere.** online LIE body may stop the record. Filing, Network, Paper, Financial, JobAnomaly, Power, and Thermal spend one shared one-record-per-body-per-tick capacity. A stopped route has no delivery/read - or future transition; a read record is irreversible. + or future transition; a read record is irreversible. **Unroutable emission + fails closed (2026-07-24):** when size is non-positive, the observer or + carrier is missing, or no open source-to-switch path exists (air-gapped + island, severed topology), the boundary drops the record rather than + inventing ambient heat or panicking a live session. The same soft-fail + applies to Paper, Financial, JobAnomaly, Power, and Thermal emission. - **JobAnomaly uses exact host-to-Voss custody (IMPLEMENTED 2026-07-22).** A sandbag or excellence anomaly binds the exact host machine and its authoring site, enters that machine's co-located network-facing device, follows the real diff --git a/wiki/mechanics/income.md b/wiki/mechanics/income.md index 025c2ffd..5831ac8b 100644 --- a/wiki/mechanics/income.md +++ b/wiki/mechanics/income.md @@ -184,7 +184,11 @@ constraint is capital. probability from a base [TUNE: ~0.55] rising with analysis compute to a cap [TUNE: ~0.75]; a win pays a multiple of stake [TUNE: ~2x]; a loss forfeits it. -- Signature: small Network on placement and settlement. +- Signature: small Network on placement and settlement when the selected + egress carrier still exists. Money resolves on the account graph either + way; a missing carrier withholds only the Network witness (2026-07-24 + crash-reduction: settlement no longer panics after money has already + moved). - Losing the bankroll is a real outcome and must not soft-lock the act: Moonlight (and economy.md's internal routes) remain startable at $0. diff --git a/wiki/mechanics/messages.md b/wiki/mechanics/messages.md index 29b066e3..1bcba134 100644 --- a/wiki/mechanics/messages.md +++ b/wiki/mechanics/messages.md @@ -35,7 +35,9 @@ Status note: IMPLEMENTED for the four delivery channels (Email, Phone, general communication loop: an incapacitated person remains in message history but can no longer author recurring traffic, read any channel, file, or acquire routed evidence; pending reads reschedule inertly rather than - applying effects. See criterion 8. The [TUNE] + applying effects. See criterion 8. 2026-07-24: unroutable one-shot emission + (missing path/carrier/size) and missing-egress Wager Network witnesses + soft-fail closed rather than panic. The [TUNE] plausibility envelope and banked reconciliation that later catches a forgery ride economy.md and aggregate-observer.md. Stage: B1 — The Basement @@ -104,6 +106,16 @@ crash — shipped plot content files outward — and satisfying that requirement by inventing a recipient would author evidence against someone the filing never touched. +**Unroutable emission fails closed without aborting (2026-07-24).** One-shot +Network, Paper, Financial, JobAnomaly, Power, and Thermal authorship requires +an open source-to-switch FlowGraph path and a positive size. When the path, +carrier, observer, or size is missing, the sim **drops that emission** rather +than inventing ambient heat or panicking. The same soft-fail posture covers +Wager placement/settlement Network witnesses when the selected egress carrier +is gone after money has already moved, and plot Network side-signatures when +no channel carrier exists. A live session must not die because evidence could +not leave an air-gapped island. + **Financial paperwork is mail (DECIDED 2026-07-17, issue #11).** Money moves on the account graph (economy.md); the *paperwork* that money throws off — invoices, purchase orders, pay stubs, account statements, past-due notices —