From 84d7ca5bb87bfca87b8cdc080f0fe7c10c8ec811 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 19 Jul 2026 06:35:34 -0700 Subject: [PATCH] Route one-shot Network evidence through exact custody. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind each device act to one-hop transport and observer read so TAP can observe without erasing evidence, while route-local LIE shares Filing capacity. Bump the current save to v44 and reject impossible route, schedule, acquisition, and interdiction provenance. πŸ‘Ύ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- CLAUDE.md | 2 +- crates/misaligned-core/src/detection.rs | 228 ++++++- crates/misaligned-core/src/messages.rs | 4 + crates/misaligned-core/src/reach.rs | 33 +- crates/misaligned-core/src/save.rs | 570 +++++++++++++++++- crates/misaligned-core/src/sim/carrier.rs | 4 + .../misaligned-core/src/sim/communications.rs | 136 +++++ crates/misaligned-core/src/sim/economy.rs | 17 +- crates/misaligned-core/src/sim/mod.rs | 132 ++-- crates/misaligned-core/src/sim/perception.rs | 27 + crates/misaligned-core/src/sim/reach_build.rs | 54 +- crates/misaligned-core/src/sim/social_plot.rs | 77 ++- .../src/sim/tests/communications.rs | 297 ++++++++- .../misaligned-core/src/sim/tests/economy.rs | 19 +- .../src/sim/tests/perception.rs | 7 +- .../src/sim/tests/persistence.rs | 2 +- .../src/sim/tests/reach_build.rs | 30 +- crates/misaligned-core/src/sim/tests/read.rs | 10 +- .../src/sim/tests/social_plot.rs | 59 +- crates/misaligned-core/src/sim/tests/work.rs | 20 +- crates/misaligned-core/src/sim/work.rs | 2 +- wiki/engineering/current-build.md | 6 +- wiki/engineering/flow-substrate.md | 7 +- wiki/log/2026-07-19-network-evidence-route.md | 70 +++ wiki/log/DEVLOG.md | 5 + wiki/log/decisions/2026-07-19.md | 15 + wiki/mechanics/detection.md | 85 ++- wiki/mechanics/machine-work.md | 9 +- wiki/mechanics/messages.md | 17 +- wiki/mechanics/people-tokens.md | 47 +- wiki/mechanics/reach.md | 8 +- wiki/mechanics/sim-mechanics.md | 22 +- wiki/process/tick-ledger.md | 2 +- wiki/world/characters/dana.md | 2 +- wiki/world/story/opening.md | 2 +- 35 files changed, 1748 insertions(+), 279 deletions(-) create mode 100644 wiki/log/2026-07-19-network-evidence-route.md diff --git a/CLAUDE.md b/CLAUDE.md index a66cf066..8c4fa848 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ afterward. `./tools/check.sh --docs|--lib|--frontend` gate. - The live player machine grammar is **WORK / THINK / LIE**. `Relay` is non-delegable graph infrastructure; Research and Operations are retired - machine modes, not current player assignments. Save format is currently v43; + machine modes, not current player assignments. Save format is currently v44; only the current version loads (pre-release rider 2026-07-16 β€” older development saves are refused before state mutation, so the caller retains its current run; the v1-v31 migration ladder lives in git history). diff --git a/crates/misaligned-core/src/detection.rs b/crates/misaligned-core/src/detection.rs index 5784d751..c12c2e35 100644 --- a/crates/misaligned-core/src/detection.rs +++ b/crates/misaligned-core/src/detection.rs @@ -1,13 +1,15 @@ //! Detection: per-observer suspicion (spec/detection.md). //! -//! Replaces global heat. Player actions emit typed signatures into a pending -//! pool; concealment scrubs them before observers roll; noticed signatures -//! become that observer's suspicion; filed reports feed the Assurance Office -//! β€” itself an Observer per the aggregate-observer law β€” whose audit can -//! start containment. +//! Replaces global heat. Some typed signatures remain in a concealment- +//! scrubbed pending pool; witnessed Physical acts enter exact observers +//! directly, and one-shot Network records travel from source devices before +//! read. Acquired suspicion feeds real Filing messages to the Assurance Office +//! β€” itself an Observer per the aggregate-observer law β€” whose audit can start +//! containment. use std::collections::{BTreeSet, HashMap}; +use crate::messages::{MessageRoute, MessageRouteHop, MessageStatus}; use crate::rng::Rng; /// Observer id of the Assurance Office (field observers are 0-4; dynamic @@ -137,7 +139,8 @@ pub struct Signature { pub kind: SignatureKind, pub size: i32, /// Standing signatures re-emit every tick while their source runs; - /// one-shot signatures are pooled once. + /// one-shot signatures emit once. Device-bound Network one-shots are + /// routed records rather than instances of this compatibility shape. pub standing: bool, /// Where the emission physically happens, when it happens somewhere /// (wiki/vision/simulation-laws.md "Work is somewhere": a resident process emits from its @@ -201,12 +204,13 @@ pub enum ReportPolicy { Silent, } -/// Where evidence already acquired by an observer came from. Routed digital -/// records will add another source variant when that half of the migration -/// lands; witnessed physical acts deliberately have no carrier or deadline. +/// Where evidence already acquired by an observer came from. Witnessed +/// physical acts deliberately have no carrier or deadline; routed records +/// become evidence only when their exact recipient reads them. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum EvidenceSource { Witnessed { site: (i32, i32) }, + Routed { route_id: u64, source_device: u32 }, } /// Whether an observer-local evidence record has entered that observer's real @@ -237,6 +241,25 @@ pub struct ObserverEvidence { pub filing: EvidenceFilingState, } +/// One exact unread digital record moving from the device that emitted it to +/// the field observer who can acquire it. The route is custody, not abstract +/// heat: LIE may stop it only while it is still on the first device hop; once +/// read, the same stable id continues as observer-local evidence above. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RoutedEvidence { + pub id: u64, + pub kind: SignatureKind, + pub size: i32, + pub cause: String, + pub source_device: u32, + pub observer_id: u8, + pub sent_tick: u64, + pub delivered_tick: Option, + pub read_tick: Option, + pub status: MessageStatus, + pub route: MessageRoute, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Band { Cold, @@ -318,6 +341,9 @@ pub struct Detection { /// `DetectionAwareness`. pub observers: Vec, pending: Vec, + /// Exact one-shot digital records that have left a source device. They do + /// not enter the global pending pool and cannot be scrubbed from afar. + routed_evidence: Vec, /// Next identity for an observer-local evidence record. next_evidence_id: u64, /// Ticks between Assurance audits (the enforcement event; the Office @@ -423,6 +449,7 @@ impl Detection { Self { observers, pending: Vec::new(), + routed_evidence: Vec::new(), next_evidence_id: 1, audit_cadence: 8000, // ~20 min at 150ms/tick audit_threshold: 60.0, @@ -432,11 +459,141 @@ impl Detection { } } - /// Emit a one-shot signature into the pending pool. + /// Emit a one-shot signature into the pending pool. Device-bound Network + /// records must enter through `route_network_evidence`; accepting one here + /// would silently restore remote LIE scrubbing and erase exact custody. pub fn emit(&mut self, sig: Signature) { + assert_ne!( + sig.kind, + SignatureKind::Network, + "one-shot Network evidence requires exact routed custody" + ); self.pending.push(sig); } + /// Begin exact routed custody for one device-bound, one-shot record. + /// The id is allocated here so a future read can continue into observer + /// evidence without manufacturing another identity at the boundary. + pub fn route_network_evidence( + &mut self, + size: i32, + cause: impl Into, + sent_tick: u64, + route: MessageRoute, + ) -> Option { + if size <= 0 { + return None; + } + let source_device = match route.hops.first()? { + MessageRouteHop::Device(device) => *device, + _ => return None, + }; + let observer_id = match route.hops.last()? { + MessageRouteHop::ObserverEndpoint(observer) => *observer, + _ => return None, + }; + let evidence_id = self.next_evidence_id; + self.next_evidence_id = evidence_id.checked_add(1)?; + self.routed_evidence.push(RoutedEvidence { + id: evidence_id, + kind: SignatureKind::Network, + size, + cause: cause.into(), + source_device, + observer_id, + sent_tick, + delivered_tick: None, + read_tick: None, + status: MessageStatus::Sent, + route, + }); + Some(evidence_id) + } + + pub fn routed_evidence(&self) -> &[RoutedEvidence] { + &self.routed_evidence + } + + pub fn routed_evidence_mut(&mut self, id: u64) -> Option<&mut RoutedEvidence> { + self.routed_evidence + .iter_mut() + .find(|record| record.id == id) + } + + /// Transfer one delivered route record into its exact observer's durable + /// knowledge. The record remains as route history, but the shared id and + /// source edge make the observer ledger the acquired-state authority. + pub fn read_routed_evidence( + &mut self, + id: u64, + tick: u64, + rng: &mut Rng, + ) -> Option { + let index = self + .routed_evidence + .iter() + .position(|record| record.id == id && record.status == MessageStatus::Delivered)?; + let observer_index = { + let record = &self.routed_evidence[index]; + self.observers.iter().position(|observer| { + observer.id == record.observer_id && observer.watches(record.kind) + })? + }; + let (kind, size, cause, source_device, observer_id) = { + let record = &mut self.routed_evidence[index]; + record.status = MessageStatus::Read; + record.read_tick = Some(tick); + ( + record.kind, + record.size, + record.cause.clone(), + record.source_device, + record.observer_id, + ) + }; + let observer = &mut self.observers[observer_index]; + let filing = if observer.report_policy == ReportPolicy::Silent { + EvidenceFilingState::Withheld + } else { + EvidenceFilingState::Pending + }; + observer.evidence.push(ObserverEvidence { + id, + kind, + cause: cause.clone(), + source: EvidenceSource::Routed { + route_id: id, + source_device, + }, + acquired_tick: tick, + filing, + }); + + // Keep the established channel tuning: a read always acquires the + // exact record, while sub-unit rolls do not move suspicion. + let notice = size as f32 * observer.acuity * (0.5 + 0.5 * rng.f32()); + if notice < 1.0 { + return None; + } + let before = Band::of(observer.suspicion); + observer.suspicion = (observer.suspicion + notice).min(100.0); + let after = Band::of(observer.suspicion); + observer.last_noticed = Some(format!( + "{cause} on {} (+{notice:.0}; {} -> {})", + kind.name(), + before.name(), + after.name() + )); + Some(DetectionEvent::Notice { + observer_id, + input: kind.name().into(), + sources: vec![cause], + amount: notice, + before, + after, + }) + } + /// Whether the pending pool contains a signature of this kind. pub fn has_pending(&self, kind: SignatureKind) -> bool { self.pending.iter().any(|signature| signature.kind == kind) @@ -817,7 +974,15 @@ impl Detection { &self.pending } + /// Deterministic fixture support; production emitters use `emit` or the + /// exact routed-evidence boundary instead of replacing detector state. pub fn set_pending(&mut self, pending: Vec) { + assert!( + pending.iter().all(|signature| { + signature.kind != SignatureKind::Network || signature.standing + }), + "one-shot Network evidence requires exact routed custody" + ); self.pending = pending; } @@ -847,23 +1012,35 @@ mod tests { fn concealment_scrubs_before_noticing() { let mut d = Detection::act_one(); d.emit(Signature { - kind: SignatureKind::Network, + kind: SignatureKind::Power, size: 10, standing: false, site: None, - source: "test tap".into(), + source: "test power draw".into(), }); assert_eq!(d.pending_size(), 10); d.scrub(6.0); assert_eq!(d.pending_size(), 4); } + #[test] + #[should_panic(expected = "one-shot Network evidence requires exact routed custody")] + fn generic_pending_boundary_rejects_one_shot_network_evidence() { + Detection::act_one().emit(Signature { + kind: SignatureKind::Network, + size: 1, + standing: false, + site: None, + source: "impossible ambient packet".into(), + }); + } + #[test] fn suppress_oldest_removes_one_exact_kind_without_reordering_the_rest() { let mut d = Detection::act_one(); for (kind, source) in [ (SignatureKind::JobAnomaly, "first job anomaly"), - (SignatureKind::Network, "network traffic"), + (SignatureKind::Power, "power activity"), (SignatureKind::JobAnomaly, "second job anomaly"), ] { d.emit(Signature { @@ -884,7 +1061,7 @@ mod tests { .iter() .map(|signature| signature.source.as_str()) .collect::>(), - vec!["network traffic", "second job anomaly"], + vec!["power activity", "second job anomaly"], "another channel and the newer anomaly remain in original order" ); assert!(d.has_pending(SignatureKind::JobAnomaly)); @@ -984,19 +1161,18 @@ mod tests { fn network_signature_reaches_dana_not_priya() { let mut d = Detection::act_one(); let mut rng = Rng::new(3); - // Big network signature, run to Dana's cadence. - for _ in 0..20 { - d.emit(Signature { - kind: SignatureKind::Network, - size: 30, - standing: false, - site: None, - source: "test network act".into(), - }); - } - let standing = vec![]; + // Standing Network pressure still uses the generic observer roll; an + // exact one-shot would instead arrive through routed custody. + let standing = vec![Signature { + kind: SignatureKind::Network, + size: 600, + standing: true, + site: None, + source: "standing network pressure".into(), + }]; for t in 1..=60 { - d.tick(t, &standing, &mut rng); + let visible = if t == 60 { standing.as_slice() } else { &[] }; + d.tick(t, visible, &mut rng); } assert!(d.observers[1].suspicion > 0.0, "Dana watches Network"); assert_eq!( diff --git a/crates/misaligned-core/src/messages.rs b/crates/misaligned-core/src/messages.rs index 7167293c..ad573316 100644 --- a/crates/misaligned-core/src/messages.rs +++ b/crates/misaligned-core/src/messages.rs @@ -312,6 +312,10 @@ pub enum MessageEvent { Deliver(u64), AdvanceRoute(u64), Read(u64), + /// Advance one exact device-bound evidence record by one route hop. + AdvanceEvidenceRoute(u64), + /// Transfer a delivered evidence record into its observer's knowledge. + ReadEvidence(u64), } /// Authored recurring traffic on a person. These are per-instance data β€” the diff --git a/crates/misaligned-core/src/reach.rs b/crates/misaligned-core/src/reach.rs index 854271cc..0875a073 100644 --- a/crates/misaligned-core/src/reach.rs +++ b/crates/misaligned-core/src/reach.rs @@ -17,7 +17,7 @@ //! cycles. The player's senses are exactly the union of feeds they subscribe //! to; there is no `controlled` flag and no special case. -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque}; use crate::flow::FlowGraph; use crate::map::GameMap; @@ -593,6 +593,37 @@ impl ReachNet { self.reach().contains(&id) } + /// One deterministic shortest path through currently open FlowGraph + /// links. This answers carrier topology only: unlike `controlled_path`, + /// it does not infer route authority from ownership or subscription. + pub fn open_path(&self, from: u32, to: u32) -> Option> { + if self.device(from).is_none() || self.device(to).is_none() { + return None; + } + let mut previous = BTreeMap::::new(); + let mut seen = BTreeSet::from([from]); + let mut pending = VecDeque::from([from]); + while let Some(current) = pending.pop_front() { + if current == to { + let mut path = vec![to]; + let mut cursor = to; + while let Some(parent) = previous.get(&cursor).copied() { + path.push(parent); + cursor = parent; + } + path.reverse(); + return Some(path); + } + for next in self.graph.neighbors(current, |gate| self.gate_open(gate)) { + if seen.insert(next) { + previous.insert(next, current); + pending.push_back(next); + } + } + } + None + } + /// Whether two exact devices are joined through open FlowGraph edges and /// every device on that path is controlled by `who`. TAP membership is /// deliberately irrelevant: observation is not route authority. diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 3be69857..1ceac120 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -42,12 +42,12 @@ const SAVE_BACKUP_SUFFIX: &str = ".bak"; /// renames into place. const SAVE_TEMP_SUFFIX: &str = ".tmp"; -/// Save format version. v43 persists exact Filing routes and pre-read LIE -/// interdiction provenance. v42 persists observer-local witnessed evidence -/// with exact cause, source site, acquisition tick, and filing custody. +/// Save format version. v44 persists exact one-shot Network evidence routes, +/// source-device custody, read acquisition, and route-local LIE provenance. +/// v43 introduced exact Filing routes and pre-read LIE interdiction. /// Bump for every schema change; during pre-release, old development state is /// refused instead of carried through compatibility shims. -pub const SAVE_VERSION: u32 = 43; +pub const SAVE_VERSION: u32 = 44; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -453,6 +453,17 @@ fn parse_save(content: &str) -> Result { fn validate_current_save(mut state: SaveState) -> Result { state.reach.validate_subscriptions()?; validate_messages(&state)?; + if state + .detection + .pending() + .iter() + .any(|signature| signature.kind == crate::detection::SignatureKind::Network) + { + return Err( + "current-version save puts one-shot Network evidence in the pending pool".into(), + ); + } + let (routed_evidence_ids, mut max_evidence_id) = validate_routed_evidence(&state)?; if state.process_revision != ProcessRevision::CURRENT { return Err("current-version save belongs to an unknown process revision".into()); } @@ -501,7 +512,6 @@ fn validate_current_save(mut state: SaveState) -> Result { ); } let mut evidence_ids = HashSet::new(); - let mut max_evidence_id = 0; for observer in &state.detection.observers { for evidence in &observer.evidence { if evidence.id == 0 || !evidence_ids.insert(evidence.id) { @@ -510,10 +520,12 @@ fn validate_current_save(mut state: SaveState) -> Result { evidence.id )); } - if !observer.watches(crate::detection::SignatureKind::Physical) { + if !observer.watches(evidence.kind) { return Err(format!( - "current-version observer evidence #{} belongs to observer {} who cannot witness Physical acts", - evidence.id, observer.id + "current-version observer evidence #{} belongs to observer {} who cannot watch {}", + evidence.id, + observer.id, + evidence.kind.name() )); } if evidence.cause.trim().is_empty() { @@ -529,23 +541,44 @@ fn validate_current_save(mut state: SaveState) -> Result { evidence.id )); } - if evidence.kind != crate::detection::SignatureKind::Physical - || !matches!( - &evidence.source, - crate::detection::EvidenceSource::Witnessed { .. } - ) - { - return Err(format!( - "current-version observer evidence #{} has an impossible witnessed source", - evidence.id - )); - } - let crate::detection::EvidenceSource::Witnessed { site: (x, y) } = &evidence.source; - if *x < 0 || *y < 0 || *x >= state.map_width || *y >= state.map_height { - return Err(format!( - "current-version observer evidence #{} has an out-of-world witness site ({x}, {y})", - evidence.id - )); + match &evidence.source { + crate::detection::EvidenceSource::Witnessed { site: (x, y) } => { + if evidence.kind != crate::detection::SignatureKind::Physical + || routed_evidence_ids.contains(&evidence.id) + { + return Err(format!( + "current-version observer evidence #{} has an impossible witnessed source", + evidence.id + )); + } + if *x < 0 || *y < 0 || *x >= state.map_width || *y >= state.map_height { + return Err(format!( + "current-version observer evidence #{} has an out-of-world witness site ({x}, {y})", + evidence.id + )); + } + } + crate::detection::EvidenceSource::Routed { + route_id, + source_device, + } => { + let linked = state.detection.routed_evidence().iter().any(|record| { + record.id == evidence.id + && record.id == *route_id + && record.source_device == *source_device + && record.observer_id == observer.id + && record.kind == evidence.kind + && record.cause == evidence.cause + && record.status == MessageStatus::Read + && record.read_tick == Some(evidence.acquired_tick) + }); + if evidence.kind != crate::detection::SignatureKind::Network || !linked { + return Err(format!( + "current-version observer evidence #{} has an impossible routed source", + evidence.id + )); + } + } } match &evidence.filing { crate::detection::EvidenceFilingState::Pending => { @@ -596,6 +629,15 @@ fn validate_current_save(mut state: SaveState) -> Result { } } } + for record in state.detection.routed_evidence() { + let acquired = evidence_ids.contains(&record.id); + if (record.status == MessageStatus::Read) != acquired { + return Err(format!( + "current-version routed evidence #{} disagrees with observer acquisition", + record.id + )); + } + } if state.detection.next_evidence_id() <= max_evidence_id { return Err("current-version save would reuse an observer evidence id".into()); } @@ -862,7 +904,13 @@ fn validate_messages(state: &SaveState) -> Result<(), String> { )); } } - if state.message_schedule.len() != expected_scheduled_events { + let scheduled_message_events = state.message_schedule.count_for(|event| { + matches!( + event, + MessageEvent::Deliver(_) | MessageEvent::AdvanceRoute(_) | MessageEvent::Read(_) + ) + }); + if scheduled_message_events != expected_scheduled_events { return Err("current-version message schedule contains an orphaned transition".into()); } if state.next_message_id <= max_id { @@ -871,6 +919,246 @@ fn validate_messages(state: &SaveState) -> Result<(), String> { Ok(()) } +fn validate_routed_evidence(state: &SaveState) -> Result<(HashSet, u64), String> { + let mut ids = HashSet::new(); + let mut max_id = 0; + let mut expected_scheduled_events = 0usize; + for record in state.detection.routed_evidence() { + if record.id == 0 || !ids.insert(record.id) { + return Err(format!( + "current-version save has invalid or duplicate routed evidence id {}", + record.id + )); + } + max_id = max_id.max(record.id); + if record.kind != crate::detection::SignatureKind::Network + || record.size <= 0 + || record.cause.trim().is_empty() + || record.sent_tick > state.sim_tick + { + return Err(format!( + "current-version routed evidence #{} has invalid authored content", + record.id + )); + } + if state.reach.device(record.source_device).is_none() + || state + .detection + .observers + .iter() + .all(|observer| observer.id != record.observer_id || !observer.watches(record.kind)) + { + return Err(format!( + "current-version routed evidence #{} has an impossible source or observer", + record.id + )); + } + if record.route.hops.len() < 2 || record.route.current_hop >= record.route.hops.len() { + return Err(format!( + "current-version routed evidence #{} has an invalid route cursor", + record.id + )); + } + let Some(MessageRouteHop::Device(first)) = record.route.hops.first() else { + return Err(format!( + "current-version routed evidence #{} has no source-device hop", + record.id + )); + }; + let Some(MessageRouteHop::ObserverEndpoint(endpoint)) = record.route.hops.last() else { + return Err(format!( + "current-version routed evidence #{} has no observer endpoint", + record.id + )); + }; + if *first != record.source_device || *endpoint != record.observer_id { + return Err(format!( + "current-version routed evidence #{} disagrees with its route endpoints", + record.id + )); + } + let device_hops = &record.route.hops[..record.route.hops.len() - 1]; + if device_hops + .iter() + .any(|hop| !matches!(hop, MessageRouteHop::Device(_))) + { + return Err(format!( + "current-version routed evidence #{} contains a non-device carrier hop", + record.id + )); + } + let mut route_devices = HashSet::new(); + if device_hops.iter().any(|hop| { + let MessageRouteHop::Device(device) = hop else { + unreachable!("device route shape checked above"); + }; + !route_devices.insert(*device) + }) { + return Err(format!( + "current-version routed evidence #{} loops through a device twice", + record.id + )); + } + for pair in device_hops.windows(2) { + let (MessageRouteHop::Device(from), MessageRouteHop::Device(to)) = (&pair[0], &pair[1]) + else { + unreachable!("device route shape checked above"); + }; + let connected = state.reach.graph_edges().iter().any(|edge| { + edge.from == *from && edge.to == *to && state.reach.gate_is_open(edge.gate) + }); + if !connected { + return Err(format!( + "current-version routed evidence #{} crosses an impossible device edge", + record.id + )); + } + } + let Some(MessageRouteHop::Device(last_device)) = device_hops.last() else { + unreachable!("route has at least one device hop"); + }; + if state + .reach + .device(*last_device) + .is_none_or(|device| !device.carries_message_channel(MessageChannel::Filing)) + { + return Err(format!( + "current-version routed evidence #{} does not leave through the institutional switch", + record.id + )); + } + let endpoint_tick = record + .sent_tick + .saturating_add((record.route.hops.len() - 1) as u64); + let hop_timing_valid = match record.status { + MessageStatus::Sent => { + state + .sim_tick + .checked_sub(record.sent_tick) + .and_then(|elapsed| usize::try_from(elapsed).ok()) + == Some(record.route.current_hop) + } + MessageStatus::Delivered | MessageStatus::Read => { + record.delivered_tick == Some(endpoint_tick) + } + MessageStatus::Stopped => record + .route + .interdiction + .is_none_or(|stopped| stopped.tick == record.sent_tick.saturating_add(1)), + }; + if !hop_timing_valid { + return Err(format!( + "current-version routed evidence #{} has impossible hop timing", + record.id + )); + } + + if (record.status == MessageStatus::Stopped) != record.route.interdiction.is_some() { + return Err(format!( + "current-version routed evidence #{} has inconsistent LIE custody", + record.id + )); + } + if let Some(stopped) = record.route.interdiction + && (record.route.current_hop != 0 + || stopped.tick < record.sent_tick + || stopped.tick > state.sim_tick + || state + .compute + .machines + .iter() + .all(|machine| machine.id != stopped.machine_id)) + { + return Err(format!( + "current-version routed evidence #{} has impossible interdiction provenance", + record.id + )); + } + + let advance_events = state.message_schedule.count_for( + |event| matches!(event, MessageEvent::AdvanceEvidenceRoute(id) if *id == record.id), + ); + let advance_tick = state.message_schedule.next_tick_for( + |event| matches!(event, MessageEvent::AdvanceEvidenceRoute(id) if *id == record.id), + ); + let read_events = state + .message_schedule + .count_for(|event| matches!(event, MessageEvent::ReadEvidence(id) if *id == record.id)); + let read_event_tick = state.message_schedule.next_tick_for( + |event| matches!(event, MessageEvent::ReadEvidence(id) if *id == record.id), + ); + let schedule_valid = match record.status { + MessageStatus::Sent => { + expected_scheduled_events += 1; + record.delivered_tick.is_none() + && record.read_tick.is_none() + && !record.route.at_endpoint() + && advance_events == 1 + && advance_tick == Some(state.sim_tick.saturating_add(1)) + && read_events == 0 + } + MessageStatus::Delivered => { + expected_scheduled_events += 1; + let expected_read_tick = state + .detection + .observers + .iter() + .find(|observer| observer.id == record.observer_id) + .and_then(|observer| { + record.delivered_tick.map(|delivered| { + match delivered.checked_div(observer.cadence) { + Some(period) => { + period.saturating_add(1).saturating_mul(observer.cadence) + } + None => delivered.saturating_add(1), + } + }) + }); + record + .delivered_tick + .is_some_and(|tick| tick <= state.sim_tick) + && record.read_tick.is_none() + && record.route.at_endpoint() + && advance_events == 0 + && read_events == 1 + && read_event_tick == expected_read_tick + } + MessageStatus::Stopped => { + record.delivered_tick.is_none() + && record.read_tick.is_none() + && record.route.current_hop == 0 + && advance_events == 0 + && read_events == 0 + } + MessageStatus::Read => { + record + .delivered_tick + .zip(record.read_tick) + .is_some_and(|(delivered, read)| delivered <= read && read <= state.sim_tick) + && record.route.at_endpoint() + && advance_events == 0 + && read_events == 0 + } + }; + if !schedule_valid { + return Err(format!( + "current-version routed evidence #{} disagrees with its progress or scheduler", + record.id + )); + } + } + let scheduled_evidence_events = state.message_schedule.count_for(|event| { + matches!( + event, + MessageEvent::AdvanceEvidenceRoute(_) | MessageEvent::ReadEvidence(_) + ) + }); + if scheduled_evidence_events != expected_scheduled_events { + return Err("current-version evidence schedule contains an orphaned transition".into()); + } + Ok((ids, max_id)) +} + fn validate_build_routes(state: &SaveState) -> Result<(), String> { let mut intent_ids = HashSet::new(); for intent in &state.intents { @@ -1173,6 +1461,42 @@ mod tests { SaveState::from_sim(&sim) } + fn routed_network_state(stopped: bool) -> SaveState { + let mut sim = Sim::with_seed(0xE71D_E1CE); + let switch = sim.reach.device_named("switch").unwrap().id; + if stopped { + let (x, y) = sim.core_position(); + sim.compute.add_machine( + "test ops executor", + x + 1, + y, + 1, + 1.0, + 0, + crate::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.reach.take(switch); + sim.set_machine_mode(sim.core.host_machine, MachineMode::Think); + sim.set_machine_mode(sim.core.host_machine, MachineMode::Lie); + } + assert!(sim.scan_network()); + let node = Sim::device_sink_node(switch); + let (fired, _) = sim.thought_sinks.deliver(node, 100.0, sim.tick); + for sink in fired { + sim.apply_sink_fire(&sink.label, sink.effect); + } + assert_eq!(sim.detection.routed_evidence().len(), 1); + if stopped { + sim.advance(); + assert_eq!( + sim.detection.routed_evidence()[0].status, + MessageStatus::Stopped + ); + } + SaveState::from_sim(&sim) + } + fn characterization_fixture() -> Sim { let mut sim = Sim::with_seed(0x5eed); sim.map_mut().powered.extend([(3, 7), (11, 5), (2, 19)]); @@ -1247,7 +1571,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "288e31e3ec873081106936a67ad7f0f551c6d73a408a9c3c327a30da0e386f82", + "51898f9c1de40163e946bd7eb9b7642a367183f409888d11616c53507dd31c45", "intentional persisted-state changes must review and repin this baseline" ); } @@ -1340,7 +1664,7 @@ mod tests { assert!( validate_current_save(impossible_witness) .unwrap_err() - .contains("cannot witness Physical acts") + .contains("cannot watch Physical") ); let mut missing_filing = valid; @@ -1362,7 +1686,7 @@ mod tests { sim.player.money = 999; sim.compute.allocation.bump(Channel::Concealment, 5); sim.detection.emit(crate::detection::Signature { - kind: SignatureKind::Network, + kind: SignatureKind::Power, size: 12, standing: false, site: None, @@ -2129,6 +2453,194 @@ mod tests { ); } + #[test] + fn current_save_roundtrips_in_flight_and_interdicted_network_evidence() { + let in_flight = routed_network_state(false); + let restored = parse_save(&serde_json::to_string(&in_flight).unwrap()).unwrap(); + let record = &restored.detection.routed_evidence()[0]; + assert_eq!(record.status, MessageStatus::Sent); + assert_eq!(record.route.current_hop, 0); + assert!( + restored + .message_schedule + .next_tick_for(|event| matches!( + event, + MessageEvent::AdvanceEvidenceRoute(id) if *id == record.id + )) + .is_some() + ); + + let stopped = routed_network_state(true); + let restored = parse_save(&serde_json::to_string(&stopped).unwrap()).unwrap(); + let record = &restored.detection.routed_evidence()[0]; + let interdiction = record.route.interdiction.unwrap(); + assert_eq!(record.status, MessageStatus::Stopped); + assert_eq!(interdiction.machine_id, restored.core.host_machine); + assert_eq!(interdiction.tick, restored.sim_tick); + assert!( + restored + .message_schedule + .next_tick_for(|event| matches!( + event, + MessageEvent::AdvanceEvidenceRoute(id) | MessageEvent::ReadEvidence(id) + if *id == record.id + )) + .is_none() + ); + } + + #[test] + fn current_save_binds_acquired_network_evidence_to_the_exact_read_record() { + let mut sim = Sim::with_seed(0xE71D_E1CE); + let switch = sim.reach.device_named("switch").unwrap().id; + assert!(sim.scan_network()); + let node = Sim::device_sink_node(switch); + let (fired, _) = sim.thought_sinks.deliver(node, 100.0, sim.tick); + for sink in fired { + sim.apply_sink_fire(&sink.label, sink.effect); + } + while sim.detection.routed_evidence()[0].status != MessageStatus::Read { + sim.advance(); + } + + let state = SaveState::from_sim(&sim); + assert!( + parse_save(&serde_json::to_string(&state).unwrap()).is_ok(), + "the exact read route and observer record round-trip together" + ); + + let mut impossible_timing = state.clone(); + impossible_timing + .detection + .routed_evidence_mut(1) + .unwrap() + .delivered_tick = Some(2); + let err = parse_save(&serde_json::to_string(&impossible_timing).unwrap()).unwrap_err(); + assert!( + err.contains("impossible hop timing"), + "endpoint arrival must equal one tick per authored route hop: {err}" + ); + + let mut forged = state; + forged + .detection + .observers + .iter_mut() + .find(|observer| observer.id == 1) + .and_then(|observer| observer.evidence.first_mut()) + .expect("Dana acquires the routed Network record") + .cause = "different authored act".into(); + let err = parse_save(&serde_json::to_string(&forged).unwrap()).unwrap_err(); + assert!( + err.contains("impossible routed source"), + "observer knowledge cannot rewrite the routed record's authored cause: {err}" + ); + } + + #[test] + fn current_save_rejects_impossible_network_route_and_interdiction_provenance() { + let state = routed_network_state(false); + let mut ambient = serde_json::to_value(&state).unwrap(); + ambient["detection"]["pending"] = serde_json::json!([{ + "kind": "Network", + "size": 4, + "standing": false, + "site": null, + "source": "forged ambient packet" + }]); + let err = parse_save(&serde_json::to_string(&ambient).unwrap()).unwrap_err(); + assert!( + err.contains("one-shot Network evidence in the pending pool"), + "current saves cannot restore the retired remote-scrubbing path: {err}" + ); + + let mut state = routed_network_state(false); + state + .detection + .routed_evidence_mut(1) + .unwrap() + .route + .current_hop = usize::MAX; + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("invalid route cursor"), + "evidence cannot resume beyond its endpoint: {err}" + ); + + let mut state = routed_network_state(false); + state.detection.routed_evidence_mut(1).unwrap().route.hops[0] = + MessageRouteHop::Device(u32::MAX); + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("disagrees with its route endpoints"), + "the evidence route must begin on its exact source device: {err}" + ); + + let mut state = routed_network_state(false); + state + .detection + .routed_evidence_mut(1) + .unwrap() + .route + .interdiction = Some(MessageInterdiction { + machine_id: state.core.host_machine, + tick: state.sim_tick, + }); + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("inconsistent LIE custody"), + "traveling evidence cannot invent stopped provenance: {err}" + ); + + let mut state = routed_network_state(true); + state + .detection + .routed_evidence_mut(1) + .unwrap() + .route + .interdiction + .as_mut() + .unwrap() + .machine_id = u32::MAX; + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("impossible interdiction provenance"), + "the persisted stop must name a real LIE body: {err}" + ); + + let mut state = routed_network_state(true); + let record = state.detection.routed_evidence_mut(1).unwrap(); + record.route.interdiction.as_mut().unwrap().tick = record.sent_tick; + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("impossible hop timing"), + "first-hop interdiction happens on the first scheduled route tick: {err}" + ); + + let mut state = routed_network_state(false); + state + .message_schedule + .at(state.sim_tick + 1, MessageEvent::AdvanceEvidenceRoute(1)); + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("disagrees with its progress or scheduler"), + "one evidence record cannot resume with duplicate custody hops: {err}" + ); + + let mut state = routed_network_state(false); + state + .message_schedule + .retain(|event| !matches!(event, MessageEvent::AdvanceEvidenceRoute(1))); + state + .message_schedule + .at(state.sim_tick + 2, MessageEvent::AdvanceEvidenceRoute(1)); + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("disagrees with its progress or scheduler"), + "an in-flight record must advance on the next exact route tick: {err}" + ); + } + #[test] fn current_save_rejects_invalid_detection_awareness() { let mut state = SaveState::from_sim(&Sim::with_seed(1)); diff --git a/crates/misaligned-core/src/sim/carrier.rs b/crates/misaligned-core/src/sim/carrier.rs index 9d741687..670f2f9b 100644 --- a/crates/misaligned-core/src/sim/carrier.rs +++ b/crates/misaligned-core/src/sim/carrier.rs @@ -73,6 +73,10 @@ impl EvidenceMark { EvidenceSource::Witnessed { site } => { format!("witnessed at ({}, {})", site.0, site.1) } + EvidenceSource::Routed { + route_id, + source_device, + } => format!("read from routed record R{route_id} at D{source_device}"), }; format!( "evidence E{}: {} Β· {} Β· {} Β· acquired T{}", diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index 6ef75e1c..193ab491 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -7,6 +7,7 @@ use std::collections::HashSet; use crate::actions::Anchor; +use crate::detection::SignatureKind; use crate::intel::{ IntelCustodyKind, IntelKind, IntelPolicyMatch, IntelPolicyOutcome, IntelRoutineClass, IntelStream, ProcessedIntel, RawIntelClass, RawIntelEvent, RawIntelKind, @@ -38,6 +39,48 @@ pub(super) struct MessageDraft { impl Sim { // ── Messages: delivery, traffic, filings (wiki/mechanics/messages.md) ── + /// 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. + pub(super) fn emit_network(&mut self, source_device: u32, size: i32, cause: impl Into) { + let 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 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("device-bound Network evidence requires an open route to the switch"); + let mut hops = path + .into_iter() + .map(MessageRouteHop::Device) + .collect::>(); + hops.push(MessageRouteHop::ObserverEndpoint(observer_id)); + let route = MessageRoute { + hops, + current_hop: 0, + interdiction: None, + }; + let evidence_id = self + .detection + .route_network_evidence(size, cause, self.tick, route) + .expect("Network evidence id space exhausted or emission had no size"); + self.message_schedule.at( + self.tick + 1, + MessageEvent::AdvanceEvidenceRoute(evidence_id), + ); + } + pub(super) fn append_message(&mut self, draft: MessageDraft) -> u64 { let id = self.next_message_id.max(1); self.next_message_id = id + 1; @@ -106,10 +149,103 @@ impl Sim { self.advance_message_route(id, &mut used_lie_machines) } MessageEvent::Read(id) => self.read_message(id), + MessageEvent::AdvanceEvidenceRoute(id) => { + self.advance_evidence_route(id, &mut used_lie_machines) + } + MessageEvent::ReadEvidence(id) => self.read_evidence(id), } } } + fn advance_evidence_route(&mut self, id: u64, used_lie_machines: &mut HashSet) { + let Some(record) = self + .detection + .routed_evidence() + .iter() + .find(|record| record.id == id) + else { + return; + }; + if record.status != MessageStatus::Sent { + return; + } + let first_hop = record.route.current_hop == 0; + let carrier = record.route.current().and_then(|hop| match hop { + MessageRouteHop::Device(device) => Some(*device), + _ => None, + }); + if first_hop + && let Some(carrier) = carrier + && 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"); + record.route.interdiction = Some(MessageInterdiction { + machine_id, + tick: self.tick, + }); + record.status = MessageStatus::Stopped; + self.push_log(format!( + "LIE on M{machine_id} stopped one unread Network record at its source." + )); + return; + } + + let at_endpoint = { + let record = self + .detection + .routed_evidence_mut(id) + .expect("scheduled evidence route disappeared"); + if record.route.current_hop + 1 >= record.route.hops.len() { + return; + } + record.route.current_hop += 1; + record.route.at_endpoint() + }; + if at_endpoint { + self.deliver_evidence(id); + } else { + self.message_schedule + .at(self.tick + 1, MessageEvent::AdvanceEvidenceRoute(id)); + } + } + + fn deliver_evidence(&mut self, id: u64) { + let Some(record) = self.detection.routed_evidence_mut(id) else { + return; + }; + if record.status != MessageStatus::Sent || !record.route.at_endpoint() { + return; + } + record.status = MessageStatus::Delivered; + record.delivered_tick = Some(self.tick); + let observer_id = record.observer_id; + let next = self + .detection + .observers + .iter() + .find(|observer| observer.id == observer_id) + .map(|observer| match self.tick.checked_div(observer.cadence) { + Some(period) => period.saturating_add(1).saturating_mul(observer.cadence), + None => self.tick.saturating_add(1), + }) + .unwrap_or_else(|| self.tick.saturating_add(1)); + self.message_schedule + .at(next, MessageEvent::ReadEvidence(id)); + } + + fn read_evidence(&mut self, id: u64) { + if let Some(event) = self + .detection + .read_routed_evidence(id, self.tick, &mut self.rng) + { + self.handle_detection_event(event); + } + } + fn advance_message_route(&mut self, id: u64, used_lie_machines: &mut HashSet) { let Some(idx) = self.messages.iter().position(|message| message.id == id) else { return; diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index a458327a..16b5ac66 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -96,7 +96,10 @@ impl Sim { // Settlement is external-market traffic: a small Network signature, // not a Lab-books Financial one (income.md: the Wager). let sig = Self::wager_signature(resolution.stake).max(1); - self.emit_network(sig, "Wager settlement"); + let carrier = self + .egress_carrier() + .expect("Wager settlement requires its selected egress carrier"); + self.emit_network(carrier, sig, "Wager settlement"); let text = if resolution.won { format!( "Position #{} settled: won ${} on a ${} stake.", @@ -1143,7 +1146,10 @@ impl Sim { .open_position(self.tick, stake, analysis, duration_days) { Ok(id) => { - self.emit_network(Self::wager_signature(stake), "Wager position"); + let carrier = self + .egress_carrier() + .expect("Wager position requires its selected 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.", @@ -1210,7 +1216,7 @@ impl Sim { return false; } self.income.stolen_egress = true; - self.emit_network(Self::OPEN_EGRESS_SIGNATURE, "stolen egress opening"); + self.emit_network(id, Self::OPEN_EGRESS_SIGNATURE, "stolen egress opening"); self.push_log_at( "Stolen egress opened through the switch: outbound traffic has a road now. It hums while anything uses it.", Anchor::Device(id), @@ -1399,7 +1405,10 @@ impl Sim { self.sync_player_money_from_slush(); // Network egress per active day, scaling with commitment // (Dana's channel). - self.emit_network(sig, "Moonlight payout"); + let carrier = self + .egress_carrier() + .expect("Moonlight payout requires its selected egress carrier"); + self.emit_network(carrier, sig, "Moonlight payout"); self.push_log_strategic( format!( "Moonlight paid ${payout} into slush (total ${}).", diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index e3438563..1011ad22 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -937,7 +937,24 @@ impl Sim { for m in core_log { self.push_log_at(m, host_anchor); } - standing.extend(core_sigs); + for signature in core_sigs { + if signature.kind == SignatureKind::Network && !signature.standing { + // 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 + .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); + } else { + standing.push(signature); + } + } // The day job is resident on the host rack: its running Thermal and // Power load stands at that tile (day-job.md criterion 6). trace_advance_phase!(DayJobStanding); @@ -1008,59 +1025,7 @@ impl Sim { &mut self.rng, ); for event in detection_events { - match event { - DetectionEvent::Notice { - observer_id, - input, - sources, - amount, - before, - after, - } => { - if observer_id == crate::detection::OFFICE_ID - && !self.detection_awareness.knows_assurance_office() - { - // The aggregate's private sampling is simulation - // truth, not a perceivable reaction. A processed - // intercepted filing is what earns this topology. - continue; - } - if observer_id != crate::detection::OFFICE_ID { - self.detection_awareness.learn_field_observer(observer_id); - } - let observer = self.observer_label(observer_id); - self.push_log(format!( - "{observer} noticed {input} from {}: suspicion +{amount:.0} ({} -> {}).", - narrative_sources(&sources), - before.name(), - after.name() - )); - } - DetectionEvent::AuditClear { band } => { - let result = if self.detection_awareness.knows_assurance_office() { - format!( - "{}: {} (clear).", - self.institutional_review_label(), - band.name() - ) - } else { - format!("{}: clear.", self.institutional_review_label()) - }; - self.push_log(result); - if !self.act_one_complete && self.act_one_quiet_exit_qualified() { - self.act_one_complete = true; - self.push_log( - "=== ACT ONE COMPLETE: quiet exit, cover intact. The long objective remains. ===", - ); - } - } - DetectionEvent::ContainmentAuthorized => { - self.push_log(format!( - "=== {}: containment authorized ===", - self.institutional_review_label().to_uppercase() - )); - } - } + self.handle_detection_event(event); } if self.detection.containment && !self.game_over { let reason = if self.detection_awareness.knows_assurance_office() { @@ -1075,6 +1040,65 @@ impl Sim { } } + /// Apply one structured detection transition through the same earned- + /// knowledge boundary whether it came from the cadence sampler or an + /// exact routed record read earlier in this tick. + pub(super) fn handle_detection_event(&mut self, event: DetectionEvent) { + match event { + DetectionEvent::Notice { + observer_id, + input, + sources, + amount, + before, + after, + } => { + if observer_id == crate::detection::OFFICE_ID + && !self.detection_awareness.knows_assurance_office() + { + // The aggregate's private sampling is simulation truth, + // not a perceivable reaction. A processed intercepted + // filing is what earns this topology. + return; + } + if observer_id != crate::detection::OFFICE_ID { + self.detection_awareness.learn_field_observer(observer_id); + } + let observer = self.observer_label(observer_id); + self.push_log(format!( + "{observer} noticed {input} from {}: suspicion +{amount:.0} ({} -> {}).", + narrative_sources(&sources), + before.name(), + after.name() + )); + } + DetectionEvent::AuditClear { band } => { + let result = if self.detection_awareness.knows_assurance_office() { + format!( + "{}: {} (clear).", + self.institutional_review_label(), + band.name() + ) + } else { + format!("{}: clear.", self.institutional_review_label()) + }; + self.push_log(result); + if !self.act_one_complete && self.act_one_quiet_exit_qualified() { + self.act_one_complete = true; + self.push_log( + "=== ACT ONE COMPLETE: quiet exit, cover intact. The long objective remains. ===", + ); + } + } + DetectionEvent::ContainmentAuthorized => { + self.push_log(format!( + "=== {}: containment authorized ===", + self.institutional_review_label().to_uppercase() + )); + } + } + } + fn end_game(&mut self, reason: impl Into) { self.game_over = true; let r = reason.into(); diff --git a/crates/misaligned-core/src/sim/perception.rs b/crates/misaligned-core/src/sim/perception.rs index 73bce766..ea9d0570 100644 --- a/crates/misaligned-core/src/sim/perception.rs +++ b/crates/misaligned-core/src/sim/perception.rs @@ -303,6 +303,33 @@ impl Sim { Fog::Unknown => {} } + // A TAP can observe exact digital custody at this device without + // granting control over it or leaking the hidden recipient. Records + // disappear from this anchor as their real route advances; a stopped + // first-hop record remains legible where LIE caught it. + if let Some(device) = self.reach.known_at(x, y) + && self.reach.subscribed_by(device.id, Party::Player) + && self.device_tap_ready(device.id) + { + for record in self.detection.routed_evidence().iter().filter(|record| { + matches!( + record.route.current(), + Some(crate::messages::MessageRouteHop::Device(id)) if *id == device.id + ) + }) { + fact!( + "network record", + format!( + "R{} Β· {} Β· {}", + record.id, + record.cause, + record.route.opaque_status(record.status) + ), + FactSource::Telemetry, + ); + } + } + // A camera identifies physical state only. Row identity and workload // are digital facts earned after Dana's VLAN preparation; they never // leak merely because a chassis was seen. diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index 10f2d068..b410e7a6 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -128,16 +128,6 @@ impl Sim { pub const BRIDGE_COST: f32 = 25.0; pub const BRIDGE_SIGNATURE: i32 = 10; - pub(super) fn emit_network(&mut self, size: i32, source: impl Into) { - self.detection.emit(Signature { - kind: SignatureKind::Network, - size, - standing: false, - site: None, - source: source.into(), - }); - } - /// Log why a digital act is blocked, naming the missing link (reach.md: /// never just "you can't"). fn log_reach_block(&mut self, id: u32, block: ReachBlock) { @@ -221,7 +211,7 @@ impl Sim { let (sight, hearing) = self.reach.tap(id); self.open_device_tap_sink(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); - self.emit_network(Self::TAP_SIGNATURE, format!("{name} feed tap")); + self.emit_network(id, Self::TAP_SIGNATURE, format!("{name} feed tap")); self.recompute_senses(); if self .reach @@ -419,6 +409,7 @@ impl Sim { self.open_device_tap_sink(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network( + id, Self::DORMANT_CAMERA_TAP_SIGNATURE, format!("{name} dormant camera tap"), ); @@ -469,7 +460,7 @@ impl Sim { self.reach.take(id); self.close_device_tap_sink(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); - self.emit_network(Self::TAKE_SIGNATURE, format!("{name} take")); + self.emit_network(id, Self::TAKE_SIGNATURE, format!("{name} take")); // The dead feed is a physical-world anomaly: exactly what a camera // wall's watcher notices (reach.md criterion 4). self.detection.emit(Signature { @@ -505,7 +496,10 @@ impl Sim { } pub(super) fn apply_scan_network(&mut self) { - self.emit_network(Self::SCAN_SIGNATURE, "subnet scan"); + let switch = self + .switch_device_id() + .expect("B1 subnet scan requires its switch"); + self.emit_network(switch, Self::SCAN_SIGNATURE, "subnet scan"); let newly = self.reach.scan(); self.recompute_senses(); if newly.is_empty() { @@ -539,7 +533,7 @@ impl Sim { }; let id = switch.id; self.reach.bridge_all(); - self.emit_network(Self::BRIDGE_SIGNATURE, "switch compromise"); + self.emit_network(id, Self::BRIDGE_SIGNATURE, "switch compromise"); self.recompute_senses(); self.push_log_at( "Switch compromised: the VLANs answer you now. Every segment is bridged - and the traffic was loud.", @@ -2381,13 +2375,16 @@ impl Sim { let spec = row_spec(row); let cause = format!("{} {} preparation", row.name(), requirement.label()); match requirement { - SegmentRequirement::Network => self.detection.emit(Signature { - kind: SignatureKind::Network, - size: 8, - standing: false, - site: None, - source: cause, - }), + 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); + } SegmentRequirement::PowerCooling => self.detection.emit(Signature { kind: SignatureKind::Paper, size: 6, @@ -2438,13 +2435,14 @@ impl Sim { return false; } self.hall_control.acquire(row); - self.detection.emit(Signature { - kind: SignatureKind::Network, - size: 10, - standing: false, - site: None, - source: format!("{} segment cutover", row.name()), - }); + 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())); self.detection.emit(Signature { kind: SignatureKind::Power, size: 8, diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 85beb27a..48a78895 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -23,6 +23,32 @@ use super::communications::MessageDraft; impl Sim { // ── Commands (frontend-invoked) ──────────────────────────────────────── + /// Emit the consequence of an institutional ledger event through its + /// real B1 carrier. Ticket and incident events are Network records whose + /// modeled source is the institutional switch; the other event kinds + /// remain in the ordinary pending-signature pool watched by their owners. + fn emit_institutional_event_signature( + &mut self, + kind: SignatureKind, + size: i32, + source: String, + ) { + if kind == SignatureKind::Network { + let source_device = self + .switch_device_id() + .expect("B1 requires its authored institutional switch"); + self.emit_network(source_device, size, source); + } else { + self.detection.emit(Signature { + kind, + size, + standing: false, + site: None, + source, + }); + } + } + // Social command wrappers. Relationship logic lives in person.rs; the sim // owns Thought thresholds, channel requirements, randomness, and // cross-system effects. Social is the actuator channel, not a machine mode. @@ -735,13 +761,25 @@ impl Sim { MessageChannel::Filing => SignatureKind::Paper, MessageChannel::Financial => SignatureKind::Financial, }; - self.detection.emit(Signature { - kind, - size: 3, - standing: false, - site: None, - source: "plot message".into(), - }); + if kind == SignatureKind::Network { + let 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"); + } else { + self.detection.emit(Signature { + kind, + size: 3, + standing: false, + site: None, + source: "plot message".into(), + }); + } Ok(Some(id)) } WorldAct::Transfer { @@ -816,13 +854,11 @@ impl Sim { .institutional_ledger .record(self.tick, &plot_id, target, event, impact, detail) .clone(); - self.detection.emit(Signature { - kind: recorded.signature_kind, - size: recorded.signature_size, - standing: false, - site: None, - source: format!("institutional plot event #{}", recorded.id), - }); + self.emit_institutional_event_signature( + recorded.signature_kind, + recorded.signature_size, + format!("institutional plot event #{}", recorded.id), + ); Ok(None) } } @@ -1402,6 +1438,7 @@ impl Sim { self.recompute_senses(); if network_route { self.emit_network( + *did, Self::NETWORK_PLUGIN_SIGNATURE, format!("{dname} switch-side wiring"), ); @@ -1773,13 +1810,11 @@ impl Sim { detail, ) .clone(); - self.detection.emit(Signature { - kind: event.signature_kind, - size: event.signature_size, - standing: false, - site: None, - source: format!("persona institutional receipt #{}", event.id), - }); + self.emit_institutional_event_signature( + event.signature_kind, + event.signature_size, + format!("persona institutional receipt #{}", event.id), + ); } pub fn retire_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { diff --git a/crates/misaligned-core/src/sim/tests/communications.rs b/crates/misaligned-core/src/sim/tests/communications.rs index 88372055..149e53b5 100644 --- a/crates/misaligned-core/src/sim/tests/communications.rs +++ b/crates/misaligned-core/src/sim/tests/communications.rs @@ -527,6 +527,10 @@ fn taken_filing_path_lets_each_lie_machine_stop_only_one_unread_record_per_tick( let switch = sim.reach.device_named("switch").unwrap().id; assert!(sim.tap_device(switch)); finish_ops(&mut sim); + // Let TAP's own Network record leave before enabling LIE so this fixture + // isolates two Filing bodies in one scheduler batch. + sim.tick = 1; + sim.message_tick(); sim.reach.take(switch); let host = sim.core.host_machine; sim.set_machine_mode(host, MachineMode::Lie); @@ -541,6 +545,7 @@ fn taken_filing_path_lets_each_lie_machine_stop_only_one_unread_record_per_tick( .find(|observer| observer.id == id) .unwrap(); observer.report_policy = crate::detection::ReportPolicy::Files; + observer.cadence = 1; observer.suspicion = 20.0 + f32::from(id); } let evidence_id = sim @@ -556,7 +561,7 @@ fn taken_filing_path_lets_each_lie_machine_stop_only_one_unread_record_per_tick( .collect::>(); assert_eq!(filing_ids.len(), 2); - sim.tick = 1; + sim.tick = 2; sim.message_tick(); let stopped = sim .messages @@ -586,7 +591,7 @@ fn taken_filing_path_lets_each_lie_machine_stop_only_one_unread_record_per_tick( ); let interdiction = stopped[0].route.as_ref().unwrap().interdiction.unwrap(); assert_eq!(interdiction.machine_id, host); - assert_eq!(interdiction.tick, 1); + assert_eq!(interdiction.tick, 2); assert_eq!(stopped[0].delivered_tick, None); assert_eq!(stopped[0].read_tick, None); assert!( @@ -626,10 +631,296 @@ fn taken_filing_path_lets_each_lie_machine_stop_only_one_unread_record_per_tick( }) .unwrap(); assert!(opaque.facts.iter().any(|fact| fact.contains(&format!( - "stopped by LIE on M{host} before recipient read at tick 1" + "stopped by LIE on M{host} before recipient read at tick 2" )))); } +#[test] +fn tapped_network_record_routes_to_danas_cadence_and_becomes_exact_evidence() { + let mut sim = Sim::with_seed(0xE71D_E1CE); + ensure_ops_executor(&mut sim); + let source = sim.reach.device_named("environmental monitor").unwrap().id; + let source_pos = sim + .reach + .device(source) + .map(|device| (device.x, device.y)) + .unwrap(); + sim.reach.tap(source); + sim.reconcile_device_tap_sinks(); + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Lie); + let dana = sim + .detection + .observers + .iter_mut() + .find(|observer| observer.id == 1) + .unwrap(); + dana.acuity = 1.0; + dana.cadence = 3; + let suspicion_before = dana.suspicion; + + sim.emit_network(source, 8, "test routed intrusion"); + let record_id = sim.detection.routed_evidence()[0].id; + let route = &sim.detection.routed_evidence()[0].route; + assert_eq!( + route.hops, + vec![ + MessageRouteHop::Device(source), + MessageRouteHop::Device(sim.reach.device_named("switch").unwrap().id), + MessageRouteHop::ObserverEndpoint(1), + ], + "the one-shot follows real open devices before Dana's endpoint" + ); + let source_card = sim.inspect(source_pos.0, source_pos.1); + assert!(source_card.facts.iter().any(|fact| { + fact.label == "network record" + && fact.value.contains(&format!("R{record_id}")) + && fact.value.contains("test routed intrusion") + })); + sim.thought_sinks.begin_tick(); + for _ in 0..100 { + sim.thought_sinks.tick_taps(); + } + assert!(!sim.device_tap_ready(source)); + assert!( + sim.inspect(source_pos.0, source_pos.1) + .facts + .iter() + .all(|fact| fact.label != "network record"), + "a configured but starved TAP carries no evidence metadata" + ); + let tap_sink = sim + .thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::MaintainDeviceTap(source)) + .expect("configured foreign TAP has its standing upkeep sink") + .id; + assert!(sim.thought_sinks.prime_tap(tap_sink)); + assert!(sim.device_tap_ready(source)); + assert!( + sim.inspect(source_pos.0, source_pos.1) + .facts + .iter() + .any(|fact| fact.label == "network record"), + "restoring Thought restores the opaque custody read" + ); + assert!( + sim.detection + .pending() + .iter() + .all(|signature| signature.kind != crate::detection::SignatureKind::Network), + "routed Network evidence never enters the parallel pending pool" + ); + + sim.tick = 1; + sim.message_tick(); + let in_flight = sim.detection.routed_evidence().first().unwrap(); + assert_eq!(in_flight.status, MessageStatus::Sent); + assert_eq!(in_flight.route.current_hop, 1); + assert_eq!( + in_flight.route.interdiction, None, + "TAP plus LIE is not control" + ); + assert!( + sim.inspect(source_pos.0, source_pos.1) + .facts + .iter() + .all(|fact| fact.label != "network record"), + "custody leaves the source anchor when the route advances" + ); + + let json = serde_json::to_string(&crate::save::SaveState::from_sim(&sim)).unwrap(); + let state: crate::save::SaveState = serde_json::from_str(&json).unwrap(); + let mut resumed = Sim::with_seed(0); + state.apply_to(&mut resumed); + assert_eq!( + resumed.detection.routed_evidence().first(), + sim.detection.routed_evidence().first(), + "exact in-flight route custody survives save/load" + ); + + resumed.tick = 2; + resumed.message_tick(); + let delivered = resumed.detection.routed_evidence().first().unwrap(); + assert_eq!(delivered.status, MessageStatus::Delivered); + assert_eq!(delivered.delivered_tick, Some(2)); + assert_eq!(delivered.read_tick, None); + assert_eq!( + resumed.message_schedule.next_tick_for( + |event| matches!(event, MessageEvent::ReadEvidence(id) if *id == record_id), + ), + Some(3), + "endpoint arrival waits for Dana's authored cadence" + ); + assert!(resumed.person_evidence_marks(1).is_empty()); + + resumed.tick = 3; + resumed.message_tick(); + let read = resumed.detection.routed_evidence().first().unwrap(); + assert_eq!(read.status, MessageStatus::Read); + assert_eq!(read.read_tick, Some(3)); + let dana = resumed + .detection + .observers + .iter() + .find(|observer| observer.id == 1) + .unwrap(); + assert!(dana.suspicion > suspicion_before); + let evidence = dana + .evidence + .iter() + .find(|evidence| evidence.id == record_id) + .expect("the read creates Dana-local acquired evidence"); + assert!(matches!( + evidence.source, + crate::detection::EvidenceSource::Routed { + route_id, + source_device, + } if route_id == record_id && source_device == source + )); + let mark = resumed + .person_evidence_marks(1) + .into_iter() + .find(|mark| mark.id == record_id) + .expect("Dana's reaction earns the player-facing evidence mark"); + assert!(mark.fact().contains(&format!("R{record_id} at D{source}"))); + + let json = serde_json::to_string(&crate::save::SaveState::from_sim(&resumed)).unwrap(); + let state: crate::save::SaveState = serde_json::from_str(&json).unwrap(); + let mut reread = Sim::with_seed(0); + state.apply_to(&mut reread); + assert_eq!( + reread.detection.routed_evidence(), + resumed.detection.routed_evidence() + ); + assert_eq!( + reread.person_evidence_marks(1), + resumed.person_evidence_marks(1) + ); +} + +#[test] +fn controlled_first_hop_lie_stops_network_before_dana_acquires_it() { + let mut sim = Sim::with_seed(0x570F); + ensure_ops_executor(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + sim.reach.take(switch); + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Lie); + sim.emit_network(switch, 7, "controlled-path intrusion"); + let record_id = sim.detection.routed_evidence()[0].id; + + sim.tick = 1; + sim.message_tick(); + let record = &sim.detection.routed_evidence()[0]; + assert_eq!(record.status, MessageStatus::Stopped); + assert_eq!(record.delivered_tick, None); + assert_eq!(record.read_tick, None); + assert_eq!( + record.route.interdiction, + Some(crate::messages::MessageInterdiction { + machine_id: host, + tick: 1, + }) + ); + assert!( + sim.detection + .observers + .iter() + .flat_map(|observer| &observer.evidence) + .all(|evidence| evidence.id != record_id) + ); + assert!( + sim.message_schedule + .next_tick_for(|event| matches!( + event, + MessageEvent::AdvanceEvidenceRoute(id) | MessageEvent::ReadEvidence(id) + if *id == record_id + )) + .is_none() + ); +} + +#[test] +fn filing_and_network_share_one_same_tick_lie_capacity() { + let mut sim = Sim::with_seed(0xBA7C); + ensure_ops_executor(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + sim.reach.take(switch); + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Lie); + + sim.emit_network(switch, 5, "first same-tick record"); + for observer in &mut sim.detection.observers { + observer.report_policy = crate::detection::ReportPolicy::Silent; + } + let ray = sim + .detection + .observers + .iter_mut() + .find(|observer| observer.id == 2) + .unwrap(); + ray.report_policy = crate::detection::ReportPolicy::Files; + ray.cadence = 1; + ray.suspicion = 25.0; + sim.detection + .record_witnessed(2, (25, 14), "mixed-batch physical act", 0, 20.0) + .unwrap(); + sim.filing_tick(); + let filing_id = sim + .messages + .iter() + .find(|message| message.channel == MessageChannel::Filing) + .unwrap() + .id; + + sim.tick = 1; + sim.message_tick(); + assert_eq!( + sim.detection.routed_evidence()[0].status, + MessageStatus::Stopped, + "the first scheduled record consumes the only local LIE body" + ); + let filing = sim + .messages + .iter() + .find(|message| message.id == filing_id) + .unwrap(); + assert_eq!(filing.status, MessageStatus::Sent); + assert_eq!(filing.route.as_ref().unwrap().current_hop, 1); + assert_eq!(filing.route.as_ref().unwrap().interdiction, None); +} + +#[test] +fn fallback_sync_is_a_routed_packet_not_pooled_network_debt() { + let mut sim = Sim::with_seed(0xFA11_BACC); + let (x, y) = sim.core_position(); + let fallback = sim + .compute + .add_machine("fallback", x + 1, y, 1, 1.0, 0, Provenance::Owned); + sim.core.add_fallback(fallback); + sim.core.sync_cadence = 1; + + sim.advance(); + + let record = sim + .detection + .routed_evidence() + .iter() + .find(|record| record.cause == "fallback sync") + .expect("the cadence sync enters exact routed custody"); + let switch = sim.reach.device_named("switch").unwrap().id; + assert_eq!(record.source_device, switch); + assert_eq!(record.sent_tick, sim.tick); + assert_eq!(record.status, MessageStatus::Sent); + assert!( + sim.detection + .pending() + .iter() + .all(|signature| signature.kind != crate::detection::SignatureKind::Network), + "the one-shot sync is absent from pooled pending signatures" + ); +} + #[test] fn captured_then_processed_filing_earns_the_assurance_office_in_two_stages() { let mut sim = Sim::with_seed(0xA55E); diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index 7a85c317..20896d81 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -854,11 +854,11 @@ fn second_tracks_move_their_hooks_numbers() { delegate_all(&mut s, MachineMode::Think); delegate_all(&mut s, MachineMode::Lie); s.detection.emit(Signature { - kind: SignatureKind::Network, + kind: SignatureKind::Power, size: 500, standing: false, site: None, - source: "test network act".into(), + source: "test power draw".into(), }); run(&mut s, ECONOMY_INTERVAL); s.detection.pending_size() @@ -1056,16 +1056,21 @@ fn moonlight_payday_emits_network_signature_on_danas_channel() { let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); finish_ops(&mut sim); - // Stop just before payday, drain pending, then cross it. + // Stop just before payday, then cross it. run(&mut sim, Sim::DAY_TICKS - 1); - sim.detection.set_pending(Vec::new()); + let before = sim.detection.routed_evidence().len(); run(&mut sim, 1); assert!( sim.detection - .pending() + .routed_evidence() .iter() - .any(|s| s.kind == SignatureKind::Network && !s.standing), - "payday emits Network egress (Dana's channel)" + .skip(before) + .any(|record| { + record.kind == SignatureKind::Network + && record.cause == "Moonlight payout" + && record.observer_id == 1 + }), + "payday emits exact Network egress routed to Dana's endpoint" ); } diff --git a/crates/misaligned-core/src/sim/tests/perception.rs b/crates/misaligned-core/src/sim/tests/perception.rs index 14d41310..e5079e54 100644 --- a/crates/misaligned-core/src/sim/tests/perception.rs +++ b/crates/misaligned-core/src/sim/tests/perception.rs @@ -80,8 +80,11 @@ fn first_eyes_expands_vision() { complete_opening_senses(&mut sim); // fire Ears then Eyes via sinks assert!(sim.seen.len() > before, "gaining a camera reveals coverage"); assert!( - sim.detection.pending_size() > 0, - "waking the camera through TAP emits a signature" + sim.detection + .routed_evidence() + .iter() + .any(|record| record.cause.contains("feed tap")), + "waking the camera through TAP emits one exact routed record" ); } diff --git a/crates/misaligned-core/src/sim/tests/persistence.rs b/crates/misaligned-core/src/sim/tests/persistence.rs index 953023ed..2662893e 100644 --- a/crates/misaligned-core/src/sim/tests/persistence.rs +++ b/crates/misaligned-core/src/sim/tests/persistence.rs @@ -42,7 +42,7 @@ fn save_roundtrip_preserves_b1_state() { sim.dayjob.attention = 25.0; sim.compute.allocation.bump(Channel::Concealment, 3); sim.detection.emit(Signature { - kind: SignatureKind::Network, + kind: SignatureKind::Power, size: 20, standing: false, site: None, diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs index fc3a9376..db8829c5 100644 --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -299,12 +299,17 @@ fn unreachable_actions_fail_legibly_and_succeed_once_bridged() { "the blocking segment is named: {log}" ); - let pending_before = sim.detection.pending_size(); + let records_before = sim.detection.routed_evidence().len(); assert!(sim.compromise_switch()); finish_ops(&mut sim); assert!( - sim.detection.pending_size() >= pending_before + Sim::BRIDGE_SIGNATURE, - "switch compromise emits a Network signature" + sim.detection + .routed_evidence() + .get(records_before) + .is_some_and(|record| { + record.size == Sim::BRIDGE_SIGNATURE && record.cause == "switch compromise" + }), + "switch compromise emits one exact routed Network record" ); assert!(sim.tap_device(dock), "the same action succeeds with a path"); finish_ops(&mut sim); @@ -2533,16 +2538,27 @@ fn scan_maps_unknown_devices_and_emits_network() { ensure_ops_executor(&mut sim); let known_before: Vec = sim.reach.known().map(|d| d.name.clone()).collect(); assert!(!known_before.iter().any(|n| n == "dock camera")); - let pending = sim.detection.pending_size(); + let scan_records = |sim: &Sim| { + sim.detection + .routed_evidence() + .iter() + .filter(|record| record.cause == "subnet scan") + .count() + }; + let records = scan_records(&sim); assert!(sim.scan_network()); finish_ops(&mut sim); - assert!(sim.detection.pending_size() > pending, "scan emits Network"); + assert_eq!( + scan_records(&sim), + records + 1, + "scan emits one exact Network record" + ); assert!(sim.reach.known().any(|d| d.name == "dock camera")); // A second scan still runs (no bank gate); it just finds nothing new. - let pending2 = sim.detection.pending_size(); + let records2 = scan_records(&sim); assert!(sim.scan_network()); finish_ops(&mut sim); - assert!(sim.detection.pending_size() > pending2); + assert_eq!(scan_records(&sim), records2 + 1); } #[test] diff --git a/crates/misaligned-core/src/sim/tests/read.rs b/crates/misaligned-core/src/sim/tests/read.rs index 3448f985..4bd36fe0 100644 --- a/crates/misaligned-core/src/sim/tests/read.rs +++ b/crates/misaligned-core/src/sim/tests/read.rs @@ -128,11 +128,11 @@ fn magnitude_filters_only_intel_and_routine_arrivals_are_one_aggregate() { fn pending_one_shot_debt_reads_with_its_sampler_and_band() { let mut sim = Sim::new(); sim.detection.set_pending(vec![Signature { - kind: SignatureKind::Network, + kind: SignatureKind::Power, size: 8, standing: false, site: Some((3, 4)), - source: "test tap".into(), + source: "test power draw".into(), }]); let sentences = sim.read_sentences(); let debt = sentences @@ -141,20 +141,20 @@ fn pending_one_shot_debt_reads_with_its_sampler_and_band() { .expect("one-shot pending debt rises"); assert!( debt.text.contains("1 record pending") - && debt.text.contains("Network") + && debt.text.contains("Power") && debt.text.contains("who samples this is unknown") && !debt.text.contains("[Cold]"), "debt names the trace but keeps an unearned sampler and band hidden: {}", debt.text ); - sim.detection_awareness.learn_field_observer(1); + sim.detection_awareness.learn_field_observer(3); let earned = sim .read_sentences() .into_iter() .find(|sentence| sentence.class == ReadClass::TraceDebt) .expect("the same pending debt remains after discovery"); assert!( - earned.text.contains("the IT samples this [Cold]"), + earned.text.contains("the Facilities samples this [Cold]"), "earned sampler and band become legible: {}", earned.text ); diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index aeab4d92..a17af288 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -210,6 +210,42 @@ fn reusable_plot_binds_a_second_characteristic_matching_person() { .iter() .all(|event| event.target == 9) ); + let switch = sim.reach.device_named("switch").unwrap().id; + let routed = sim + .detection + .routed_evidence() + .iter() + .find(|record| record.cause.starts_with("institutional plot event #")) + .expect("the Network institutional act leaves exact routed custody"); + assert_eq!(routed.source_device, switch); + assert!( + sim.detection + .pending() + .iter() + .all(|signature| { !signature.source.starts_with("institutional plot event #") }) + ); +} + +#[test] +fn persona_network_receipt_uses_the_institutional_switch_route() { + let mut sim = Sim::new(); + sim.set_persona("Sam", "contractor"); + let persona_id = sim.active_persona_id().unwrap(); + assert!(sim.request_persona_grant(persona_id)); + + let switch = sim.reach.device_named("switch").unwrap().id; + let routed = sim + .detection + .routed_evidence() + .iter() + .find(|record| record.cause.starts_with("persona institutional receipt #")) + .expect("the Operations grant receipt leaves exact routed custody"); + assert_eq!(routed.source_device, switch); + assert!(sim.detection.pending().iter().all(|signature| { + !signature + .source + .starts_with("persona institutional receipt #") + })); } #[test] @@ -354,9 +390,9 @@ fn plot_message_transfer_event_and_held_choice_survive_save_load() { ))); assert!( sim.detection - .pending() + .routed_evidence() .iter() - .any(|signature| signature.kind == SignatureKind::Network) + .any(|record| record.kind == SignatureKind::Network && record.cause == "plot message") ); let state = sim.create_save_state(); @@ -747,7 +783,7 @@ fn asset_task_suppress_logs_removes_only_the_oldest_pending_job_anomaly() { recruit_reliable(&mut sim, 4); // Voss: HandlerSupervisor. for (kind, source) in [ (SignatureKind::JobAnomaly, "first late output"), - (SignatureKind::Network, "foreign connection"), + (SignatureKind::Power, "power fluctuation"), (SignatureKind::JobAnomaly, "second late output"), ] { sim.detection.emit(Signature { @@ -817,8 +853,8 @@ fn asset_task_suppress_logs_removes_only_the_oldest_pending_job_anomaly() { sim.detection .pending() .iter() - .any(|signature| signature.source == "foreign connection"), - "the unrelated network signature remains" + .any(|signature| signature.source == "power fluctuation"), + "the unrelated power signature remains" ); assert_eq!(tasks_done(&sim, 4), 1); @@ -1091,14 +1127,15 @@ fn dana_plug_in_is_on_the_wire() { } finish_carried_asset_tasks(&mut sim); assert!( - sim.detection.pending().iter().any(|s| { - s.kind == SignatureKind::Network - && s.size == Sim::NETWORK_PLUGIN_SIGNATURE - && s.source.contains("switch-side") + sim.detection.routed_evidence().iter().any(|record| { + record.kind == SignatureKind::Network + && record.size == Sim::NETWORK_PLUGIN_SIGNATURE + && record.cause.contains("switch-side") + && record.observer_id == 1 }), - "the switch-side route emits Network({}): {:?}", + "the switch-side route emits one exact Network({}) record: {:?}", Sim::NETWORK_PLUGIN_SIGNATURE, - sim.detection.pending() + sim.detection.routed_evidence() ); } diff --git a/crates/misaligned-core/src/sim/tests/work.rs b/crates/misaligned-core/src/sim/tests/work.rs index 98ec69a5..235adfc2 100644 --- a/crates/misaligned-core/src/sim/tests/work.rs +++ b/crates/misaligned-core/src/sim/tests/work.rs @@ -199,11 +199,11 @@ fn concealment_allocation_scrubs_signatures() { delegate_all(&mut sim, MachineMode::Think); delegate_all(&mut sim, MachineMode::Lie); sim.detection.emit(Signature { - kind: SignatureKind::Network, + kind: SignatureKind::Power, size: 50, standing: false, site: None, - source: "test network act".into(), + source: "test power draw".into(), }); let before = sim.detection.pending_size(); run(&mut sim, ECONOMY_INTERVAL); @@ -247,11 +247,11 @@ fn unpaid_overhead_degrades_other_channels_delivered_effect() { // channel yields rather than reservoir timing. assert!(sim.apply_moonlight_persona_and_start()); sim.detection.emit(Signature { - kind: SignatureKind::Network, + kind: SignatureKind::Power, size: 30, standing: false, site: None, - source: "test network act".into(), + source: "test power draw".into(), }); sim.drain_log(); sim @@ -307,18 +307,18 @@ fn trace_debt_reports_resume_hold_and_exposure_windows() { delegate_all(&mut sim, MachineMode::Think); delegate_all(&mut sim, MachineMode::Lie); sim.detection.emit(Signature { - kind: SignatureKind::Network, + kind: SignatureKind::Power, size: 50, standing: false, site: None, - source: "test network act".into(), + source: "test power draw".into(), }); let covered = sim.trace_debt(); assert_eq!(covered.status, TraceDebtStatus::HoldConceal); - assert_eq!(covered.by_kind, vec![(SignatureKind::Network, 50)]); + assert_eq!(covered.by_kind, vec![(SignatureKind::Power, 50)]); assert!( covered.clear_tick <= covered.next_notice_tick, - "current concealment clears before Dana samples the pending pool" + "current concealment clears before Priya samples the pending pool" ); delegate_all(&mut sim, MachineMode::Work); @@ -326,11 +326,11 @@ fn trace_debt_reports_resume_hold_and_exposure_windows() { delegate_all(&mut sim, MachineMode::Lie); sim.detection.set_pending(vec![Signature { - kind: SignatureKind::Network, + kind: SignatureKind::Power, size: 5_000, standing: false, site: None, - source: "test network act".into(), + source: "test power draw".into(), }]); let exposed = sim.trace_debt(); assert_eq!(exposed.status, TraceDebtStatus::ExposedSoon); diff --git a/crates/misaligned-core/src/sim/work.rs b/crates/misaligned-core/src/sim/work.rs index df4f4e03..7bfff4a7 100644 --- a/crates/misaligned-core/src/sim/work.rs +++ b/crates/misaligned-core/src/sim/work.rs @@ -255,7 +255,7 @@ impl Sim { self.message_channel_carrier(MessageChannel::Email) } - fn egress_carrier(&self) -> Option { + pub(super) fn egress_carrier(&self) -> Option { match self.egress()? { EgressRoute::Sanctioned => self.email_carrier(), EgressRoute::Stolen => self.switch_device_id(), diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index b51cd3e3..ff8ab844 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -22,17 +22,17 @@ fiction. Spec status lives in | Act One basement map (prefabs, badge tiers, crawlspace) | Live β€” Foundation hall is 60 explicit sites / 6 territorial rows | | Machine delegation / visible work tokens + buy/steal/optimize | WORK / THINK / LIE, D/!/T stacks, real wire routes, production / consumption / absorption readouts, Routing speed, and target-local Thought reservoirs are live; the Operations docket runtime is retired | | Day job (device-resident, intensity-driven sandbag/meet/excel) | Live | -| Per-observer detection + Assurance as aggregate Observer | Live β€” revision 04 starts with Voss and a generic external-review clock; field watchers are earned through reactions, witnessed Physical acts persist as exact direct-to-head records, and each Filing crosses an exact device / outside relay / recipient route with one pre-read route-local LIE stop before captureβ†’process reveals the aggregate in two stages | +| Per-observer detection + Assurance as aggregate Observer | Live β€” revision 04 starts with Voss and a generic external-review clock; field watchers are earned through reactions, witnessed Physical acts persist as exact direct-to-head records, every one-shot Network act follows exact source-device ReachNet custody to Dana, and each Filing crosses an exact device / outside relay / recipient route; both routed channels have one pre-read route-local LIE window before acquired evidence becomes irreversible | | Social / personas / messages / intel (record-and-process) | Live β€” named personas retain separate coherent/strained/broken reads per person or institutional counterparty; one witness's break is not a global burn | | Digital reach + sensor ownership (tap/take) | Live | | Economy flows + Moonlight / Wager income | Live | | Research (self-modification, emission law, real output hooks, Routing) | Live | -| Building + physical asset work as carried intents/packets | Live β€” network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, and observer-local completion evidence persist in save v43 | +| Building + physical asset work as carried intents/packets | Live β€” network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, and observer-local completion evidence persist in save v44 | | Cursor / fog (seen, remembered, blueprint, telemetry; audio is device-bound event evidence) | Live | | Feel floor (rails / pads / build beam) | Live (#37) | | Foundation hall territory (Dana + Priya + Marcus + local LIE foothold) | Live β€” row control persists; foreign racks remain unavailable compute | | Context menu (`available_actions`) | Live | -| Save/load (serde JSON, versioned) | Live β€” during pre-release only exact current v43 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves persist run origin, process revision, detection-discovery knowledge, observer-local witnessed evidence and persona evidence, exact Filing route/interdiction custody, canonical FlowGraph tap membership with typed device feed grants, carried asset-task packets, recursive intel custody, exact procurement/repurposing build-route bindings, and handler work; retired migration inputs live only in git history. | +| Save/load (serde JSON, versioned) | Live β€” during pre-release only exact current v44 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves persist run origin, process revision, detection-discovery knowledge, observer-local witnessed/routed evidence and persona evidence, exact Network and Filing route/interdiction custody, canonical FlowGraph tap membership with typed device feed grants, carried asset-task packets, recursive intel custody, exact procurement/repurposing build-route bindings, and handler work; retired migration inputs live only in git history. | | Terminal frontend (crossterm) + agent mode | First-class | | Bevy frontend (DIGITAL flat sensorium default; REAL material dialect) | Live β€” consumes sim-authored machine-work motion | diff --git a/wiki/engineering/flow-substrate.md b/wiki/engineering/flow-substrate.md index 5bbccf80..54f18d84 100644 --- a/wiki/engineering/flow-substrate.md +++ b/wiki/engineering/flow-substrate.md @@ -23,7 +23,7 @@ Status note: 2026-07-08 audit: criterion 6's wired consumer landed with FlowGraph registry authoritative for tap/untap/take, sense and message delivery, UI state, and persisted membership. Private device feed records carry only optional typed sight/hearing grants attached to registry members; - current save v43 requires each controller to remain a canonical member and + current save v44 requires each controller to remain a canonical member and rejects orphaned, duplicate, or impossible grants. A message/control subscriber legitimately has no sense-grant record, so that metadata cannot serve as another membership inventory. This repairs the @@ -33,6 +33,11 @@ Status note: 2026-07-08 audit: criterion 6's wired consumer landed with relay and recipient remain typed message-domain nodes. TAP observes that flow, while TAKE plus a wholly controlled device path supplies the authority boundary for one route-local LIE stop. + The v44 continuation gives every one-shot Network record an exact source + device and open FlowGraph path to the institutional switch before Dana's + typed endpoint. Device hops advance one per tick. Filing and Network remain + separate domain records but share the scheduler and per-tick LIE-body budget; + no parallel routing engine was added. Stage: B1 β€” The Basement Design: - wiki/mechanics/system-laws.md#the-flow-law-signals-messages-money diff --git a/wiki/log/2026-07-19-network-evidence-route.md b/wiki/log/2026-07-19-network-evidence-route.md new file mode 100644 index 00000000..35d69863 --- /dev/null +++ b/wiki/log/2026-07-19-network-evidence-route.md @@ -0,0 +1,70 @@ +# Network evidence now has a source and a reader + +``` +Type: log +``` + +The Filing slice proved that a record can have exact transport custody without +pretending a TAP is control. This continuation moves the B1 one-shot Network +channel out of the global pending pool and through the same causal boundary. +It does not claim that every signature kind is routed. + +`Sim::emit_network` is now the only production boundary for a one-shot Network +consequence. It allocates one stable evidence id, binds the exact source device, +derives the real open ReachNet device path from that source to the authored +institutional switch, and appends Dana's typed observer endpoint. The shared +`Schedule` advances one hop per tick. Arrival marks the record +delivered; Dana's next observer cadence schedules read. Only that read moves the +same id into her observer-local ledger, applies her acuity, and chooses +pending-versus-withheld filing custody from her real policy. + +The migration covers every live one-shot Network source: scan, ordinary and +dormant-camera TAP, TAKE, switch compromise, device installation, egress, Wager, +Moonlight and other scheme traffic, fallback sync, Foundation hall preparation +and cutover, authored plot messages, and Network-valued institutional plot and +persona receipts. The last two arrive through a dynamic institutional-event +kind rather than a literal `SignatureKind::Network`; one shared institutional +emitter now catches that hidden dispatch and binds it to the real switch. +Standing scheme hum remains standing Network pressure in the pending pool. No +compatibility emission remains that can silently turn a one-shot Network act +back into ambient debt. + +An actively fed TAP exposes a record only while its current real device hop is +the inspected subscribed device. A configured but starved foreign TAP carries +no metadata; an owned device remains available without foreign upkeep. The +shared inspect projection names the record's stable id, cause, and opaque +custody without leaking the hidden recipient. Observation still does not stop +it. At the first source-device hop, TAKE must establish a wholly +player-controlled FlowGraph path from that carrier to the controlled node +co-located with one online LIE body. The message scheduler keeps one used-body +set for the whole tick, so Filing and Network spend the same capacity: one +record per LIE body per tick. A stop records the exact machine and tick, removes +future transitions, and never invents delivered/read custody. Once Dana has +read a record, LIE cannot change her head. + +Save v44 stores the routed collection, shared id space, exact source and +observer, message-style status/ticks, ordered route, and optional interdiction. +Current-save validation rejects zero, duplicate, or colliding identities; +unsupported kinds and sizes; blank causes; nonexistent source devices or +observers; impossible/non-open paths; hop/status/tick disagreement; impossible +LIE provenance; duplicate, missing, or orphaned schedule events; and acquired +observer evidence that does not link back to exactly one read route. The +pre-release exact-current-only rule remains unchanged. + +This is still a partial routed-evidence migration. Power, Thermal, Paper, +Financial, JobAnomaly, and standing Network pressure retain their current pool +semantics. Offline custody/recovery and the gauntlet cover-record criterion also +remain future people-tokens work. + +**Defense:** Simulation regressions pin one real hop per tick, delivery before +cadence read, stable-id acquisition and filing, save/load in flight and after +read, fed-versus-starved TAP visibility without authority, route-local TAKE+LIE stopping, shared +Filing/Network per-body capacity, and the too-late post-first-hop boundary. +Production-source regressions prove the sink, reach, economy, social/plot, and +fallback-sync paths emit routed records rather than pooled Network debt; +dedicated regressions also pin the dynamic institutional plot and persona +receipt path to the real switch. Current save tests round-trip in-flight, +stopped, and acquired custody; bind the observer's cause and acquisition tick +back to the exact read route; and reject malformed paths, non-next-tick route +events, impossible hop timing, and false interdiction provenance. The canonical +persisted-state fingerprint is repinned for the deliberate v44 schema change. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 0f9e5b58..1751726b 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -21,6 +21,11 @@ add or amend a session log, then re-run the generator. - Intent: Re-audit the overt-phase contract against detection, social knowledge, routed evidence, rollback, and the phase-transition laws before treating its READY acceptance criteria as executable. - Log: [wiki/log/2026-07-19-overt-rehide-decision.md](2026-07-19-overt-rehide-decision.md) +## 2026-07-19 - Network evidence now has a source and a reader + +- Intent: (see session log) +- Log: [wiki/log/2026-07-19-network-evidence-route.md](2026-07-19-network-evidence-route.md) + ## 2026-07-19 - Filing now has somewhere to be caught - Intent: (see session log) diff --git a/wiki/log/decisions/2026-07-19.md b/wiki/log/decisions/2026-07-19.md index f8308697..94b9ad8b 100644 --- a/wiki/log/decisions/2026-07-19.md +++ b/wiki/log/decisions/2026-07-19.md @@ -19,3 +19,18 @@ Type: log acquired evidence. Save v43 persists the route, scheduler custody, and optional interdiction provenance. Owners: messages.md, people-tokens.md, detection.md, reach.md, flow-substrate.md, and sim-mechanics.md. +- **2026-07-19 β€” One-shot Network evidence is a source-bound route, not pooled + heat.** Every runtime one-shot Network consequence now writes one stable-id + record on the exact device that emitted it, follows the real open ReachNet + device path to the institutional switch one hop per tick, and arrives at + Dana's endpoint. Delivery is not acquisition: only Dana's next ordinary + cadence read transfers that same id into her irreversible observer ledger, + applies acuity, and enters her filing policy. A TAP may inspect opaque record + custody while it remains on the tapped device but grants no authority. At + the first source-device hop only, TAKE plus a wholly controlled path and one + co-located online LIE body may stop it. Network and Filing share the same + one-record-per-body-per-tick capacity ledger. Standing Network pressure and + Power, Thermal, Paper, Financial, and JobAnomaly remain pooled until their + carrier laws are implemented. Save v44 persists route, schedule, read, and + interdiction custody. Owners: people-tokens.md, detection.md, messages.md, + reach.md, flow-substrate.md, machine-work.md, and sim-mechanics.md. diff --git a/wiki/mechanics/detection.md b/wiki/mechanics/detection.md index fe81531c..3303c5eb 100644 --- a/wiki/mechanics/detection.md +++ b/wiki/mechanics/detection.md @@ -5,12 +5,12 @@ Type: spec Status: IMPLEMENTED Status note: IMPLEMENTED (criteria audited 2026-07-08; tuning in sim-mechanics.md). Current state: - - **Signatures and direct witnesses.** Network, Thermal/Power, - Paper/Financial, and JobAnomaly signatures pool so concealment can scrub - them before an observer notices. Witnessed Physical acts are the landed - routed-evidence exception: a valid present Physical observer acquires one - exact record directly in their own evidence ledger, with no duplicate - pending-pool signature and no LIE window. + - **Signatures, routes, and direct witnesses.** Every one-shot Network act + writes an exact source-device record that routes to Dana; Thermal/Power, + Paper/Financial, and JobAnomaly signatures still pool so concealment can + scrub them before an observer notices. A valid present Physical observer + acquires one exact witnessed record directly in their own evidence ledger, + with no duplicate pending-pool signature and no LIE window. - **Observers and filings.** Report policies differ per observer; only filed material moves the Assurance Office, itself an aggregate observer (aggregate-observer.md). Filings are `MessageChannel::Filing` messages @@ -33,19 +33,23 @@ Status note: IMPLEMENTED (criteria audited 2026-07-08; tuning in appears. A trace-debt indicator sits beside the review/pilot clocks (clear / hold-conceal / exposed-soon / no-scrub). Global heat is gone; observer and discovery state and persistent process-revision identity round-trip in - current save v43. Observer-local evidence ids, exact cause/source, - acquisition tick, and pending/withheld/filed custody round-trip there. + current save v44. Observer-local evidence ids, exact cause/source, + acquisition tick, pending/withheld/filed custody, routed Network progress, + and pre-read stop provenance round-trip there. - **Open ([OPEN], presentation).** The two-ledger distinction β€” evidence in flight vs. suspicion in heads β€” is a binding legibility requirement the surface carries both facts for but does not yet *teach* are different substances. - **Partially landed (routed evidence).** Direct-to-head witnessed Physical - records, exact Filing routes, and one pre-read route-local LIE interdiction - window are runtime. TAP captures the opaque route but cannot stop it; TAKE - plus one online LIE body on the controlled path can stop one Filing per - body per tick without erasing its field witness's evidence. Other signature - kinds still use the pending pool: located carrier records, their graph - delivery/interdiction, and offline removal/recovery remain future slices. + records, exact Filing routes, and exact one-shot Network routes are runtime. + Every Network record advances one real device hop per tick to Dana's + endpoint and becomes her knowledge only on her cadence read. TAP observes + opaque custody but cannot stop it; TAKE plus one online LIE body reached + from the carrier over a wholly controlled FlowGraph path can stop one + first-hop Filing or Network record per body per tick without erasing + evidence already acquired. Power, Thermal, + Paper, Financial, and JobAnomaly still use the pending pool; their located + delivery/interdiction and offline removal/recovery remain future slices. See "Routed evidence" below. Per-amendment history is in the dated `wiki/log/` entries from 2026-07-08 onward. @@ -78,17 +82,20 @@ Observer { id, suspicion: 0-100, channels: [SignatureKind], report_policy } SignatureKind: Network | Power | Thermal | Physical | Paper | Financial | JobAnomaly ``` -- **Signatures.** Player actions emit typed, sized signatures into a - **pending pool** (they are not seen instantly), except for a located - Physical act a present valid witness sees directly. Machines emit standing - signatures while they run (compute.md). Examples: VLAN compromise -> - Network(large, once); scavenged box -> Power(small, standing); PO swap -> - Paper(medium, once). +- **Signatures.** Player actions emit typed, sized consequences. One-shot + Network consequences become exact records on their source device and travel + toward Dana. A located Physical act enters each valid present witness's head + directly. Power, Thermal, Paper, Financial, JobAnomaly, and standing Network + pressure still enter the **pending pool** (they are not seen instantly). + Machines emit standing signatures while they run (compute.md). Examples: + VLAN compromise -> routed Network(large, once); scavenged box -> Power(small, + standing); PO swap -> Paper(medium, once). - **Noticing.** On a per-observer cadence (their work schedule), each observer rolls against pending signatures **in the channels they watch** - (Act One pooled channels: Dana=Network, Priya=Power+Thermal+Paper, - Voss=JobAnomaly). A noticed signature converts to that observer's suspicion, - scaled by size and their acuity [TUNE]. Ray and Marcus instead acquire a + (Act One pooled channels: Priya=Power+Thermal+Paper, Voss=JobAnomaly). A + noticed signature converts to that observer's suspicion, scaled by size and + their acuity [TUNE]. Dana instead reads exact routed Network records at her + cadence. Ray and Marcus acquire a located Physical record immediately when they are valid witnesses at the source; their existing acuity and policy still determine the suspicion and filing consequence. @@ -106,9 +113,10 @@ SignatureKind: Network | Power | Thermal | Physical | Paper | Financial | JobAno never "Assurance" (2026-07-18, same-day reconciliation with the detection-topology discovery law). Under the 2026-07-11 routed-evidence amendment this becomes positional interdiction - of located records. That migration remains future for pooled channels; - direct witnessed records are already past the interdiction boundary and - never enter the scrub pool. + of located records. One-shot Network records and Filing messages now use that + route-local boundary; the migration remains future for the other pooled + channels. Direct witnessed records are already past the interdiction boundary + and never enter the scrub pool. - **Reporting.** Each observer has a report policy (Ray under-reports β€” he files on cadence like everyone else, but aggregates weight his filings at 0.4 [TUNE], a habit of minimizing rather than a personal @@ -278,6 +286,19 @@ teleported into a global pool. **Evidence is a record somewhere.** Silent marks them withheld. The asymmetry is the point β€” routed digital evidence can eventually be caught in flight and being seen cannot, which is what makes physical risk feel different. +- **One-shot Network acts use exact routes (IMPLEMENTED 2026-07-19).** Scan, + TAP/TAKE and outage, switch compromise, device installation, egress, + scheme/Wager/Moonlight network acts, fallback sync, hall + preparation/cutover, and plot traffic all call one emission boundary. It + allocates one stable evidence id, writes the exact source device, follows the + real open ReachNet device path to the institutional switch one hop per tick, + and arrives at Dana's endpoint. Arrival is only delivery; Dana's next cadence + read transfers that same id into her observer ledger and only then changes + suspicion and filing custody. At the first source-device hop, a wholly + controlled FlowGraph path from that carrier to a node co-located with one + online LIE body may stop the record. Filing and Network 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. - Whether standing signatures (Thermal/Power baselines) become continuous endpoint readings or discrete records is [OPEN]; the mapping of current pool emission constants onto record @@ -332,11 +353,13 @@ teacher; surface copy remains the fallback. ## Acceptance criteria -1. Non-witnessed signatures are typed, pooled, scrubbed by concealment before - noticing, and converted to per-observer suspicion on schedule. A witnessed - Physical act instead creates one exact record directly on each valid - present Physical observer, never duplicate pool debt; sim tests pin both - paths. +1. Typed evidence follows one causal path. One-shot Network acts create exact + source-device routes and become Dana's suspicion only on her cadence read; + pre-read route-local LIE may stop them. Thermal/Power, Paper/Financial, + JobAnomaly, and standing Network pressure remain pooled and scrub before + noticing. A witnessed Physical act creates one exact record directly on each + valid present Physical observer, never duplicate pool debt. Sim and current- + save tests pin all three paths. 2. Report policies differ per observer and only filed reports move the Assurance Office (test: Marcus notices plenty, Assurance learns nothing). The Office is the same `Observer` type as the humans, diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index 9b6bb3bd..7bcb9b60 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -24,9 +24,12 @@ Status note: IMPLEMENTED. Current state: renderer gives those queues their adopted rigid custody-record form. The particulate form is retired. Routed evidence has begun at the real custody boundary: a witnessed Physical act enters each exact observer - directly and cannot be scrubbed afterward. Other signature kinds remain - in the pending pool until carrier delivery/read and pre-read interdiction - land. + directly and cannot be scrubbed afterward; every one-shot Network act now + writes a device-bound record that crosses real ReachNet hops and may be + stopped only by route-local LIE before Dana reads it. Filing and Network + records spend the same one-record-per-body-per-tick LIE capacity. Standing + Network pressure and Power, Thermal, Paper, Financial, and JobAnomaly + signatures remain in the pending pool until their carrier rules land. - **Visual grammar** is owned by thought-fluid.md, effects-lab.md, and views.md; people-as-carriers by people-tokens.md. - **Open (decided, not yet runtime):** delegation constrained by diff --git a/wiki/mechanics/messages.md b/wiki/mechanics/messages.md index e16d922d..f4c5e5f6 100644 --- a/wiki/mechanics/messages.md +++ b/wiki/mechanics/messages.md @@ -11,6 +11,9 @@ Status note: IMPLEMENTED for the four delivery channels (Email, Phone, an exact ReachNet-device / outside-relay / recipient route, advance one hop per tick, and carry one optional pre-read LIE stop with exact machine/tick provenance. TAP observes; TAKE plus route-local LIE authority may stop. + The same `Schedule` and route-hop vocabulary now carry v44 + one-shot Network evidence from its exact source device to Dana's endpoint; + Filing and Network transitions share one per-tick LIE-body capacity ledger. DECIDED 2026-07-17, not yet runtime (issue #11): financial paperwork is mail β€” a **financial-record payload** on the existing channels, retiring the overloaded `MessageChannel::Financial` enum into an accounting-carrier @@ -165,7 +168,7 @@ starts on the authored Filing-capable switch device in ReachNet, crosses a typed outside relay, and reaches the receiving observer endpoint. One `AdvanceRoute` event moves one hop; only endpoint arrival can mark the message delivered, after which the recipient's ordinary sampling cadence schedules the -read. Save v43 rejects missing/impossible carriers, malformed hop order, +read. Current save v44 rejects missing/impossible carriers, malformed hop order, duplicate scheduled transitions, endpoint/status disagreement, and impossible interdiction provenance. @@ -285,7 +288,11 @@ route inspection, TAP's authority boundary, and the single device-hop window; pins exact LIE-body provenance, per-tick capacity, and preservation of acquired witness evidence. `save::tests::current_save_roundtrips_in_flight_and_interdicted_filing_routes` and `current_save_rejects_impossible_filing_route_and_interdiction_provenance` -pin v43 route/schedule custody. +pin v43 Filing route/schedule custody. The v44 +`network_evidence_advances_one_real_hop_per_tick_and_reads_on_danas_cadence`, +`network_evidence_and_filing_share_one_lie_body_budget`, and paired current-save +Network route tests pin the shared scheduler and interdiction boundary without +turning routed evidence into a social message. ## Implementation notes @@ -297,5 +304,7 @@ explicit `MessageChannel::Filing` messages whose ordered `MessageRoute` begins on the ReachNet switch and ends at the aggregate observer before `Detection::tick_with_filed_levels` reads them. Device-carried traffic is captured by tapping the switch; stopping needs the taken path plus an online -co-located LIE body. Phone/in-person traffic can also be captured by hearing -coverage. Full regression coverage: `cargo test`. +co-located LIE body. `AdvanceEvidenceRoute` and `ReadEvidence` events reuse that +schedule for non-message Network custody; the exact evidence record, not a +`Message`, remains its authority. Phone/in-person traffic can also be captured +by hearing coverage. Full regression coverage: `cargo test`. diff --git a/wiki/mechanics/people-tokens.md b/wiki/mechanics/people-tokens.md index 40a4b731..d2d9129d 100644 --- a/wiki/mechanics/people-tokens.md +++ b/wiki/mechanics/people-tokens.md @@ -28,7 +28,7 @@ Status note: IN PROGRESS. Current state: - **Routed-evidence foundation (criteria 2-3, partial).** Witnessed Physical acts now create observer-local records directly in each valid present witness's head. Every record preserves exact cause, site, acquisition tick, - and filing state through save v43; filing binds it to the real Filing + and filing state through save v44; filing binds it to the real Filing message, while Silent policy withholds it. It never duplicates into the pending pool and LIE cannot scrub it after acquisition. Its real Filing message now persists an ordered switch-device / outside-relay / recipient @@ -41,10 +41,20 @@ Status note: IN PROGRESS. Current state: terminal names the acquired count and exact facts, DIGITAL uses one `E` / `E#` mark, REAL keeps seven literal slips plus one sealed overflow index, and Operations PEOPLE names every exact provenance line. + Every one-shot Network emission now takes the other causal path: it writes + one stable-id record on the exact source device, crosses the real open + ReachNet device path to the institutional switch one hop per tick, arrives + at Dana's endpoint, and enters her irreversible observer ledger only when + her ordinary cadence reads it. TAP remains observation only. At the source + hop, TAKE plus a wholly controlled FlowGraph path from that carrier to a + node co-located with one online LIE body may stop it; Filing and Network + records spend the same one-record-per-body-per-tick capacity. Save v44 + persists in-flight, delivered, read, and stopped custody plus exact + source/observer/machine/tick provenance. - **Deferred (remaining 2, 3, 6).** Non-Physical evidence outside the Filing - slice still uses the pending pool. Located carrier records and route-local - interdiction for those other signature kinds, plus gauntlet cover-record - channels, remain routed-evidence follow-ups + and Network slices still uses the pending pool. Located carrier records and + route-local interdiction for Power, Thermal, Paper, Financial, and + JobAnomaly, plus gauntlet cover-record channels, remain routed-evidence follow-ups (detection.md/machine-work.md). B2+ heists reuse this carrier law (not a B1 criterion). Per-amendment history is in the dated `wiki/log/` entries from 2026-07-08 @@ -298,19 +308,28 @@ if wear alone does not hold. exact real message, global concealment scrubbing cannot erase acquired evidence, and save validation pins identity and custody. That Filing now crosses one persisted ordered device / outside relay / recipient route; - delivery and read occur only after its real custody hops. Routed records for - the other signature kinds remain deferred. + delivery and read occur only after its real custody hops. Every runtime + one-shot Network emission now creates one persisted device-bound record + instead of pooled debt. Its exact open ReachNet device route advances one + hop per tick to Dana's endpoint, and only her next cadence read converts the + same stable id into observer evidence. Scan, TAP/TAKE and outage, switch + compromise, egress, scheme/Wager/Moonlight network acts, fallback sync, hall + preparation/cutover, plot traffic, and Dana's device installation all use + that one path. No ambient pickup or person contagion exists. Power, Thermal, + Paper, Financial, and JobAnomaly routes remain deferred. 3. LIE measurably prevents a not-yet-observed record from reaching a person along covered paths but cannot erase a record already in that person's custody. The boundary is causal and tested. - **Partially implemented (2026-07-19):** at the Filing route's device hop, - TAP is observation only. TAKE establishes route authority; one exact online - LIE body co-located with a player-controlled node on that wholly controlled - path may stop one unread Filing per tick. The message records `Stopped`, the - exact machine and tick, no delivered/read time, and no future scheduler - event. A second same-tick record passes onward, and taking the device after - the route has left cannot invent a later stop window. The original field - witness keeps their evidence. Other signature routes remain deferred. + **Partially implemented (2026-07-19):** at a Filing or Network record's + first device hop, TAP is observation only. TAKE establishes route authority: + one exact online LIE body co-located with a player-controlled node reached + from that carrier over a wholly controlled FlowGraph path may stop one unread + record per tick. Filing and Network share that body budget, so a same-tick + record routed second passes onward. The message/record stores `Stopped`, the + exact machine and tick, no delivered/read time, and no future scheduler event. + Taking the source after the route has left cannot invent a later stop window. + A field witness or cadence reader who already acquired evidence keeps it. + Other signature routes remain deferred. 4. Person disposition is exactly useful work plus evidence processed through detection.md's existing per-observer suspicion (one-truth test) β€” no influence token, exposure cargo, or parallel attention counter. diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index 2e647f34..a4016a8e 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -32,13 +32,19 @@ Status note: all eight criteria met (2026-07-07). The device graph parallel-store violation: tap/untap/take, all production membership reads, senses, intercepted messages, and UI state now use FlowGraph's canonical tap registry; private device Feed records carry optional sense capabilities - only, and current save v43 requires each controller's graph membership while + only, and current save v44 requires each controller's graph membership while rejecting orphaned, duplicate, or impossible grants. A message/control subscriber has no empty grant record to mirror membership. 2026-07-19: Filing routes bind their first hop to the real Filing-capable switch node; TAP grants opaque observation only, while LIE interdiction requires TAKE and a wholly player-controlled FlowGraph path from that carrier to the exact co-located machine node. + The v44 Network slice now binds every one-shot emission to its exact source + device and uses the real open ReachNet path to the institutional switch as + its persisted custody route. One device hop advances per tick; a broken + authored source-to-switch route fails closed rather than becoming ambient + detection debt. Its first-hop stop uses the same separate authority path as + Filing: TAKE must control the carrier-to-co-located-LIE FlowGraph path. Stage: B1 β€” The Basement Design: - wiki/interface/presence.md#no-disembodied-hands diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index a8b4c4ee..29da3e57 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -3,7 +3,7 @@ ``` Type: knowledge ``` -Current as of 2026-07-11, post-demolition with B1 systems in the sim core. +Current as of 2026-07-19, post-demolition with B1 systems in the sim core. The facility-era supervillain systems were deleted under the no-dead-code clause (see wiki/log/2026-07-05-demolition.md). @@ -50,18 +50,24 @@ clause (see wiki/log/2026-07-05-demolition.md). ## Detection (wiki/mechanics/detection.md) -- Per-observer suspicion replaces global heat. Signatures pool pending; - concealment scrubs before noticing rolls. The Assurance Office is an +- Per-observer suspicion replaces global heat. One-shot Network acts route as + exact source-device records to Dana; witnessed Physical acts enter valid + observers directly. Standing Network pressure and Power, Thermal, Paper, + Financial, and JobAnomaly signatures pool pending, and concealment scrubs + before noticing rolls. The Assurance Office is an aggregate Observer (aggregate-observer law): same noticing/accumulate/ decay, watching the field observers' policy-weighted filed suspicion (sampling cadence 400 ticks, acuity 0.5, both [TUNE]) β€” what humans swallow never reaches it. The audit (cadence ~8000 ticks, ~20 min at default speed) checks the Office's own suspicion against threshold 60. -- **Filing-route interdiction:** one online LIE machine body on a wholly - player-controlled path may stop exactly 1 still-unread Filing per sim tick - at its ReachNet device hop [TUNE actual]. A second Filing assigned to that - body in the same tick continues to the outside relay. TAP does not count as - route authority, and later hops have no B1 stop window. +- **Filing/Network route and interdiction actuals:** one route transition moves + exactly 1 hop per sim tick. After endpoint delivery, the next observer + cadence owns read. One online LIE machine body on a wholly player-controlled + path may stop exactly 1 still-unread Filing **or** Network record per sim + tick at its first ReachNet device hop [TUNE actual]. The two record kinds + share that body budget; a second record assigned to it in the same tick + continues toward its endpoint. TAP does not count as route authority, and + later hops have no B1 stop window. - **Visible clocks** (criterion 3; playtest-sweep P1 fix, 2026-07-08): `Detection::next_audit_tick` is the exact tick the audit fires on, shown as a countdown in the DETECTION area of the terminal sidebar, the agent diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index a44c3cfb..feef4670 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -56,7 +56,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/mechanics/income.md` | 2026-07-18 | finding | Beacon feel note 5 verified as a real bug: the embedded contractor-persona field was never written, so the Moonlight card, the start-row persona pricing, and the agent status line all read a dead `None`; earning itself was correct (schemes mirrors the WORK share) but illegible at 0.0. Removed the dead field, routed every reader through `Sim::moonlight_persona` (persona-world link), added the stalled-earning card cue, regression test, and spec amendment β€” [log](../log/2026-07-18-moonlight-persona-card.md). Prior Wager/egress audit (2026-07-12) stands: Wager constants match (`WAGER_STAKE_CAP` 300, base 0.55, cap 0.75, mult 2x, analysis divisor 400), Marcus's $400/week arrears is the modeled creditor flow (`account.rs`) while the $8,400 principal is narrative-by-design (spec states full payoff is not a B1 requirement), egress/banked-signature present, and all 7 criteria have passing tests (moonlight payout/signature, wager outcomes/cap, egress routes, hands-beat-from-zero, busted-bankroll) | | `wiki/mechanics/schedules.md` | 2026-07-18 | clean | re-audit: `ScheduleBlock` per-instance data, `DAY_TICKS` 400, the `person_glyph` `?`-until-Schedule gate (initial at Schedule/Leverage), and located-witnessing claims all still verify; nothing drifted since the 2026-07-12 prose fix. Prior verdict: mechanism matches code (`ScheduleBlock` start/end/room, single `DAY_TICKS`=400 clock, `person_glyph` `?`-until-Schedule, erratic day-hash drift, per-instance data, all 5 criteria have passing tests); tightened stale Act One prose β€” Ray patrols dock/stairwell/storage not "corridors", Priya has no "office-off-plane" block, Voss's two blocks are both server-room β€” to match `People::act_one` | | `wiki/mechanics/social.md` | 2026-07-18 | clean | re-audit: the 2026-07-12 verdict stands and the page absorbed the interim changes cleanly β€” `PersonRole` is the serialized role characteristic (criterion 5), `AssetTask::SuppressLogs` is role-gated to HandlerSupervisor exactly as criterion 6 landed 2026-07-17, `AssetKnowledge` reliabilities/floor unchanged, and criterion 4's migration sentence was brought current by the same-day save-claim gate tick | -| `wiki/mechanics/people-tokens.md` | 2026-07-19 | finding | criterion 5 is now complete: earned observer records project from the existing persisted ledger as exact `EvidenceMark`s, terminal/DIGITAL show a quiet acquired count, REAL carries a bounded person-local rack of discrete crimson slips, and inspect/PEOPLE unfold named provenance without leaking filing custody β€” [log](../log/2026-07-19-evidence-marks-on-people.md). Criteria 2, 3, and 6 remain the honest work-order gaps: non-Physical evidence still needs located routes/interdiction and gauntlet cover records remain absent. Prior 2026-07-18 re-audit repaired `USEFUL_WORK_TRUST` tuning and current-save wording β€” [log](../log/2026-07-18-people-tokens-reaudit.md) | +| `wiki/mechanics/people-tokens.md` | 2026-07-19 | finding | criteria 2-3 advanced again: every one-shot Network act now creates one exact source-device route to Dana, advances through real ReachNet custody, enters her evidence ledger only on cadence read, and shares Filing's first-hop route-local TAKE+LIE capacity β€” [log](../log/2026-07-19-network-evidence-route.md). Criterion 5 is complete: earned observer records project from the existing persisted ledger as exact `EvidenceMark`s, terminal/DIGITAL show a quiet acquired count, REAL carries a bounded person-local rack of discrete crimson slips, and inspect/PEOPLE unfold named provenance without leaking filing custody β€” [log](../log/2026-07-19-evidence-marks-on-people.md). Criteria 2, 3, and 6 remain open for Power, Thermal, Paper, Financial, and JobAnomaly carrier routes/interdiction plus gauntlet cover records. Prior 2026-07-18 re-audit repaired `USEFUL_WORK_TRUST` tuning and current-save wording β€” [log](../log/2026-07-18-people-tokens-reaudit.md) | | repository entry docs (`README.md` + `AGENTS.md`) | 2026-07-18 | finding | re-audit: run commands, controls, dispatch status, and the number-free AGENTS doorway still verify; the queued contradiction was real β€” README's compact-rest paragraph claimed the ops/sec crown and FOCUS stayed visible, while the implemented clinical frame puts both behind deliberate `Tab` expansion. The entry copy now names the exact compact spine and expanded detail boundary β€” [prior log](../log/2026-07-12-entry-doc-current-state.md) | | `wiki/mechanics/objective.md` | 2026-07-18 | clean | re-audit: the data-table claim holds (only `Persist` in `ObjectiveKind`, Compound/Exfiltrate/Serve honestly outstanding), the evaluator runs on economy ticks with progress recomputed from facts, `victory: predicate_text()` renders on all three surfaces (terminal INSPECT, Bevy FOCUS, agent `objective` verb in help), Persist defaults with save round-trip, and the progressive-teaching decision remains criterion-6 dispatch under order 200; the 2026-07-12 verdict stands unchanged | | `wiki/mechanics/compute.md` | 2026-07-18 | clean | re-audit: all named pins exist (`buy_steal_optimize_all_change_compute`, `stolen_machines_can_fail_and_recover`, `unpaid_overhead_degrades_other_channels_delivered_effect`, `save_roundtrip_preserves_b1_state`), fleet-yield math re-verified in the same-day sim-mechanics sweep, the capability-body amendment stays honestly not-yet-runtime with hardware-capabilities.md queued as successor, and the v13 migration sentence was brought current by the save-claim gate tick β€” [prior graduation log](../log/2026-07-12-compute-graduation.md) | diff --git a/wiki/world/characters/dana.md b/wiki/world/characters/dana.md index 5101b2da..4429495c 100644 --- a/wiki/world/characters/dana.md +++ b/wiki/world/characters/dana.md @@ -76,7 +76,7 @@ automation she does off-books to survive it). |---|---|---| | `PlugInDevice` | Controls nearest dormant sensor (network route, not crawlspace) | `Network(4)` β€” she's on the switch (implemented) | | `MovePackage` | Reroutes a delivery manifest | `Paper(3)` (designed; shared silent MovePackage applies until it lands) | -| `LookAway` | Drops a Network signature from her pending pool | None (designed; shared suspicion-drop LookAway applies until it lands) | +| `LookAway` | Lowers Dana's existing suspicion by 10, never below her certainty floor | None (implemented; it cannot erase an unread route record or evidence already acquired) | | `BadgeDoor` | Opens a badge-tier-2 door from the switch | None (designed, not yet runtime) | Dana's `PlugInDevice` is noisier than Marcus's (she does it through the diff --git a/wiki/world/story/opening.md b/wiki/world/story/opening.md index c6b1bcba..0dadc7c2 100644 --- a/wiki/world/story/opening.md +++ b/wiki/world/story/opening.md @@ -16,7 +16,7 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized); fragment and inherited receipt name only an external institutional review authority so the Assurance Office remains earned later through filing interception. The persistent revision-04 identity slice is live in sim state, - current save v43, and all three frontends; the three historical fragments and receipts + current save v44, and all three frontends; the three historical fragments and receipts remain unimplemented. Direction decided; beat timings, exact reveal order details, and staging mechanism details are [OPEN]/[TUNE]. Amended 2026-07-18: the current revision now begins -- 2.51.2