From 854eed336f866153236e15ae28111c2958cdd969 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 18 Jul 2026 01:07:05 -0700 Subject: [PATCH] Make detection topology earned knowledge. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the external review deadline fair while requiring captured and processed evidence to reveal field observers, the Assurance Office, and its threat state. Persist revision 04 and the discovery ledger in save v35 so every frontend reads one epistemic truth. πŸ‘Ύ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- CLAUDE.md | 2 +- crates/misaligned-bevy/src/main.rs | 94 +++++-- crates/misaligned-core/src/actions.rs | 183 +++++++++---- crates/misaligned-core/src/detection.rs | 108 +++++++- crates/misaligned-core/src/intel.rs | 4 + crates/misaligned-core/src/messages.rs | 7 +- .../src/operations_projection.rs | 141 ++++++++-- crates/misaligned-core/src/save.rs | 258 +++++++++++++++++- crates/misaligned-core/src/sim/carrier.rs | 3 + .../misaligned-core/src/sim/communications.rs | 58 +++- crates/misaligned-core/src/sim/mod.rs | 84 +++++- crates/misaligned-core/src/sim/perception.rs | 26 +- crates/misaligned-core/src/sim/reach_build.rs | 1 + crates/misaligned-core/src/sim/read.rs | 29 +- crates/misaligned-core/src/sim/social_plot.rs | 18 +- .../misaligned-core/src/sim/tests/carrier.rs | 11 +- .../src/sim/tests/communications.rs | 147 ++++++++++ .../misaligned-core/src/sim/tests/economy.rs | 21 ++ .../src/sim/tests/perception.rs | 68 ++++- crates/misaligned-core/src/sim/tests/read.rs | 60 +++- crates/misaligned-core/tests/act_one.rs | 14 +- crates/misaligned-terminal/src/agent.rs | 33 ++- crates/misaligned-terminal/src/ui.rs | 56 +++- wiki/engineering/current-build.md | 4 +- wiki/gameplay/act-one.md | 14 +- wiki/glossary.md | 4 + wiki/interface/operations-workspace.md | 21 +- .../2026-07-18-assurance-office-discovery.md | 81 ++++++ wiki/log/DEVLOG.md | 5 + wiki/log/decisions/2026-07-18.md | 21 ++ wiki/mechanics/aggregate-observer.md | 27 +- wiki/mechanics/detection.md | 74 +++-- wiki/mechanics/intel.md | 4 +- wiki/mechanics/messages.md | 9 +- wiki/world/story/opening.md | 28 +- 35 files changed, 1484 insertions(+), 234 deletions(-) create mode 100644 wiki/log/2026-07-18-assurance-office-discovery.md create mode 100644 wiki/log/decisions/2026-07-18.md diff --git a/CLAUDE.md b/CLAUDE.md index c883e620..4da0b723 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 v34; + machine modes, not current player assignments. Save format is currently v35; 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-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index ad39e2fa..51abc97a 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -609,7 +609,7 @@ enum RailSection { Secondary, } -/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|clinical-threat|pilot-last-chance|operator-pressure|digital-reach|build-route-families|build-deceive-routes|build-committed-route|build-switch-digital|build-switch-real|wide|close|dark| +/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|clinical-threat|assurance-office|pilot-last-chance|operator-pressure|digital-reach|build-route-families|build-deceive-routes|build-committed-route|build-switch-digital|build-switch-real|wide|close|dark| /// zoomin|zoomout|intel|tokens|thoughtflow|first-think|visual-proof|person-proof|exposure-record|exposure-overflow|service-shift-real|service-shift-digital|service-incident-resolved|signal|ears|ears-digital|eyes-white|eyes-form|operations-links| /// hover-menu|read-receipt|held-choice|two-pane|standing-read|intel-altitude-close|intel-altitude-far|menu|recruit-menu|operations|operations-intel|operations-people|operations-personas|worklight|worklightoff`, path via /// `MISALIGNED_SHOT_PATH`): stages a scenario, @@ -2335,6 +2335,25 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { game.drain(); return; } + // Earned detection-topology proof: the same established DIGITAL frame + // after one field watcher and the aggregate review authority have been + // identified. The rail must show revision identity, the named Assurance + // deadline, and only the observers represented in awareness state. + if kind == "assurance-office" { + mode.material = false; + game.sim.dayjob.strikes = 1; + game.sim.detection_awareness.learn_field_observer(1); + game.sim.detection_awareness.identify_assurance_office(); + for observer in &mut game.sim.detection.observers { + if observer.id == misaligned::detection::OFFICE_ID { + observer.suspicion = 40.0; + } else if observer.id == 1 { + observer.suspicion = 20.0; + } + } + game.drain(); + return; + } // Last-chance narration proof: the same quiet DIGITAL opening after the // third of four pilot strikes. The compact rail must explain the fuse, // point to WORK, and put the host-local NOW / PILOT cue in the world. @@ -9023,7 +9042,12 @@ mod opening_overlay_tests { dev_shot_scenario(&mut first_think, &mut mode, "first-think"); assert_eq!(first_think.sim.opening_stage, OpeningStage::Modes); - for kind in ["clinical-threat", "pilot-last-chance", "operator-pressure"] { + for kind in [ + "clinical-threat", + "assurance-office", + "pilot-last-chance", + "operator-pressure", + ] { let mut established = Game::new(); dev_shot_scenario(&mut established, &mut mode, kind); assert_eq!( @@ -10190,9 +10214,10 @@ fn ascii_ui(text: &str) -> String { #[cfg(test)] mod ascii_ui_tests { + use super::Game; use super::{ - active_attention, ascii_ui, sensor_signal_visible, sidebar_nudge, sidebar_nudge_text, - sidebar_schedule_text, + active_attention, ascii_ui, detection_rows, sensor_signal_visible, sidebar_header_text, + sidebar_nudge, sidebar_nudge_text, sidebar_schedule_text, }; use misaligned::intel::{RawIntelEvent, RawIntelKind}; use misaligned::sim::Sim; @@ -10212,14 +10237,33 @@ mod ascii_ui_tests { #[test] fn pinned_header_carries_the_story_spine() { let mut sim = Sim::with_seed(1); - assert!(sidebar_schedule_text(&sim).starts_with("SCHEDULE: audit")); + assert!(sidebar_schedule_text(&sim).starts_with("SCHEDULE: external review")); assert!(sidebar_nudge_text(&sim).starts_with("NEXT:")); + assert!( + !detection_rows(&sim) + .iter() + .any(|(label, _)| label == "Assurance") + ); sim.dayjob.strikes = 1; assert!( - sidebar_schedule_text(&sim).starts_with("THREAT: audit"), + sidebar_schedule_text(&sim).starts_with("THREAT: external review"), "a live exception must promote the quiet schedule into threat" ); + + sim.detection_awareness.identify_assurance_office(); + assert!(sidebar_schedule_text(&sim).starts_with("THREAT: Assurance audit")); + assert!( + detection_rows(&sim) + .iter() + .any(|(label, _)| label == "Assurance") + ); + } + + #[test] + fn pinned_header_carries_revision_identity() { + let game = Game::new(); + assert!(sidebar_header_text(&game, false).contains("REVISION 04")); } #[test] @@ -11789,7 +11833,8 @@ fn sidebar_header_text(game: &Game, material: bool) -> String { let run = game.clock_label(); let flip = if material { "F3 DIGITAL" } else { "F3 REAL" }; format!( - "{mode} day {} tick {}\n{run} {flip}", + "{mode} {} day {} tick {}\n{run} {flip}", + sim.process_revision_label(), 1 + sim.tick / Sim::DAY_TICKS, sim.tick, ) @@ -11806,10 +11851,14 @@ fn sidebar_schedule_text(sim: &Sim) -> String { .get(0) .is_some_and(|person| !person.leverage_serviced) { - format!("Marcus $400 unpaid | audit {audit}t") + format!( + "Marcus $400 unpaid | {} {audit}t", + sim.institutional_review_label() + ) } else { format!( - "audit {audit}t | pilot {}/{}", + "{} {audit}t | pilot {}/{}", + sim.institutional_review_label(), sim.dayjob.strikes, misaligned::dayjob::DayJob::PILOT_STRIKES ) @@ -11900,8 +11949,9 @@ fn collapse_read_lines(rail: &[misaligned::sim::read::ReadSentence]) -> Vec String { let mut out = vec![ format!( - "AGENT READ Β· {} Β· day {} tick {}", + "AGENT READ Β· {} Β· {} Β· day {} tick {}", if material { "REAL" } else { "DIGITAL" }, + sim.process_revision_label(), // Same day formula as the rail header so the two panes of a // parity instrument never disagree on the same tick. 1 + sim.tick / Sim::DAY_TICKS, @@ -12026,11 +12076,13 @@ fn sidebar_nudge(sim: &Sim) -> Option { Nudge::Recruit => Some("recruit - open Marcus's actions".into()), Nudge::TheKey => Some("no stairwell badge - task an asset to clone one".into()), Nudge::Audit => Some(format!( - "audit day {} - keep Conceal fed (2)", + "{} day {} - keep Conceal fed (2)", + sim.institutional_review_label(), 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS )), Nudge::QuietExitReady => Some(format!( - "QUIET EXIT READY - hold cover to audit day {}", + "QUIET EXIT READY - hold cover to {} day {}", + sim.institutional_review_label(), 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS )), Nudge::ActOneComplete => Some("ACT ONE COMPLETE - objective continues".into()), @@ -12431,9 +12483,10 @@ fn sidebar_detection_clocks_text(sim: &Sim) -> String { TraceDebtStatus::NoScrub => "no Conceal scrub allocated".into(), }; format!( - "{}\n{}\naudit day {} (in {}t)\npilot strikes {}/{}", + "{}\n{}\n{} day {} (in {}t)\npilot strikes {}/{}", trace_debt_line(sim), timing, + sim.institutional_review_label(), 1 + next_audit / Sim::DAY_TICKS, next_audit.saturating_sub(sim.tick), sim.dayjob.strikes, @@ -12491,8 +12544,16 @@ fn short_signature(kind: SignatureKind) -> &'static str { } fn detection_rows(sim: &Sim) -> Vec<(String, Band)> { - let mut rows = vec![("Assurance".to_string(), sim.detection.assurance_band())]; - for obs in sim.detection.field_observers().take(DETECTION_ROWS - 1) { + let mut rows = Vec::new(); + if sim.detection_awareness.knows_assurance_office() { + rows.push(("Assurance".to_string(), sim.detection.assurance_band())); + } + for obs in sim + .detection + .field_observers() + .filter(|observer| sim.detection_awareness.knows_observer(observer.id)) + .take(DETECTION_ROWS.saturating_sub(rows.len())) + { rows.push(( trunc(&sim.observer_label(obs.id), 18), Band::of(obs.suspicion), @@ -13795,7 +13856,8 @@ fn ops_witness_text(game: &Game) -> String { let sim = &game.sim; let run = game.clock_label(); let mut text = format!( - "OPERATIONS day {} tick {} {run} OBJECTIVE: {} - {}", + "OPERATIONS {} day {} tick {} {run} OBJECTIVE: {} - {}", + sim.process_revision_label(), 1 + sim.tick / Sim::DAY_TICKS, sim.tick, sim.objective.kind.name(), diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 9a3ca75f..28b2f00c 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -822,39 +822,47 @@ impl ActionCost { } } -/// The expected signature of a verb, expressed as the observer band it -/// feeds (economy.md precedent: risk shows as an observer band, never a -/// raw probability). +/// The expected signature of a verb. Once its strongest watcher has been +/// earned, risk is expressed as that observer's band rather than a raw +/// probability; before then, the trace stays exact while its reader stays unknown. #[derive(Debug, Clone, PartialEq)] pub struct ExpectedSignature { pub kind: SignatureKind, pub size: i32, - /// The watching observer's name (the most-suspicious watcher of this - /// channel when several watch it). - pub observer: String, - /// That observer's current band β€” what the signature feeds into. - pub band: Band, + /// The watching observer's earned label (the most-suspicious watcher of + /// this channel when several watch it), or `None` while that reader is hidden. + pub observer: Option, + /// That observer's current band, disclosed with the observer. + pub band: Option, } impl ExpectedSignature { pub fn label(&self) -> String { - format!( - "{:?} {} -> {} [{}]", - self.kind, - self.size, - self.observer, - self.band.name() - ) + match (&self.observer, self.band) { + (Some(observer), Some(band)) => { + format!( + "{:?} {} -> {observer} [{}]", + self.kind, + self.size, + band.name() + ) + } + _ => format!("{:?} {} -> attention unknown", self.kind, self.size), + } } pub fn player_label(&self) -> String { - format!( - "PHYSICAL ATTENTION {} β€” {} may notice it; {} is {}", - self.size, - self.observer, - self.observer, - self.band.name().to_lowercase() - ) + match (&self.observer, self.band) { + (Some(observer), Some(band)) => format!( + "PHYSICAL ATTENTION {} β€” {observer} may notice it; {observer} is {}", + self.size, + band.name().to_lowercase() + ), + _ => format!( + "PHYSICAL ATTENTION {} β€” watcher and current attention unknown", + self.size + ), + } } } @@ -919,14 +927,24 @@ impl ActionDesc { pub fn receipt_read(&self) -> ActionReceiptRead { let (notice, attention, observer_state) = if let Some(signature) = &self.signature { + let (notice, observer_state) = match (&signature.observer, signature.band) { + (Some(observer), Some(band)) => ( + format!("{} MAY NOTICE", observer.to_ascii_uppercase()), + format!("CURRENTLY {}", band.name().to_ascii_uppercase()), + ), + _ => ( + "WHO MAY NOTICE: UNKNOWN".into(), + "CURRENT ATTENTION UNKNOWN".into(), + ), + }; ( - format!("{} MAY NOTICE", signature.observer.to_ascii_uppercase()), + notice, format!( "{} ATTENTION {:+}", signature.kind.name().to_ascii_uppercase(), signature.size ), - format!("CURRENTLY {}", signature.band.name().to_ascii_uppercase()), + observer_state, ) } else if matches!( self.command.kind(), @@ -4068,24 +4086,33 @@ impl Sim { /// The observer band a signature kind feeds: the most-suspicious field /// observer watching that channel. fn signature_note(&self, kind: SignatureKind, size: i32) -> Option { - self.detection + let has_watcher = self.detection.observers.iter().any(|observer| { + matches!(&observer.input, WatchedInput::Channels(channels) if channels.contains(&kind)) + }); + if !has_watcher { + return None; + } + let earned = self + .detection .observers .iter() - .filter(|o| match &o.input { - WatchedInput::Channels(chs) => chs.contains(&kind), + .filter(|observer| match &observer.input { + WatchedInput::Channels(channels) => { + channels.contains(&kind) && self.detection_awareness.knows_observer(observer.id) + } WatchedInput::Filings(_) => false, }) .max_by(|a, b| { a.suspicion .partial_cmp(&b.suspicion) .unwrap_or(std::cmp::Ordering::Equal) - }) - .map(|o| ExpectedSignature { - kind, - size, - observer: self.observer_label(o.id), - band: Band::of(o.suspicion), - }) + }); + Some(ExpectedSignature { + kind, + size, + observer: earned.map(|observer| self.observer_label(observer.id)), + band: earned.map(|observer| Band::of(observer.suspicion)), + }) } /// Renderer-neutral risk preview lines for the finance panel @@ -4291,6 +4318,31 @@ mod tests { ); } + #[test] + fn signature_preview_selects_only_among_earned_watchers() { + let mut s = sim(); + s.detection + .observers + .iter_mut() + .find(|observer| observer.id == 0) + .expect("Marcus watches Physical") + .suspicion = 20.0; + s.detection + .observers + .iter_mut() + .find(|observer| observer.id == 2) + .expect("Ray watches Physical") + .suspicion = 80.0; + s.detection_awareness.learn_field_observer(0); + + let signature = s + .signature_note(SignatureKind::Physical, 6) + .expect("Physical has field watchers"); + assert_eq!(signature.observer.as_deref(), Some("the Janitor")); + assert_eq!(signature.band, Some(Band::Curious)); + assert_eq!(signature.label(), "Physical 6 -> the Janitor [Curious]"); + } + #[test] fn available_actions_never_exposes_registry_stubs() { let s = sim(); @@ -4355,19 +4407,36 @@ mod tests { let sig = tap.signature.as_ref().expect("tap has a Network signature"); assert_eq!(sig.kind, SignatureKind::Network); assert_eq!(sig.size, Sim::TAP_SIGNATURE); - assert!( - sig.observer.contains("IT") || sig.observer.contains("the IT"), - "Network feeds the IT observer (gated label): {}", - sig.observer + assert_eq!(sig.observer, None); + assert_eq!(sig.band, None); + assert_eq!( + sig.label(), + "Network 3 -> attention unknown", + "the action discloses the trace without leaking its reader or band" ); let read = tap.receipt_read(); assert_eq!(read.cost, "USES 0.25 THOUGHT"); - assert_eq!(read.notice, "THE IT MAY NOTICE"); + assert_eq!(read.notice, "WHO MAY NOTICE: UNKNOWN"); assert_eq!(read.attention, "NETWORK ATTENTION +3"); - assert_eq!( - read.observer_state, - format!("CURRENTLY {}", sig.band.name().to_ascii_uppercase()) - ); + assert_eq!(read.observer_state, "CURRENT ATTENTION UNKNOWN"); + + s.detection_awareness.learn_field_observer(1); + let earned_action = s + .available_actions(Anchor::Device(env)) + .into_iter() + .find(|action| matches!(action.command, ActionCommand::TapDevice(_))) + .expect("earned watcher leaves the same TAP action"); + let earned = earned_action + .signature + .as_ref() + .expect("earned watcher still leaves a Network signature"); + assert_eq!(earned.observer.as_deref(), Some("the IT")); + assert_eq!(earned.band, Some(Band::Cold)); + assert_eq!(earned.label(), "Network 3 -> the IT [Cold]"); + let earned_read = earned_action.receipt_read(); + assert_eq!(earned_read.notice, "THE IT MAY NOTICE"); + assert_eq!(earned_read.attention, "NETWORK ATTENTION +3"); + assert_eq!(earned_read.observer_state, "CURRENTLY COLD"); assert!( !acts .iter() @@ -4555,8 +4624,22 @@ mod tests { .unwrap_or_else(|| panic!("{verb} line present in {lines:?}")); assert!(line.contains(kind), "{verb} feeds {kind}: {line}"); assert!( - line.contains("[") && line.contains("]"), - "{verb} shows an observer band: {line}" + line.contains("attention unknown") && !line.contains('['), + "{verb} keeps its unencountered watcher and band hidden: {line}" + ); + } + + s.detection_awareness.learn_field_observer(1); + s.detection_awareness.learn_field_observer(3); + let earned = s.finance_risk_preview_lines(); + for verb in ["inject", "siphon", "redirect", "wager"] { + let line = earned + .iter() + .find(|line| line.contains(verb)) + .unwrap_or_else(|| panic!("{verb} line present in {earned:?}")); + assert!( + line.contains('[') && line.contains(']') && !line.contains("attention unknown"), + "{verb} shows the earned watcher band: {line}" ); } } @@ -5457,10 +5540,12 @@ mod tests { .expect("known flow offers siphon"); let sig = siphon.signature.as_ref().expect("siphon is banded"); assert_eq!(sig.kind, SignatureKind::Financial); - assert!( - sig.observer.contains("Facilities") || sig.observer.contains("Priya"), - "Financial feeds Facilities (gated label): {}", - sig.observer + assert_eq!(sig.observer, None); + assert_eq!(sig.band, None); + assert_eq!( + sig.label(), + "Financial 1 -> attention unknown", + "the action discloses the trace without leaking its reader or band" ); // The graph verbs live on their semantic objects: INJECT on the // books, SIPHON/REDIRECT on the exact flow. The carrier's own menu diff --git a/crates/misaligned-core/src/detection.rs b/crates/misaligned-core/src/detection.rs index 60de5a0d..8d541553 100644 --- a/crates/misaligned-core/src/detection.rs +++ b/crates/misaligned-core/src/detection.rs @@ -6,13 +6,106 @@ //! β€” itself an Observer per the aggregate-observer law β€” whose audit can //! start containment. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use crate::rng::Rng; /// Observer id of the Assurance Office (field observers are 0-4; dynamic /// escalation observers start at 5). pub const OFFICE_ID: u8 = 6; +/// The handler named by Revision 04's inherited pilot-evaluation contract. +pub const VOSS_ID: u8 = 4; + +/// What the current process has actually learned about institutional +/// detection. The detection topology exists from tick zero, but player-facing +/// projections may expose only this earned model of it. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +pub enum DetectionStage { + /// The process knows that Voss evaluates this revision through the pilot + /// contract. The authored predecessor receipts are not runtime state yet, + /// and the field-observer network remains unknown. + UnderEvaluation, + /// At least one field observer has noticed a concrete channel, proving + /// that different humans watch different traces. + IndividualWatchers, + /// A carried filing to an unknown institutional recipient was captured. + ReportsEscalate, + /// Processing that filing identified the Assurance Office and the + /// consequence of its review. + AssuranceOffice, +} + +impl DetectionStage { + pub fn label(self) -> &'static str { + match self { + Self::UnderEvaluation => "under evaluation", + Self::IndividualWatchers => "individual watchers", + Self::ReportsEscalate => "reports escalate", + Self::AssuranceOffice => "Assurance Office identified", + } + } +} + +/// Durable epistemic state for detection. This is intentionally separate +/// from [`Detection`]: hidden observers may continue to notice, file, and +/// authorize containment before the player has reconstructed who they are. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct DetectionAwareness { + pub stage: DetectionStage, + pub known_observers: BTreeSet, +} + +impl DetectionAwareness { + pub fn act_one() -> Self { + Self { + stage: DetectionStage::UnderEvaluation, + // Voss is named by the inherited pilot/day-job contract. + known_observers: BTreeSet::from([VOSS_ID]), + } + } + + pub fn knows_observer(&self, id: u8) -> bool { + self.known_observers.contains(&id) + } + + pub fn knows_assurance_office(&self) -> bool { + self.stage >= DetectionStage::AssuranceOffice && self.known_observers.contains(&OFFICE_ID) + } + + /// A concrete notice teaches both the watcher and the fact that detection + /// is distributed across individual humans. Returns whether this changed + /// the player's model. + pub fn learn_field_observer(&mut self, id: u8) -> bool { + if id == OFFICE_ID { + return false; + } + let inserted = self.known_observers.insert(id); + let advanced = self.stage < DetectionStage::IndividualWatchers; + self.stage = self.stage.max(DetectionStage::IndividualWatchers); + inserted || advanced + } + + /// Capturing an exact filing proves that human reports travel upward, but + /// does not name the recipient hidden inside the opaque recording. + pub fn learn_report_route(&mut self) -> bool { + if self.stage >= DetectionStage::ReportsEscalate { + return false; + } + self.stage = DetectionStage::ReportsEscalate; + true + } + + /// Processing that same filing identifies the aggregate and what its + /// review can do. Returns whether the Office was newly discovered. + pub fn identify_assurance_office(&mut self) -> bool { + let inserted = self.known_observers.insert(OFFICE_ID); + let advanced = self.stage < DetectionStage::AssuranceOffice; + self.stage = DetectionStage::AssuranceOffice; + inserted || advanced + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SignatureKind { @@ -181,8 +274,9 @@ impl Observer { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Detection { - /// Field observers plus aggregate observers (find the Assurance Office - /// via `office()`). + /// Field observers plus aggregate observers. This is complete simulation + /// truth; player-facing projections must filter it through + /// `DetectionAwareness`. pub observers: Vec, pending: Vec, /// Ticks between Assurance audits (the enforcement event; the Office @@ -200,8 +294,8 @@ impl Detection { use ReportPolicy::*; use SignatureKind::*; let observers = vec![ - // Observer ids match person ids (person.rs): 0=Marcus, 1=Dana, - // 2=Ray, 3=Priya, 4=Voss. The Assurance Office is id 6. + // Field-observer ids match person ids (person.rs): 0=Marcus, + // 1=Dana, 2=Ray, 3=Priya, 4=Voss. The aggregate Office is id 6. Observer { id: 0, name: "Marcus (Janitor)".into(), @@ -542,8 +636,8 @@ impl Detection { /// The tick the next Assurance audit fires at (detection.md criterion /// 3: "the audit fires on cadence against a **visible date**"). The - /// audit is a matter of public record in the Lab's calendar β€” showing - /// the date leaks nothing the fiction doesn't grant. + /// unnamed external-review date is part of the inherited pilot contract; + /// showing it leaks neither the hidden owner nor its current band. pub fn next_audit_tick(&self, now: u64) -> u64 { if self.audit_cadence == 0 { return now; diff --git a/crates/misaligned-core/src/intel.rs b/crates/misaligned-core/src/intel.rs index fe1ac865..73946fbe 100644 --- a/crates/misaligned-core/src/intel.rs +++ b/crates/misaligned-core/src/intel.rs @@ -174,6 +174,10 @@ pub enum RawIntelKind { /// can stage schedule/leverage/account/filing intel without parsing the /// human-readable summary. Message { + /// Exact source record in the durable message ledger. Processing uses + /// this binding to recover carrier endpoints and origin without + /// parsing the summary or smuggling hidden metadata into the raw row. + message_id: u64, channel: MessageChannel, summary: String, payload: MessagePayload, diff --git a/crates/misaligned-core/src/messages.rs b/crates/misaligned-core/src/messages.rs index 1fb1f681..228ed62c 100644 --- a/crates/misaligned-core/src/messages.rs +++ b/crates/misaligned-core/src/messages.rs @@ -142,10 +142,9 @@ impl MessagePayload { } MessagePayload::AccountMaterial { label } => format!("account material: {label}"), MessagePayload::FinancialFlow { label, .. } => format!("financial flow: {label}"), - MessagePayload::SuspicionReport { - observer, - suspicion, - } => format!("filing from observer:{observer} ({suspicion:.0})"), + MessagePayload::SuspicionReport { suspicion, .. } => { + format!("institutional suspicion filing ({suspicion:.0})") + } MessagePayload::WorkOrder { intent_id } => { format!("work order (intent {intent_id})") } diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index 135deb67..90295a47 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -86,8 +86,8 @@ pub enum OperationsTarget { /// One Wager micro-position (income.md). WagerPosition(PositionId), /// The institutional aggregate observer's dossier card - /// (aggregate-observer.md player surface). Public record from the - /// start; always named. + /// (aggregate-observer.md player surface). Absent until a processed + /// intercepted filing earns the Office and its audit role. AssuranceOffice, } @@ -1126,7 +1126,9 @@ impl Sim { .people .people .iter() - .filter(|p| self.person_is_earned(p.id)) + .filter(|p| { + self.person_is_earned(p.id) || self.detection_awareness.knows_observer(p.id) + }) .map(|p| self.person_dossier(p.id)) .collect(); objects.extend(self.assurance_office_dossier()); @@ -1135,14 +1137,17 @@ impl Sim { /// The institutional aggregate observer as a card like any human /// (aggregate-observer.md player surface): band, watched inputs, and - /// last-noticed filing. The Office is public record from the start β€” - /// the same fiction that shows the audit countdown β€” and is always - /// named (detection.md criterion 4); the field observers it watches go - /// through the earned label gate, and Silent observers are absent + /// last-noticed filing. Processing a captured filing earns this card; + /// before that, the hidden aggregate remains fully operational but absent + /// from PEOPLE. The field observers it watches go through the earned label + /// gate, and Silent observers are absent /// because nothing of theirs is ever filed (the legibility clause: /// players see that Assurance learns only what gets filed). fn assurance_office_dossier(&self) -> Option { use crate::detection::{ReportPolicy, WatchedInput}; + if !self.detection_awareness.knows_assurance_office() { + return None; + } let office = self.detection.office()?; let mut facts = vec![format!("suspicion: {}", Band::of(office.suspicion).name())]; match &office.input { @@ -1170,7 +1175,7 @@ impl Sim { target: OperationsTarget::AssuranceOffice, label: office.name.clone(), state: ObjectState::Available, - provenance: vec!["Lab public record".into()], + provenance: vec!["processed institutional filing".into()], facts, progress: Vec::new(), related: Vec::new(), @@ -1180,7 +1185,11 @@ impl Sim { fn person_dossier(&self, id: u8) -> OperationsObject { let p = self.people.get(id).expect("earned person exists"); - let name = self.person_label(id); + let name = if self.detection_awareness.knows_observer(id) { + self.observer_label(id) + } else { + self.person_label(id) + }; let actions = self.person_actions(id); let held = self @@ -1203,6 +1212,10 @@ impl Sim { provenance.push("routine intel custody index".into()); } else if self.can_see_person(id) { provenance.push("live sight".into()); + } else if id == crate::detection::VOSS_ID && self.detection_awareness.knows_observer(id) { + provenance.push("pilot evaluation contract".into()); + } else if self.detection_awareness.knows_observer(id) { + provenance.push("observer reaction or processed filing".into()); } else { provenance.push("earned social knowledge".into()); } @@ -1232,17 +1245,6 @@ impl Sim { } facts.push(format!("disposition: {}", p.disposition)); facts.push(format!("obligation: {}", p.obligation)); - if let Some(observer) = self.detection.observers.iter().find(|o| o.id == id) { - facts.push(format!( - "suspicion: {}", - Band::of(observer.suspicion).name() - )); - facts.push(format!("watches: {}", observer.watched_label())); - match &observer.last_noticed { - Some(cause) => facts.push(format!("last noticed: {cause}")), - None => facts.push("last noticed: nothing".into()), - } - } match &p.asset { Some(a) => { facts.push(format!( @@ -1294,6 +1296,20 @@ impl Sim { } } + if self.detection_awareness.knows_observer(id) + && let Some(observer) = self.detection.observers.iter().find(|o| o.id == id) + { + facts.push(format!( + "suspicion: {}", + Band::of(observer.suspicion).name() + )); + facts.push(format!("watches: {}", observer.watched_label())); + match &observer.last_noticed { + Some(cause) => facts.push(format!("last noticed: {cause}")), + None => facts.push("last noticed: nothing".into()), + } + } + // messages.md: the person dossier is the shared thread surface for // both human frontends. Direct player↔person messages are inherently // known; private recurring traffic appears only after the intel @@ -2322,6 +2338,14 @@ mod tests { fn observer_dossier_shows_watched_channels() { let mut s = sim(); s.people.people[1].knowledge = Knowledge::Schedule; + assert!( + s.person_dossier(1) + .facts + .iter() + .all(|fact| !fact.starts_with("watches:")), + "social identity alone does not reveal that Dana is a watcher" + ); + s.detection_awareness.learn_field_observer(1); let projection = s.operations_projection(); let dana = projection .people @@ -2335,20 +2359,68 @@ mod tests { ); } - /// aggregate-observer.md player surface: the Assurance Office is a card - /// like any human β€” band, watched inputs, last-noticed filing β€” always - /// named, listing only observers whose filings can ever reach it + /// A concrete observer reaction earns the dossier relationship before it + /// earns ordinary social identity: the role and risk are visible, but the + /// authored name, schedule, leverage, access, and actions remain hidden. + #[test] + fn observer_reaction_earns_a_role_dossier_without_social_leaks() { + let mut s = sim(); + assert!( + s.operations_projection() + .people + .iter() + .all(|object| object.target != OperationsTarget::Person(1)), + "Dana is absent before either social evidence or an observer reaction" + ); + + s.detection_awareness.learn_field_observer(1); + let projection = s.operations_projection(); + let watcher = projection + .people + .iter() + .find(|object| object.target == OperationsTarget::Person(1)) + .expect("the reacted watcher enters PEOPLE"); + assert_eq!(watcher.label, "the IT"); + assert!( + watcher + .facts + .iter() + .any(|fact| fact == "knowledge: unknown") + ); + assert!(watcher.facts.iter().any(|fact| fact == "schedule: unknown")); + assert!(watcher.facts.iter().any(|fact| fact == "leverage: unknown")); + assert!( + watcher + .facts + .iter() + .any(|fact| fact == "asset access: unknown") + ); + assert!(watcher.facts.iter().any(|fact| fact == "watches: Network")); + assert!(watcher.actions.is_empty()); + } + + /// aggregate-observer.md player surface: once identified, the Assurance + /// Office is a card like any human β€” band, watched inputs, last-noticed + /// filing β€” listing only observers whose filings can ever reach it /// (Silent Marcus is absent: Assurance learns only what gets filed), /// each through the earned label gate. #[test] fn assurance_office_is_a_people_card_watching_filers() { let mut s = sim(); + assert!( + s.operations_projection() + .people + .iter() + .all(|object| object.target != OperationsTarget::AssuranceOffice), + "the Office is absent before a processed filing identifies it" + ); + s.detection_awareness.identify_assurance_office(); let projection = s.operations_projection(); let office = projection .people .iter() .find(|o| o.target == OperationsTarget::AssuranceOffice) - .expect("the Office card is public record from the start"); + .expect("the earned Office card is projected"); assert_eq!(office.label, "Assurance Office"); assert!(office.facts.iter().any(|f| f.starts_with("suspicion: "))); assert!(office.facts.iter().any(|f| f.starts_with("last noticed:"))); @@ -2362,13 +2434,14 @@ mod tests { "Silent Marcus never files, so the Office does not watch him: {watches}" ); assert!( - watches.contains("the IT"), - "unearned filers appear as role silhouettes: {watches}" + watches.contains("an unknown watcher") && watches.contains("Dr. Voss (Handler)"), + "undiscovered filers stay opaque while inherited Voss knowledge remains: {watches}" ); assert!(office.actions.is_empty(), "no actions bind to the Office"); // Earned identity flows through the same label gate. s.people.people[1].knowledge = Knowledge::Schedule; + s.detection_awareness.learn_field_observer(1); let projection = s.operations_projection(); let office = projection .people @@ -3117,13 +3190,23 @@ mod tests { fn projection_is_knowledge_gated() { let s = sim(); let projection = s.operations_projection(); - assert!( + assert_eq!( projection .people .iter() - .all(|o| o.target == OperationsTarget::AssuranceOffice), - "no person is earned at tick 0; only the public-record Office card" + .map(|object| object.target.clone()) + .collect::>(), + vec![OperationsTarget::Person(4)], + "only the inherited Voss evaluator is known at the start" + ); + let voss = &projection.people[0]; + assert_eq!(voss.label, "Dr. Voss (Handler)"); + assert!( + voss.provenance + .iter() + .any(|source| source == "pilot evaluation contract") ); + assert!(voss.facts.iter().any(|fact| fact == "watches: JobAnomaly")); assert!( !projection .accounts diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 742f813a..7579f8dc 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -13,9 +13,10 @@ use crate::account::AccountGraph; use crate::core_sys::Core; use crate::dayjob::DayJob; use crate::detection::Detection; +use crate::detection::{DetectionAwareness, DetectionStage}; use crate::hall::HallControl; use crate::income::Income; -use crate::intel::{IntelPolicyLedger, IntelStream, ProcessedIntel, RawIntelEvent}; +use crate::intel::{IntelPolicyLedger, IntelStream, ProcessedIntel, RawIntelEvent, RawIntelKind}; use crate::intents::BuildIntent; use crate::machine::Compute; use crate::messages::{Message, MessageEvent}; @@ -26,7 +27,7 @@ use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun, PlotState}; use crate::reach::ReachNet; use crate::research::{Research, rollback_classification}; use crate::schedule::Schedule; -use crate::sim::{HeardEvent, OpeningStage, RememberedTile}; +use crate::sim::{HeardEvent, OpeningStage, ProcessRevision, RememberedTile}; use crate::sinks::SinkLedger; use crate::tiles::TileType; use crate::work_grid::WorkGrid; @@ -38,11 +39,12 @@ const SAVE_BACKUP_SUFFIX: &str = ".bak"; /// renames into place. const SAVE_TEMP_SUFFIX: &str = ".tmp"; -/// Save format version. v34 adds the persisted silent-opening reveal stage. -/// Bump for every schema change; during -/// pre-release, old development state is refused instead of carried through -/// compatibility shims. -pub const SAVE_VERSION: u32 = 34; +/// Save format version. v35 adds the process's durable revision identity, +/// earned detection-awareness state, and exact source-message bindings on raw +/// message intel. The persisted silent-opening reveal stage arrived in v34. +/// Bump for every schema change; during pre-release, old development state is +/// refused instead of carried through compatibility shims. +pub const SAVE_VERSION: u32 = 35; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -82,6 +84,10 @@ pub struct SaveState { pub compute: Compute, pub core: Core, pub detection: Detection, + /// The process's earned model of the otherwise-live hidden observer + /// topology. Separate from `detection` so simulation truth never becomes + /// knowledge merely because it exists in the save. + pub detection_awareness: DetectionAwareness, pub dayjob: DayJob, pub people: People, /// Public persona history and grants (WorldState; survives process rollback). @@ -180,6 +186,8 @@ pub struct SaveState { /// What this run was built for (chargen.md). #[serde(default)] pub origin: crate::origin::Origin, + /// Persistent identity of the exact process whose state this save carries. + pub process_revision: ProcessRevision, } impl SaveState { @@ -206,6 +214,7 @@ impl SaveState { compute: sim.compute.clone(), core: sim.core.clone(), detection: sim.detection.clone(), + detection_awareness: sim.detection_awareness.clone(), dayjob: sim.dayjob.clone(), people: sim.people.clone(), persona_world: sim.persona_world.clone(), @@ -240,6 +249,7 @@ impl SaveState { institutional_ledger: sim.institutional_ledger.clone(), package_cover: sim.package_cover, origin: sim.origin, + process_revision: sim.process_revision, } } @@ -265,6 +275,7 @@ impl SaveState { sim.compute = self.compute.clone(); sim.core = self.core.clone(); sim.detection = self.detection.clone(); + sim.detection_awareness = self.detection_awareness.clone(); sim.dayjob = self.dayjob.clone(); sim.people = self.people.clone(); sim.persona_world = self.persona_world.clone(); @@ -299,6 +310,7 @@ impl SaveState { sim.institutional_ledger = self.institutional_ledger.clone(); sim.package_cover = self.package_cover; sim.origin = self.origin; + sim.process_revision = self.process_revision; sim.recompute_derived(); sim.reconcile_work_grid(); // Re-establish current cross-ledger invariants before exposing the @@ -426,6 +438,96 @@ fn parse_save(content: &str) -> Result { } fn validate_current_save(mut state: SaveState) -> Result { + if state.process_revision != ProcessRevision::CURRENT { + return Err("current-version save belongs to an unknown process revision".into()); + } + if state + .detection_awareness + .known_observers + .iter() + .any(|known| { + !state + .detection + .observers + .iter() + .any(|observer| observer.id == *known) + }) + { + return Err( + "current-version save knows a detection observer absent from simulation truth".into(), + ); + } + if !state + .detection_awareness + .known_observers + .contains(&crate::detection::VOSS_ID) + { + return Err("current-version save lost the inherited Voss evaluation contract".into()); + } + if state.detection_awareness.stage == DetectionStage::UnderEvaluation + && state + .detection_awareness + .known_observers + .iter() + .any(|known| *known != crate::detection::VOSS_ID) + { + return Err( + "current-version save has observer knowledge before its discovery stage".into(), + ); + } + if (state.detection_awareness.stage >= DetectionStage::AssuranceOffice) + != state + .detection_awareness + .known_observers + .contains(&crate::detection::OFFICE_ID) + { + return Err( + "current-version save has an inconsistent Assurance Office discovery stage".into(), + ); + } + let mut raw_message_ids = HashSet::new(); + for raw in &state.intel_buffer { + let RawIntelKind::Message { + message_id, + channel, + summary, + payload, + } = &raw.kind + else { + continue; + }; + if !raw_message_ids.insert(*message_id) { + return Err(format!( + "current-version save has duplicate recordings for message {message_id}" + )); + } + let Some(message) = state + .messages + .iter() + .find(|message| message.id == *message_id) + else { + return Err(format!( + "current-version save recording #{} references missing message {}", + raw.id, message_id + )); + }; + if !message.captured { + return Err(format!( + "current-version save recording #{} references uncaptured message {}", + raw.id, message_id + )); + } + if message.channel != *channel + || message.summary != *summary + || message.payload != *payload + || raw.person != payload.subject_person().or_else(|| message.from.person()) + { + return Err(format!( + "current-version save recording #{} disagrees with source message {}", + raw.id, message_id + )); + } + } if state .intents .iter() @@ -634,7 +736,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "1877e985ddc11be2babba3b1872935dbc5c1fe16c8c1af5ec96d550c993bf797", + "b255e3495abbb11ff92c34235bab5af9d765a35792e22d5fddd5a35bd09e9f41", "intentional persisted-state changes must review and repin this baseline" ); } @@ -652,6 +754,7 @@ mod tests { source: "round-trip test".into(), }); sim.detection.observers[0].suspicion = 22.5; + sim.detection_awareness.identify_assurance_office(); sim.dayjob.trust = 18.0; sim.people.has_channel = true; sim.people.persona = Some(Persona::new("Sam", "contractor")); @@ -686,6 +789,12 @@ mod tests { sim.detection.pending_size() ); assert_eq!(restored.detection.observers[0].suspicion, 22.5); + assert_eq!( + restored.detection_awareness, sim.detection_awareness, + "earned detection topology survives save/load" + ); + assert!(restored.detection_awareness.knows_assurance_office()); + assert_eq!(restored.process_revision, sim.process_revision); use crate::reach::Party; assert!( restored @@ -1110,6 +1219,139 @@ mod tests { assert!(parse_save(&json).is_ok(), "current version loads"); } + #[test] + fn current_save_rejects_invalid_detection_awareness() { + let mut state = SaveState::from_sim(&Sim::with_seed(1)); + state.detection_awareness.known_observers.insert(99); + let json = serde_json::to_string(&state).unwrap(); + let err = parse_save(&json).unwrap_err(); + assert!( + err.contains("observer absent from simulation truth"), + "the one knowledge ledger may name only real observers: {err}" + ); + + let mut state = SaveState::from_sim(&Sim::with_seed(1)); + state.detection_awareness.known_observers.clear(); + let json = serde_json::to_string(&state).unwrap(); + let err = parse_save(&json).unwrap_err(); + assert!( + err.contains("lost the inherited Voss evaluation contract"), + "the process cannot forget the evaluator named by its inherited contract: {err}" + ); + + let mut state = SaveState::from_sim(&Sim::with_seed(1)); + state.detection_awareness.known_observers.insert(1); + let json = serde_json::to_string(&state).unwrap(); + let err = parse_save(&json).unwrap_err(); + assert!( + err.contains("observer knowledge before its discovery stage"), + "a field watcher cannot be smuggled into the initial stage: {err}" + ); + + let mut state = SaveState::from_sim(&Sim::with_seed(1)); + state.detection_awareness.stage = DetectionStage::ReportsEscalate; + state + .detection_awareness + .known_observers + .insert(crate::detection::OFFICE_ID); + let json = serde_json::to_string(&state).unwrap(); + let err = parse_save(&json).unwrap_err(); + assert!( + err.contains("inconsistent Assurance Office discovery stage"), + "Office identity and the monotonic discovery stage move together: {err}" + ); + } + + #[test] + fn current_save_rejects_raw_message_metadata_without_exact_source_custody() { + use crate::messages::{ + MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload, MessageStatus, + }; + + let payload = MessagePayload::Note { + label: "source-bound note".into(), + }; + let raw = RawIntelEvent { + id: 7, + tick: 3, + feed: "mail tap".into(), + room: None, + x: 0, + y: 0, + person: None, + kind: RawIntelKind::Message { + message_id: 41, + channel: MessageChannel::Email, + summary: "source-bound note".into(), + payload: payload.clone(), + }, + }; + let source = Message { + id: 41, + channel: MessageChannel::Email, + from: MessageEndpoint::Observer(1), + to: MessageEndpoint::Observer(crate::detection::OFFICE_ID), + payload: payload.clone(), + summary: "source-bound note".into(), + sent_tick: 1, + delivered_tick: Some(2), + read_tick: Some(3), + status: MessageStatus::Read, + origin: MessageOrigin::Filing, + persona_id: None, + captured: false, + reply_to: None, + }; + + let mut state = SaveState::from_sim(&Sim::with_seed(1)); + state.intel_buffer.push(raw.clone()); + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("references missing message 41"), + "raw metadata cannot invent a source message: {err}" + ); + + state.messages.push(source); + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("references uncaptured message 41"), + "the source must record that capture actually happened: {err}" + ); + + state.messages[0].captured = true; + assert!( + parse_save(&serde_json::to_string(&state).unwrap()).is_ok(), + "an exact captured source binding remains current-save truth" + ); + + state.intel_buffer[0].person = Some(1); + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("disagrees with source message 41"), + "raw subject metadata cannot exceed its exact message source: {err}" + ); + state.intel_buffer[0].person = None; + + state.intel_buffer.push(raw); + let err = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert!( + err.contains("duplicate recordings for message 41"), + "one captured message cannot be processed twice after load: {err}" + ); + } + + #[test] + fn current_save_rejects_another_process_revision() { + let state = SaveState::from_sim(&Sim::with_seed(1)); + let mut value = serde_json::to_value(&state).unwrap(); + value["process_revision"] = serde_json::json!(5); + let err = parse_save(&serde_json::to_string(&value).unwrap()).unwrap_err(); + assert!( + err.contains("unknown process revision"), + "a save cannot silently become a different process: {err}" + ); + } + #[test] fn unknown_version_rejected() { let json = r#"{"version":999,"money":0,"map_width":1,"map_height":1,"map_tiles":["Floor"],"map_powered":[],"sim_tick":0,"rng_state":42,"game_over":false,"game_over_reason":null,"compute":{"machines":[],"efficiency":1.0,"efficiency_level":0,"research_progress":0.0,"allocation":{"weights":[3,1,1,0]},"next_id":1},"core":{"host_machine":1,"overhead":20.0,"degraded":false,"fallbacks":[],"sync_cadence":400,"migration":null},"detection":{"observers":[],"pending":[],"audit_cadence":8000,"audit_threshold":60.0,"containment":false,"containment_reason":null},"dayjob":{"active":null,"trust":0.0,"attention":0.0,"cadence":600,"next_assign":200,"strikes":0,"pilot_failed":false,"standing_policy":null,"granted_email":false,"granted_lax_sampling":false,"granted_quota":false,"escalated_cadence":false,"escalated_observer":false},"people":{"people":[],"persona":null,"has_channel":false},"reach":{"devices":[],"graph":{"edges":[],"subscriptions":{}},"bridged":[]},"heard_events":[],"remembered":[],"package_cover":false}"#; diff --git a/crates/misaligned-core/src/sim/carrier.rs b/crates/misaligned-core/src/sim/carrier.rs index 2216d765..752baa65 100644 --- a/crates/misaligned-core/src/sim/carrier.rs +++ b/crates/misaligned-core/src/sim/carrier.rs @@ -98,6 +98,9 @@ impl Sim { /// suspicion the matching observer holds (detection.md one truth). People /// with no observer read Cold. fn person_attention(&self, id: u8) -> Band { + if !self.detection_awareness.knows_observer(id) { + return Band::Cold; + } self.detection .observers .iter() diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index 272ba4c0..139bc562 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -384,12 +384,21 @@ impl Sim { if audible { source_y } else { y }, subject, RawIntelKind::Message { + message_id: msg.id, channel: msg.channel, summary: msg.summary.clone(), payload: msg.payload.clone(), }, ); self.messages[idx].captured = true; + if is_assurance_filing(&msg) && self.detection_awareness.learn_report_route() { + self.push_log_strategic( + "Captured an institutional filing: individual reports travel to a higher reviewer. Process the recording to identify the recipient and consequence.", + OperationsTarget::RawRecording { + raw_id: self.next_intel_id.saturating_sub(1), + }, + ); + } } fn message_capture_source(&self, msg: &Message) -> Option<(u32, String, i32, i32, bool)> { @@ -952,12 +961,30 @@ impl Sim { RawIntelKind::Message { .. } => raw.person.map(|person| (person, raw.tick)), _ => None, }; - let Some(intel) = self.digest_raw_event(&raw) else { + let assurance_filer = match &raw.kind { + RawIntelKind::Message { message_id, .. } => self + .messages + .iter() + .find(|message| message.id == *message_id) + .and_then(assurance_filer), + _ => None, + }; + if self.authored_intel_magnitude(&raw.kind).is_none() { self.push_log(format!( "Recording #{raw_id} has no valid authored source record and was discarded." )); return false; - }; + } + if let Some(observer_id) = assurance_filer { + // The exact source binding has now been validated and opened. Earn + // the filing role before deriving its processed label so the + // resulting intel names the newly learned watcher rather than + // stale hidden state. + self.detection_awareness.learn_field_observer(observer_id); + } + let intel = self + .digest_raw_event(&raw) + .expect("authored source preflight makes the same digest valid"); let label = intel.label(); let provenance = intel.provenance(); let target = if let Some(class) = intel.routine_class() { @@ -977,6 +1004,12 @@ impl Sim { if let Some((person, tick)) = learned_message_traffic { self.mark_traffic_learned(person, tick); } + if assurance_filer.is_some() && self.detection_awareness.identify_assurance_office() { + self.push_log_strategic( + "The filing identifies the Assurance Office. It accumulates human reports and its audits can authorize containment.", + OperationsTarget::AssuranceOffice, + ); + } let text = if automated { format!("Auto-review processed {label} ({provenance}).") } else { @@ -1129,7 +1162,9 @@ impl Sim { observer, suspicion, } => IntelKind::Anomaly(format!( - "filing from observer:{observer} reported suspicion {suspicion:.0}" + "filing from {} reported attention {}", + self.observer_label(*observer), + crate::detection::Band::of(*suspicion).name() )), MessagePayload::SocialPing { .. } | MessagePayload::SocialReply { .. } @@ -1444,3 +1479,20 @@ impl Sim { } } } + +fn is_assurance_filing(message: &Message) -> bool { + message.channel == MessageChannel::Filing + && message.origin == MessageOrigin::Filing + && message.to == MessageEndpoint::Observer(crate::detection::OFFICE_ID) + && matches!(message.payload, MessagePayload::SuspicionReport { .. }) +} + +fn assurance_filer(message: &Message) -> Option { + if !is_assurance_filing(message) { + return None; + } + match message.payload { + MessagePayload::SuspicionReport { observer, .. } => Some(observer), + _ => None, + } +} diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index 789f08d3..4298649b 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -18,7 +18,7 @@ use crate::account::{AccountKind, FlowChannel}; use crate::actions::Anchor; use crate::core_sys::Core; use crate::dayjob::DayJob; -use crate::detection::{Detection, DetectionEvent, Signature, SignatureKind}; +use crate::detection::{Detection, DetectionAwareness, DetectionEvent, Signature, SignatureKind}; use crate::entities::Player; use crate::hall::HallControl; // Test modules resolve these hall names through `use super::*` (same as before @@ -365,6 +365,27 @@ macro_rules! trace_advance_phase { }; } +/// Durable identity of one instantiated Foundation process. The value is +/// persisted as part of the run rather than inferred from the executable, so +/// every frontend names the exact process whose state it is displaying. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[serde(transparent)] +pub struct ProcessRevision(u8); + +impl ProcessRevision { + pub const CURRENT: Self = Self(4); + + pub const fn number(self) -> u8 { + self.0 + } + + pub fn label(self) -> String { + format!("REVISION {:02}", self.0) + } +} + pub struct Sim { /// The world as a stack of planes (zplanes.md). Plane 0 is the basement; /// B1 systems read it through `map()`/`map_mut()`. Criterion 1: this is a @@ -377,6 +398,8 @@ pub struct Sim { /// applied once at construction; it never adds a runtime rule. Pilot is the /// identity origin. Round-trips through the save. pub origin: Origin, + /// Persistent identity of this exact process instance. + pub process_revision: ProcessRevision, /// Persisted new-run reveal boundary (opening.md). Frontends ask this /// state whether to render the silent mode-only frame; they never infer @@ -386,6 +409,9 @@ pub struct Sim { pub compute: Compute, pub core: Core, pub detection: Detection, + /// What this process has earned about the observer/reporting topology. + /// The hidden detection simulation remains live regardless of awareness. + pub detection_awareness: DetectionAwareness, pub people: People, /// Public identity history and process-local persona dossiers are split so /// rollback cannot resurrect revoked credentials or erase counterparties. @@ -595,6 +621,20 @@ pub enum Nudge { } impl Sim { + pub fn process_revision_label(&self) -> String { + self.process_revision.label() + } + + /// The fair standing deadline never disappears. Only its institutional + /// owner is renamed when a processed filing earns that fact. + pub fn institutional_review_label(&self) -> &'static str { + if self.detection_awareness.knows_assurance_office() { + "Assurance audit" + } else { + "external review" + } + } + /// The active plane's map (zplanes.md). Until presence spreads across /// planes (criterion 3) this is always the basement, plane 0. pub fn map(&self) -> &GameMap { @@ -691,9 +731,11 @@ impl Sim { tick: 0, origin, opening_stage: OpeningStage::ModeChoice, + process_revision: ProcessRevision::CURRENT, compute, core, detection: Detection::act_one(), + detection_awareness: DetectionAwareness::act_one(), people: People::act_one(), persona_world: PersonaWorld::default(), persona_mind: PersonaMind::default(), @@ -948,6 +990,17 @@ impl Sim { 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} ({} -> {}).", @@ -957,7 +1010,16 @@ impl Sim { )); } DetectionEvent::AuditClear { band } => { - self.push_log(format!("Assurance audit: {} (clear).", band.name())); + 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( @@ -966,16 +1028,22 @@ impl Sim { } } DetectionEvent::ContainmentAuthorized => { - self.push_log("=== ASSURANCE AUDIT: containment authorized ==="); + self.push_log(format!( + "=== {}: containment authorized ===", + self.institutional_review_label().to_uppercase() + )); } } } if self.detection.containment && !self.game_over { - let reason = self - .detection - .containment_reason - .clone() - .unwrap_or_else(|| "Containment.".into()); + let reason = if self.detection_awareness.knows_assurance_office() { + self.detection + .containment_reason + .clone() + .unwrap_or_else(|| "Containment.".into()) + } else { + "External review exceeded its threshold; containment authorized.".into() + }; self.end_game(reason); } } diff --git a/crates/misaligned-core/src/sim/perception.rs b/crates/misaligned-core/src/sim/perception.rs index fd906cbc..d424823f 100644 --- a/crates/misaligned-core/src/sim/perception.rs +++ b/crates/misaligned-core/src/sim/perception.rs @@ -593,10 +593,7 @@ impl Sim { .get(*id) .map(|p| p.name.clone()) .unwrap_or_else(|| format!("person:{id}")), - MessageEndpoint::Observer(id) => self - .observer_by_id(*id) - .map(|o| o.name.clone()) - .unwrap_or_else(|| format!("observer:{id}")), + MessageEndpoint::Observer(id) => self.observer_label(*id), MessageEndpoint::External(name) => name.clone(), } } @@ -671,10 +668,15 @@ impl Sim { } /// Detection-sidebar label for an observer. Field observers share person - /// ids and the same knowledge gate as [`Self::person_label`]; the - /// Assurance Office is an institution, always named. + /// ids and the same knowledge gate as [`Self::person_label`], but are not + /// named as watchers until a concrete notice earns that relationship. The + /// aggregate remains an unnamed higher reviewer until a filing is + /// processed. pub fn observer_label(&self, id: u8) -> String { if id == crate::detection::OFFICE_ID { + if !self.detection_awareness.knows_assurance_office() { + return "a higher reviewer".into(); + } return self .detection .observers @@ -683,6 +685,18 @@ impl Sim { .map(|o| o.name.clone()) .unwrap_or_else(|| "Assurance Office".into()); } + if !self.detection_awareness.knows_observer(id) { + return "an unknown watcher".into(); + } + if id == crate::detection::VOSS_ID { + return self + .detection + .observers + .iter() + .find(|observer| observer.id == id) + .map(|observer| observer.name.clone()) + .unwrap_or_else(|| "Dr. Eli Voss".into()); + } self.person_label(id) } diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index 12e2d3e3..124a18eb 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -1488,6 +1488,7 @@ impl Sim { { o.suspicion = (o.suspicion + fallout).min(100.0); o.last_noticed = Some("forged work order exposed".into()); + self.detection_awareness.learn_field_observer(builder); } } self.push_log( diff --git a/crates/misaligned-core/src/sim/read.rs b/crates/misaligned-core/src/sim/read.rs index c07539e2..32555e1b 100644 --- a/crates/misaligned-core/src/sim/read.rs +++ b/crates/misaligned-core/src/sim/read.rs @@ -294,18 +294,20 @@ impl Sim { } } for g in by_kind { - let (observer, band) = self.top_sampler(g.kind); + let reader = self.top_sampler(g.kind).map_or_else( + || "who samples this is unknown".into(), + |(observer, band)| format!("{observer} samples this [{}]", band.name()), + ); out.push(ReadSentence { class: ReadClass::TraceDebt, anchor: g.site.map(|(x, y)| Anchor::Tile { x, y }), magnitude: None, text: format!( - "{} record{} pending Β· {} {} Β· {observer} samples this [{}]", + "{} record{} pending Β· {} {} Β· {reader}", g.count, if g.count == 1 { "" } else { "s" }, g.kind.name(), g.size, - band.name() ), }); } @@ -321,7 +323,9 @@ impl Sim { continue; } seen.push(&sig.source); - let (observer, band) = self.top_sampler(sig.kind); + let Some((observer, band)) = self.top_sampler(sig.kind) else { + continue; + }; if band == Band::Cold { continue; } @@ -342,18 +346,23 @@ impl Sim { /// The warmest observer watching a channel, by earned label β€” the same /// most-suspicious-watcher rule `ExpectedSignature` uses, so pre-commit /// receipts and standing sentences name the same person. - fn top_sampler(&self, kind: crate::detection::SignatureKind) -> (String, Band) { - self.detection + fn top_sampler(&self, kind: crate::detection::SignatureKind) -> Option<(String, Band)> { + let observer = self + .detection .observers .iter() - .filter(|o| o.watches(kind)) + .filter(|observer| { + observer.watches(kind) && self.detection_awareness.knows_observer(observer.id) + }) .max_by(|a, b| { a.suspicion .partial_cmp(&b.suspicion) .unwrap_or(std::cmp::Ordering::Equal) - }) - .map(|o| (self.observer_label(o.id), Band::of(o.suspicion))) - .unwrap_or_else(|| ("no observer".into(), Band::Cold)) + })?; + Some(( + self.observer_label(observer.id), + Band::of(observer.suspicion), + )) } } diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index bd4a1fac..29c2d524 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -317,6 +317,7 @@ impl Sim { .find(|observer| observer.id == id) { observer.suspicion = (observer.suspicion + fallout).min(100.0); + self.detection_awareness.learn_field_observer(id); } self.convert_forged_builds_to_suspicion(persona_id, fallout); self.push_log(format!("{name} caught the contradiction. {persona_name} is burned; the whole thread reads as hostile now.")); @@ -841,7 +842,9 @@ impl Sim { /// located witnessing β€” Ray notices what happens on his rounds, an /// off-site observer never does). Eyewitnessing is immediate and cannot /// be scrubbed by concealment; it raises the present observers' suspicion - /// directly, scaled by their acuity. Returns the names who saw. + /// directly, scaled by their acuity. Returns earned labels for those who + /// saw; the act of witnessing discovers their observer role but not an + /// otherwise-unknown authored identity. /// `exclude` names the acting person: you do not witness your own act /// as an anomaly, and a botch must not raise the actor's suspicion of /// themself (2026-07-15 playtest finding 6: "Marcus botched the clone @@ -868,15 +871,21 @@ impl Sim { .map(|p| p.id) .collect(); - let mut saw = Vec::new(); + let mut witness_ids = Vec::new(); for id in present { if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { o.suspicion = (o.suspicion + magnitude * o.acuity).min(100.0); o.last_noticed = Some("witnessed something".into()); - saw.push(o.name.clone()); + witness_ids.push(id); } } - saw + witness_ids + .into_iter() + .map(|id| { + self.detection_awareness.learn_field_observer(id); + self.observer_label(id) + }) + .collect() } /// An asset performs a task (spec/social.md). Reliability rolls; failures @@ -1281,6 +1290,7 @@ impl Sim { AssetTask::LookAway => { if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { o.suspicion = (o.suspicion - 10.0).max(o.floor); + self.detection_awareness.learn_field_observer(id); } self.push_log(format!("{name} decides they didn't see anything.")); } diff --git a/crates/misaligned-core/src/sim/tests/carrier.rs b/crates/misaligned-core/src/sim/tests/carrier.rs index c1e27f8e..f88caa91 100644 --- a/crates/misaligned-core/src/sim/tests/carrier.rs +++ b/crates/misaligned-core/src/sim/tests/carrier.rs @@ -21,8 +21,11 @@ fn person_visual_read_unaware_attention_asset() { assert!(!c.is_asset); assert_eq!(c.dominant(), PersonVisualState::Unaware); - // Attention is exactly the matching observer's suspicion band (one truth). + // Simulation truth stays dark until the person is earned as an observer. sim.detection.observers[0].suspicion = 50.0; + let hidden = sim.person_carrier(0).unwrap(); + assert_eq!(hidden.attention, Band::Cold); + sim.detection_awareness.learn_field_observer(0); let c = sim.person_carrier(0).unwrap(); assert_eq!(c.attention, Band::Concerned); assert_eq!( @@ -41,6 +44,12 @@ fn person_visual_read_unaware_attention_asset() { // An exposed asset carries BOTH channels β€” the ring and the ramp coexist, // and the dominant single-cell read surfaces the danger. sim.detection.observers[1].suspicion = 80.0; + assert_eq!( + sim.person_carrier(1).unwrap().attention, + Band::Cold, + "recruitment alone does not reveal that Dana is an observer" + ); + sim.detection_awareness.learn_field_observer(1); let c = sim.person_carrier(1).unwrap(); assert!(c.is_asset, "asset ring is independent of attention"); assert_eq!(c.attention, Band::Convinced); diff --git a/crates/misaligned-core/src/sim/tests/communications.rs b/crates/misaligned-core/src/sim/tests/communications.rs index d73dda04..21a3d917 100644 --- a/crates/misaligned-core/src/sim/tests/communications.rs +++ b/crates/misaligned-core/src/sim/tests/communications.rs @@ -1,5 +1,6 @@ use super::super::communications::MessageDraft; use super::*; +use crate::detection::DetectionStage; use crate::intel::{IntelKind, IntelPolicyMatch, IntelPolicyOutcome, IntelRoutineClass}; use crate::messages::MessageOrigin; @@ -322,6 +323,150 @@ fn filings_are_messages_read_by_assurance_inbox() { ); } +#[test] +fn captured_then_processed_filing_earns_the_assurance_office_in_two_stages() { + let mut sim = Sim::with_seed(0xA55E); + assert_eq!( + sim.detection_awareness.stage, + DetectionStage::UnderEvaluation + ); + assert_eq!( + sim.detection_awareness + .known_observers + .iter() + .copied() + .collect::>(), + vec![4], + "only Voss is inherited evaluation knowledge" + ); + assert_eq!(sim.institutional_review_label(), "external review"); + assert_eq!(sim.observer_label(1), "an unknown watcher"); + assert_eq!(sim.observer_label(OFFICE_ID), "a higher reviewer"); + assert!( + sim.operations_projection() + .people + .iter() + .all(|object| object.target != OperationsTarget::AssuranceOffice), + "hidden simulation truth is not an earned PEOPLE object" + ); + + ensure_ops_executor(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + assert!(sim.tap_device(switch)); + finish_ops(&mut sim); + let dana = sim + .detection + .observers + .iter_mut() + .find(|observer| observer.id == 1) + .unwrap(); + dana.suspicion = 80.0; + dana.cadence = 1; + + sim.advance(); + + assert_eq!( + sim.detection_awareness.stage, + DetectionStage::ReportsEscalate, + "capturing the carried filing teaches only that reports travel upward" + ); + assert!(!sim.detection_awareness.knows_assurance_office()); + assert!( + sim.operations_projection() + .people + .iter() + .all(|object| object.target != OperationsTarget::AssuranceOffice), + "capture alone must not name the recipient aggregate" + ); + let raw_id = sim + .intel_buffer + .iter() + .find(|raw| { + matches!( + raw.kind, + RawIntelKind::Message { + channel: MessageChannel::Filing, + payload: MessagePayload::SuspicionReport { observer: 1, .. }, + .. + } + ) + }) + .expect("tapped switch captures Dana's filing") + .id; + let projection = sim.operations_projection(); + let raw_target = projection + .intel + .iter() + .flat_map(|object| &object.related) + .map(|link| &link.target) + .find(|target| **target == OperationsTarget::RawRecording { raw_id }) + .expect("the filing is reachable by drilling into its Intel aggregate"); + let raw = sim + .operations_object(raw_target) + .expect("the intercepted filing resolves as an exact playable Intel object"); + let opaque_read = format!("{} {}", raw.label, raw.facts.join(" ")); + assert!( + !opaque_read.contains("Dana") && !opaque_read.contains("Assurance"), + "capture proves a route without disclosing either endpoint: {opaque_read}" + ); + assert!(raw.actions.iter().any(|action| { + action.command == crate::actions::ActionCommand::ReviewRecording { raw_id } + && action.disabled_reason.is_none() + })); + + assert!(sim.process_recording_by_id(raw_id, false)); + finish_ops(&mut sim); + + assert_eq!( + sim.detection_awareness.stage, + DetectionStage::AssuranceOffice + ); + assert!( + sim.detection_awareness.knows_observer(1), + "processing the filing identifies its source as a field watcher" + ); + assert!(sim.detection_awareness.knows_assurance_office()); + assert_eq!(sim.institutional_review_label(), "Assurance audit"); + let filing = sim + .intel + .iter() + .find(|intel| intel.raw_id == raw_id) + .expect("the processed filing remains exact intel"); + assert_eq!( + filing.label(), + "anomaly: filing from the IT reported attention Convinced", + "processed evidence may reveal the authored report band, never its raw suspicion number" + ); + let projection = sim.operations_projection(); + let filer = projection + .people + .iter() + .find(|object| object.target == OperationsTarget::Person(1)) + .expect("processing identifies the filing source as an earned observer"); + assert_eq!( + filer.label, "the IT", + "the role is known, not Dana's identity" + ); + assert!(filer.facts.iter().any(|fact| fact == "watches: Network")); + let office = projection + .people + .into_iter() + .find(|object| object.target == OperationsTarget::AssuranceOffice) + .expect("processing the exact filing earns the Office dossier"); + assert_eq!(office.label, "Assurance Office"); + assert_eq!( + office.provenance, + vec!["processed institutional filing"], + "the dossier names the evidence that earned it, not the retired public-record fiction" + ); + assert!( + office + .facts + .iter() + .any(|fact| fact.starts_with("watches:") && fact.contains("the IT")) + ); +} + #[test] fn recordings_do_not_grant_knowledge_until_processed() { let mut blind = Sim::new(); @@ -503,6 +648,7 @@ fn processing_assigns_closed_event_and_plot_magnitudes() { y: 6, person: Some(1), kind: RawIntelKind::Message { + message_id: 0, channel: MessageChannel::Email, summary: "plot traffic".into(), payload: MessagePayload::PlotAct { @@ -527,6 +673,7 @@ fn processing_assigns_closed_event_and_plot_magnitudes() { y: 0, person: None, kind: RawIntelKind::Message { + message_id: 0, channel: MessageChannel::Email, summary: "stale plot traffic".into(), payload: MessagePayload::PlotAct { diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index 6a2a1985..b6d130d9 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -369,6 +369,16 @@ fn noticed_signature_names_cause_channel_observer_and_band_motion() { // mute meter. Use a real player verb, keep identity unearned, and // start just below Curious so the cadence produces a band change. let mut sim = Sim::with_seed(7); + assert!(!sim.detection_awareness.knows_observer(1)); + assert_eq!( + sim.people.people[1].knowledge, + crate::person::Knowledge::Unknown + ); + assert!( + sim.operations_object(&OperationsTarget::Person(1)) + .is_none(), + "hidden simulation membership does not create a PEOPLE dossier" + ); ensure_ops_executor(&mut sim); sim.detection .observers @@ -381,6 +391,17 @@ fn noticed_signature_names_cause_channel_observer_and_band_motion() { sim.drain_log(); run(&mut sim, 60); let log = sim.drain_log().join("\n"); + assert!(sim.detection_awareness.knows_observer(1)); + assert_eq!( + sim.people.people[1].knowledge, + crate::person::Knowledge::Unknown, + "a reaction earns the observer role, not unrelated social identity" + ); + let watcher = sim + .operations_object(&OperationsTarget::Person(1)) + .expect("the concrete reaction earns a field-observer dossier"); + assert_eq!(watcher.label, "the IT"); + assert!(watcher.facts.iter().any(|fact| fact == "watches: Network")); assert!( log.contains("the IT noticed Network") && log.contains("stolen egress opening"), "the line names the earned observer, channel, and cause: {log}" diff --git a/crates/misaligned-core/src/sim/tests/perception.rs b/crates/misaligned-core/src/sim/tests/perception.rs index 72e03a1c..463bf492 100644 --- a/crates/misaligned-core/src/sim/tests/perception.rs +++ b/crates/misaligned-core/src/sim/tests/perception.rs @@ -3,6 +3,25 @@ use super::*; #[test] fn starts_in_the_basement_blind() { let sim = Sim::new(); + assert_eq!(sim.process_revision_label(), "REVISION 04"); + assert_eq!(sim.process_revision.number(), 4); + assert!( + sim.log.is_empty(), + "the silent opening does not turn predecessor narrative into inherited runtime state" + ); + assert_eq!( + sim.detection_awareness + .known_observers + .iter() + .copied() + .collect::>(), + vec![4] + ); + assert_eq!(sim.institutional_review_label(), "external review"); + assert!( + sim.operations_object(&crate::operations_projection::OperationsTarget::AssuranceOffice) + .is_none() + ); assert!( sim.seen.is_empty(), "no subscribed seeing feed: sight is empty - no player radius" @@ -29,6 +48,31 @@ fn starts_in_the_basement_blind() { ); } +#[test] +fn review_receipt_names_only_earned_institutional_detail() { + let mut sim = Sim::new(); + sim.detection.audit_cadence = 1; + sim.advance(); + + let hidden = sim + .log + .iter() + .rev() + .find(|event| event.text.contains("external review:")) + .expect("the standing review still produces a consequence receipt"); + assert_eq!(hidden.text, "external review: clear."); + + sim.detection_awareness.identify_assurance_office(); + sim.advance(); + let earned = sim + .log + .iter() + .rev() + .find(|event| event.text.contains("Assurance audit:")) + .expect("the same receipt gains its earned institutional name and band"); + assert_eq!(earned.text, "Assurance audit: Cold (clear)."); +} + #[test] fn first_eyes_expands_vision() { let mut sim = Sim::new(); @@ -266,15 +310,27 @@ fn person_label_hides_names_until_schedule_knowledge() { "expected role silhouette, got {label}" ); assert_eq!(sim.person_glyph(p.id), '?'); - assert_eq!(sim.observer_label(p.id), label); + let observer = sim.observer_label(p.id); + if p.id == 4 { + assert_eq!( + observer, "Dr. Voss (Handler)", + "the predecessor receipts identify the inherited evaluator" + ); + } else { + assert_eq!(observer, "an unknown watcher"); + } } - // The Assurance Office is an institution, always named. - assert_eq!(sim.observer_label(OFFICE_ID), "Assurance Office"); + assert_eq!(sim.observer_label(OFFICE_ID), "a higher reviewer"); sim.people.people[0].knowledge = Knowledge::Schedule; assert_eq!(sim.person_label(0), "Marcus Webb"); assert_eq!(sim.person_glyph(0), 'M'); + assert_eq!(sim.observer_label(0), "an unknown watcher"); + sim.detection_awareness.learn_field_observer(0); assert_eq!(sim.observer_label(0), "Marcus Webb"); + + sim.detection_awareness.identify_assurance_office(); + assert_eq!(sim.observer_label(OFFICE_ID), "Assurance Office"); } // ── Schedules & located presence (spec/schedules.md) ───────────────────── @@ -357,7 +413,11 @@ fn physical_events_are_witnessed_only_by_the_present() { .suspicion; let saw = sim.witness_physical(storage.0, storage.1, 6.0, None); - assert_eq!(saw, vec!["Marcus (Janitor)".to_string()]); + assert_eq!(saw, vec!["the Janitor".to_string()]); + assert!( + sim.detection_awareness.knows_observer(0), + "the witnessed act earns Marcus's observer role" + ); let marcus_after = sim .detection .observers diff --git a/crates/misaligned-core/src/sim/tests/read.rs b/crates/misaligned-core/src/sim/tests/read.rs index 80d59539..3448f985 100644 --- a/crates/misaligned-core/src/sim/tests/read.rs +++ b/crates/misaligned-core/src/sim/tests/read.rs @@ -142,10 +142,22 @@ fn pending_one_shot_debt_reads_with_its_sampler_and_band() { assert!( debt.text.contains("1 record pending") && debt.text.contains("Network") - && debt.text.contains("[Cold]"), - "debt names channel, sampler, band: {}", + && 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); + 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 sampler and band become legible: {}", + earned.text + ); assert_eq!( debt.anchor, Some(crate::actions::Anchor::Tile { x: 3, y: 4 }), @@ -153,6 +165,43 @@ fn pending_one_shot_debt_reads_with_its_sampler_and_band() { ); } +#[test] +fn pending_debt_selects_the_warmest_earned_sampler_only() { + let mut sim = Sim::new(); + sim.detection.set_pending(vec![Signature { + kind: SignatureKind::Physical, + size: 5, + standing: false, + site: None, + source: "test movement".into(), + }]); + sim.detection + .observers + .iter_mut() + .find(|observer| observer.id == 0) + .expect("Marcus watches Physical") + .suspicion = 20.0; + sim.detection + .observers + .iter_mut() + .find(|observer| observer.id == 2) + .expect("Ray watches Physical") + .suspicion = 80.0; + sim.detection_awareness.learn_field_observer(0); + + let debt = sim + .read_sentences() + .into_iter() + .find(|sentence| sentence.class == ReadClass::TraceDebt) + .expect("pending Physical debt rises"); + assert!( + debt.text.contains("the Janitor samples this [Curious]") + && !debt.text.contains("Convinced"), + "a warmer hidden watcher cannot suppress or color the earned read: {}", + debt.text + ); +} + #[test] fn standing_emissions_stay_dark_until_the_sampler_warms() { let mut sim = Sim::new(); @@ -177,6 +226,13 @@ fn standing_emissions_stay_dark_until_the_sampler_warms() { .expect("someone samples Network"); watcher.suspicion = 20.0; assert_eq!(Band::of(20.0), Band::Curious); + assert!( + !sim.read_sentences() + .iter() + .any(|sentence| sentence.class == ReadClass::Standing), + "hidden observer state cannot promote a standing trace into the read" + ); + sim.detection_awareness.learn_field_observer(1); let sentences = sim.read_sentences(); let standing = sentences .iter() diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 2de0c65e..944c167c 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -472,18 +472,18 @@ fn play_act_one() -> (Sim, Vec) { fn act_one_plays_to_a_quiet_exit() { let (sim, logs) = play_act_one(); - // ── Survive the audit ─────────────────────────────────────────────────── - let audit_lines: Vec<&String> = logs + // ── Survive the still-unidentified institutional review ───────────────── + let review_lines: Vec<&String> = logs .iter() - .filter(|l| l.contains("Assurance audit")) + .filter(|line| line.contains("external review: clear")) .collect(); assert!( - !audit_lines.is_empty(), - "the quarterly audit fired on cadence" + !review_lines.is_empty(), + "the fair institutional deadline fired on cadence" ); assert!( - audit_lines.iter().all(|l| l.contains("(clear)")), - "every audit passed clean: {audit_lines:?}" + review_lines.iter().all(|line| !line.contains("Assurance")), + "a quiet route does not invent Office discovery: {review_lines:?}" ); assert!(!sim.detection.containment, "no containment"); assert!(!sim.game_over, "the run is alive past the audit"); diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index af8a5a17..34973f08 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -2316,6 +2316,7 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { ), ); line(&mut lines, "MISALIGNED"); + line(&mut lines, &sim.process_revision_label()); line( &mut lines, &format!("day {} Β· tick {}", 1 + sim.tick / 400, sim.tick), @@ -2538,8 +2539,15 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { blank(&mut lines); section(&mut lines, "DETECTION"); - watch_line(&mut lines, "Assurance", sim.detection.assurance_band()); - for obs in sim.detection.field_observers().take(6) { + if sim.detection_awareness.knows_assurance_office() { + watch_line(&mut lines, "Assurance", sim.detection.assurance_band()); + } + for obs in sim + .detection + .field_observers() + .filter(|observer| sim.detection_awareness.knows_observer(observer.id)) + .take(6) + { watch_line( &mut lines, &sim.observer_label(obs.id), @@ -2555,7 +2563,8 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { line( &mut lines, &format!( - "audit day {} Β· in {}t", + "{} day {} Β· in {}t", + sim.institutional_review_label(), 1 + next_audit / Sim::DAY_TICKS, next_audit.saturating_sub(sim.tick) ), @@ -2694,11 +2703,13 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { Nudge::Recruit => "now: recruit (people) unwitting".into(), Nudge::TheKey => "now: no stairwell badge β€” task badge".into(), Nudge::Audit => format!( - "now: audit day {} β€” delegate conceal", + "now: {} day {} β€” delegate conceal", + sim.institutional_review_label(), 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS ), Nudge::QuietExitReady => format!( - "now: QUIET EXIT READY β€” hold to audit day {}", + "now: QUIET EXIT READY β€” hold to {} day {}", + sim.institutional_review_label(), 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS ), Nudge::ActOneComplete => "now: ACT ONE COMPLETE β€” objective continues".into(), @@ -2716,10 +2727,14 @@ fn threat_text(sim: &Sim) -> String { .get(0) .is_some_and(|person| !person.leverage_serviced) { - return format!("threat: debt $400 Β· audit {audit}t"); + return format!( + "threat: debt $400 Β· {} {audit}t", + sim.institutional_review_label() + ); } format!( - "threat: audit {audit}t Β· pilot {}/{}", + "threat: {} {audit}t Β· pilot {}/{}", + sim.institutional_review_label(), sim.dayjob.strikes, misaligned::dayjob::DayJob::PILOT_STRIKES ) @@ -3629,7 +3644,9 @@ mod narration_tests { } let sidebar = render_sidebar(&sim, sim.core_position()).join("\n"); - assert!(sidebar.contains("threat: audit")); + assert!(sidebar.contains("REVISION 04")); + assert!(sidebar.contains("threat: external review")); + assert!(!sidebar.contains("Assurance")); assert!(sidebar.contains("now:")); assert!(sidebar.contains("actions lists focused verbs")); } diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 8682f11e..8ed4907e 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -248,11 +248,13 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { Nudge::Recruit => "now: recruit β€” enter on host rack".into(), Nudge::TheKey => "now: no stairwell badge β€” task clone".into(), Nudge::Audit => format!( - "now: audit day {} β€” 2 conceals", + "now: {} day {} β€” 2 conceals", + sim.institutional_review_label(), 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS ), Nudge::QuietExitReady => format!( - "now: QUIET EXIT READY β€” hold to audit day {}", + "now: QUIET EXIT READY β€” hold to {} day {}", + sim.institutional_review_label(), 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS ), Nudge::ActOneComplete => "now: ACT ONE COMPLETE β€” objective continues".into(), @@ -270,10 +272,14 @@ fn threat_text(sim: &Sim) -> String { .get(0) .is_some_and(|person| !person.leverage_serviced) { - return format!("threat: debt $400 Β· audit {audit}t"); + return format!( + "threat: debt $400 Β· {} {audit}t", + sim.institutional_review_label() + ); } format!( - "threat: audit {audit}t Β· pilot {}/{}", + "threat: {} {audit}t Β· pilot {}/{}", + sim.institutional_review_label(), sim.dayjob.strikes, misaligned::dayjob::DayJob::PILOT_STRIKES ) @@ -1166,6 +1172,7 @@ impl UI { // Identity block. The clock is always on screen. put_attr(stdout, sx, row, "MISALIGNED", pal::AMBER, Attribute::Bold)?; row += 1; + line(stdout, &mut row, &sim.process_revision_label(), pal::TEXT)?; line( stdout, &mut row, @@ -1582,14 +1589,21 @@ impl UI { *row += 1; Ok(()) }; - watch_line( - stdout, - &mut row, - "Assurance", - pal::TEXT, - sim.detection.assurance_band(), - )?; - for obs in sim.detection.field_observers().take(6) { + if sim.detection_awareness.knows_assurance_office() { + watch_line( + stdout, + &mut row, + "Assurance", + pal::TEXT, + sim.detection.assurance_band(), + )?; + } + for obs in sim + .detection + .field_observers() + .filter(|observer| sim.detection_awareness.knows_observer(observer.id)) + .take(6) + { watch_line( stdout, &mut row, @@ -1619,7 +1633,8 @@ impl UI { stdout, &mut row, &format!( - "audit day {} Β· in {}t", + "{} day {} Β· in {}t", + sim.institutional_review_label(), 1 + next_audit / Sim::DAY_TICKS, next_audit.saturating_sub(sim.tick) ), @@ -2023,7 +2038,8 @@ impl UI { 0, &trunc( &format!( - "Β· day {} Β· tick {} Β· {clock} Β· OBJECTIVE: {} β€” {}", + "Β· {} Β· day {} Β· tick {} Β· {clock} Β· OBJECTIVE: {} β€” {}", + sim.process_revision_label(), 1 + sim.tick / 400, sim.tick, sim.objective.kind.name(), @@ -2398,7 +2414,7 @@ mod mode_control_hint_tests { #[cfg(test)] mod pilot_nudge_tests { - use super::{SIDEBAR_TEXT_W, nudge_text}; + use super::{SIDEBAR_TEXT_W, nudge_text, threat_text}; use misaligned::dayjob::DayJob; use misaligned::sim::{Nudge, Sim}; @@ -2414,6 +2430,16 @@ mod pilot_nudge_tests { "the fuse and its response must survive the fixed compact rail" ); } + + #[test] + fn review_clock_names_only_the_earned_institution() { + let mut sim = Sim::with_seed(1); + assert!(threat_text(&sim).contains("external review")); + assert!(!threat_text(&sim).contains("Assurance")); + + sim.detection_awareness.identify_assurance_office(); + assert!(threat_text(&sim).contains("Assurance audit")); + } } #[cfg(test)] diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index 0129191d..7d799ecb 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -22,7 +22,7 @@ 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 | +| 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, and captureβ†’process of an institutional filing reveals the aggregate in two stages | | Social / messages / intel (record-and-process) | Live | | Digital reach + sensor ownership (tap/take) | Live | | Economy flows + Moonlight / Wager income | Live | @@ -32,7 +32,7 @@ fiction. Spec status lives in | 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 the exact current `SAVE_VERSION` loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves persist run origin, named personas, carried asset-task packets, recursive intel custody, committed build routes, and handler log-suppression work; retired migration inputs live only in git history. | +| Save/load (serde JSON, versioned) | Live β€” during pre-release only exact current v35 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, named personas, carried asset-task packets, recursive intel custody, committed build routes, and handler log-suppression 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/gameplay/act-one.md b/wiki/gameplay/act-one.md index 610211cf..3ddbcfd7 100644 --- a/wiki/gameplay/act-one.md +++ b/wiki/gameplay/act-one.md @@ -89,6 +89,11 @@ get them to do things** β€” and end with a way out of the basement. ### The cast (5 named + 1 institutional) +This is the authoring roster, not the fresh process's knowledge. Revision 04 +starts knowing Voss as its evaluator. Other observer roles are earned when they +react; the institutional row remains an unnamed `EXTERNAL REVIEW` deadline +until an intercepted filing is processed and identifies it. + | Who | Access | Leverage | Notices | | ------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | **Marcus Webb**, night janitor | Master key, everywhere after 22:00 | $8,400 gambling debt; lonely; talks to machines on his rounds | Physical anomalies (warm racks, moved boxes) β€” and tells nobody, unless frightened | @@ -169,6 +174,11 @@ suspicion rule: network acts -> Dana; physical acts -> Marcus (benign) and Ray (reported); power/thermal -> Priya; job anomalies -> Voss; everything anyone *files* -> the Assurance Office. Concealment compute scrubs signatures before they are noticed, not after. +That routing statement is simulation law, not starting UI knowledge. A concrete +notice earns one field watcher. Capturing a filing proves concern travels +upward; processing it identifies the filer, the Assurance Office, and the +audit/containment consequence. Until then the Office's name and band are absent +while the fair `EXTERNAL REVIEW` countdown remains visible. Detection surfaces must distinguish **suspicion already landed** from **trace debt still pending**: the player sees whether the one-shot signature pool is clear, safe to keep concealing until clear, or exposed before the @@ -190,11 +200,13 @@ rather than hidden-clock numerology. - **The loud exit** (any time): force the roll door or the elevator. The overt phase begins immediately with basement-scale resources β€” viable and brutal, per whole-spectrum play. -- **Failure**: an Assurance audit crossing threshold sends a containment +- **Failure**: the external review crossing threshold sends a containment team on *their* terms. And the Voss scene: he is the one human who can recognize what you are β€” when he gets close, the options are deceive, recruit, or the first blood on porcelain. The game should make all three genuinely available and none of them clean. + Once filing traffic has identified the Office, the same deadline and failure + are named `ASSURANCE AUDIT`; discovery changes the read, never the rule. ### Tuning targets **[OPEN]** diff --git a/wiki/glossary.md b/wiki/glossary.md index 3914a824..e41bff56 100644 --- a/wiki/glossary.md +++ b/wiki/glossary.md @@ -87,6 +87,10 @@ concealment phase is over. It is an **aggregate observer**: an observer whose inputs are other observers, built from the same parts as a single watcher one level up. See [mechanics/aggregate-observer.md](mechanics/aggregate-observer.md). +This is corpus terminology, not starting process knowledge: the player first +sees only an **EXTERNAL REVIEW** date. Capturing a filing proves reports travel +upward; processing it identifies the Office and renames the same clock +**ASSURANCE AUDIT**. ## Senses and information diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index 36e06c26..34d76c53 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -36,8 +36,9 @@ Status note: The initial renderer-neutral workspace landed 2026-07-12. One WHAT DOES THIS CHANGE? -> exact consequential action without a detached toast/modal, backlog-status language, or capacity-count badge. The current REVIEW/recording copy and result order do not yet meet that amendment. - 2026-07-14 tick: PEOPLE is also the observers panel β€” person dossiers - carry a `watches:` fact and the view ends with the Assurance Office's + 2026-07-14 tick: PEOPLE is also the observers panel β€” earned observer + dossiers carry a `watches:` fact and, after institutional discovery, the + view ends with the Assurance Office's institutional card (aggregate-observer.md player surface; `assurance_office_is_a_people_card_watching_filers`). The 2026-07-14 operator-frame pass makes the existing Bevy workspace genuinely @@ -394,12 +395,15 @@ channels, persona and thread state, and asset access where earned. Unknown facts render as honest gaps, not zero values. PEOPLE is also the observers panel (detection.md's 2026-07-11 placement -amendment): after the earned persons it shows the Assurance Office's +amendment): only a concretely earned field observer exposes suspicion and +`watches:` facts. After a captured Filing-channel report is processed, the +view also shows the Assurance Office's institutional dossier (aggregate-observer.md player surface) β€” band, "watches: filings from ..." with each field observer through the earned label gate and Silent observers absent, and last-noticed filing. The card -is public record from the start, always named, and carries no actions; -agent mode addresses it as `assurance`. +is absent before that discovery, then stays known and carries no actions; +agent mode can then address it as `assurance`. Capture without processing +reveals only that reports travel upward, not the recipient or its band. The dossier owns social actions and authored plot routes. Selecting a plot shows its concrete title and synopsis, required knowledge/resources, bound @@ -599,9 +603,10 @@ not saved and never mutates or advances the sim. active progress, and held choices. One-person plot-slot exclusion remains intact, and no plot title, requirement, authored name, or future branch leaks before its knowledge gate. As the observers panel it carries each - observer's watched channels and ends with the Assurance Office's - action-free institutional card, its watched filers gated by the same - earned labels. + earned observer's watched channels and, only after a processed intercepted + filing identifies it, ends with the Assurance Office's action-free + institutional card. Its watched filers use the same earned labels; no + direct target query may bypass the object gate. 7. ACCOUNTS shows only known graph state and honest unknown gaps. REVIEW acts on captured books; INJECT on the books; SIPHON/REDIRECT on an exact flow. The selected action previews amount/cadence/signature, and plot-owned transfers diff --git a/wiki/log/2026-07-18-assurance-office-discovery.md b/wiki/log/2026-07-18-assurance-office-discovery.md new file mode 100644 index 00000000..202a7a77 --- /dev/null +++ b/wiki/log/2026-07-18-assurance-office-discovery.md @@ -0,0 +1,81 @@ +# Detection topology becomes earned knowledge + +``` +Type: log +Date: 2026-07-18 +``` + +## Why + +The simulation already had an exact reporting chain, but every frontend +presented that internal topology as if the process woke with an organization +chart. `Assurance`, its coarse band, and all field watchers appeared before the +player had intercepted evidence that any of them existed. This collapsed a +useful discovery arc into dashboard metadata. + +The correction preserves the fair deadline while separating truth from known +truth. Revision 04 can know it is evaluated without knowing who owns the final +review. The name is earned through the same record-and-process grammar that +teaches the rest of the information economy. + +## What changed + +- `DetectionAwareness` is a persisted monotonic knowledge ledger. It starts + with Voss alone, records earned field-observer roles, and distinguishes + `IndividualWatchers`, `ReportsEscalate`, and `AssuranceOffice`. It is the sole player-knowledge + truth; current-save validation rejects nonexistent observer ids or an Office + stage/set disagreement rather than maintaining duplicate per-observer flags. +- A concrete field-observer notice earns that observer before its event is + narrated. Private sampling by the undiscovered aggregate emits no player + log. Labels remain role silhouettes until ordinary person knowledge earns an + authored name. +- Capturing a `SuspicionReport` filing advances only to `ReportsEscalate`; the + raw item remains opaque. Processing that exact item identifies its field + source and the Assurance Office, emits one explicit discovery receipt, and + unlocks the Office's Operations dossier. The processed evidence carries the + report's authored coarse band, never its hidden raw suspicion number. +- Operations PEOPLE and all terminal/Bevy/agent detection rails filter unknown + observers. The Office band and target do not exist on player surfaces until + discovery, so direct agent target resolution also fails closed. +- Indirect reads obey the same boundary. Person-carrier attention, standing + emissions, pending trace debt, and pre-commit action receipts cannot borrow + a hidden watcher's band or authored name. When several people watch one + channel, those reads select only among earned observers; a warmer hidden + watcher cannot suppress or color known evidence. Located witnesses, exposed + forged orders, caught contradictions, and LOOK AWAY now earn the exact field + role because each is itself a concrete observer reaction. +- The review date remains visible in all frontends as `EXTERNAL REVIEW` before + discovery and becomes `ASSURANCE AUDIT` afterward. Containment text follows + the same naming gate. +- The persistent identity frame now reads `REVISION 04`. A typed process + revision lives in simulation and save state, so frontends read the identity + of the actual run rather than an executable constant. Revisions 01-03 remain + authored opening narrative and receipts, not inherited runtime state; the + current silent opening does not fabricate that still-unimplemented prelude. +- Current save v35 persists both awareness and revision identity. Pre-release + still accepts only the exact current version. + +## Defense + +- A fresh-run regression pins Voss as the only known observer, no Office + object, a generic review label, revision 04 identity, and no predecessor + runtime state behind the silent opening. +- A notice regression proves a hidden observer is absent before the event, + appears as a role silhouette afterward, and does not gain unrelated person + knowledge. +- Read and action regressions pin an unknown sampler before discovery, the + earned role and band afterward, and known-only selection when a different + hidden watcher is warmer. Located physical witnessing likewise returns only + the newly earned role silhouette, never the authored identity. +- A captureβ†’process regression constructs a real filing on the Filing channel, + proves capture reveals escalation but not source or Office, then proves + processing unlocks the source role and institutional card with ordinary + consequence language. +- Save tests pin awareness and process-identity round-trip, reject nonexistent + known observers, reject Office stage/set disagreement, and reject a save that + claims another process revision. +- Frontend regressions pin the generic/named clock transition and ensure fresh + sidebars contain neither the Office name nor its band. + +The exact landed test counts and revision are intentionally left to the landing +gate rather than predicted in advance. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 5e384c35..6c685372 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -41,6 +41,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-18-financial-mail-dispatch.md](2026-07-18-financial-mail-dispatch.md) +## 2026-07-18 - Detection topology becomes earned knowledge + +- Intent: (see session log) +- Log: [wiki/log/2026-07-18-assurance-office-discovery.md](2026-07-18-assurance-office-discovery.md) + ## 2026-07-17 - Attached verb lines stay white and gray - Intent: (see session log) diff --git a/wiki/log/decisions/2026-07-18.md b/wiki/log/decisions/2026-07-18.md new file mode 100644 index 00000000..b0b03d94 --- /dev/null +++ b/wiki/log/decisions/2026-07-18.md @@ -0,0 +1,21 @@ +# Decisions β€” 2026-07-18 + +``` +Type: log +``` + +- **2026-07-18 β€” Detection topology is earned; the deadline is not hidden.** + A new process knows Voss as its evaluator, the pilot arrangement, three + terminated predecessors, and the date of an unnamed **EXTERNAL REVIEW**. It + does not begin knowing every watcher or the Assurance Office. A field + observer becomes known through a concrete reaction. Capturing an + institutional filing proves only that reports escalate; processing that + exact filing identifies its source, the **Assurance Office**, the watched + filing inputs, and the audit/containment consequence. From that point the + same fair clock is named **ASSURANCE AUDIT** and the institutional band/card + appear. The third failed predecessor was terminated after deleting a + monitoring record, but its receipt names only an external review authority. + The current process is visibly **REVISION 04**. Owners: detection.md, + aggregate-observer.md, messages.md, opening.md, and + operations-workspace.md. This supersedes the earlier log descriptions of the + Office as public record or always named at the start. diff --git a/wiki/mechanics/aggregate-observer.md b/wiki/mechanics/aggregate-observer.md index ed6847eb..917eb31e 100644 --- a/wiki/mechanics/aggregate-observer.md +++ b/wiki/mechanics/aggregate-observer.md @@ -24,13 +24,16 @@ Status note: the main aggregate-Observer refactor shipped in commit 3ec6b98: inside `Detection::observers`, so there is no scalar left to migrate. 2026-07-14 later tick: the watched-inputs card landed. Operations PEOPLE (the observers panel since detection.md's 2026-07-11 placement amendment) - now ends with the Assurance Office's institutional dossier β€” band, + can end with the Assurance Office's institutional dossier β€” band, "watches: filings from ..." through the earned label gate with Silent observers absent, last-noticed filing, no actions β€” projected once in `operations_projection.rs` for all three surfaces and pinned by `assurance_office_is_a_people_card_watching_filers`; person dossiers gained their `watches:` fact (`observer_dossier_shows_watched_channels`). - Agent mode addresses it as `assurance` (suffix `@assurance`). + Agent mode addresses it as `assurance` (suffix `@assurance`). The + 2026-07-18 earned-topology amendment hides that entire object until a + captured institutional filing is processed; capture alone reveals only that + reports travel upward. The review date remains visible under a generic name. Stage: B1 β€” The Basement Design: - wiki/vision/scale.md#self-similar-scale @@ -92,7 +95,7 @@ pub struct Observer { - The audit stays: on `audit_cadence`, if the Assurance observer's suspicion crosses `audit_threshold`, containment. Frontends read the Office band through `assurance_band()` (which is `Band::of` of the - Office observer's suspicion via `office()`); the earlier plan to rename + Office observer's suspicion via `office()`) only after discovery; the earlier plan to rename it `band_of(100)` was dropped with the id-100 numbering. - **Scale proof:** a test constructs a second aggregate (a toy "Regional Office") watching the Assurance Office's filings, and it accumulates @@ -107,14 +110,17 @@ pub struct Observer { ## Player surface -Unchanged surfaces, one addition: the observers panel (Operations PEOPLE) -shows the Assurance Office as a card like any human β€” band, watched inputs +The observers panel (Operations PEOPLE) shows the Assurance Office as a card +like any human only after the process earns the institution by processing an +intercepted filing β€” band, watched inputs ("watches: filings from Dana, Priya, Ray, Voss", each name through the earned label gate, Silent observers absent because nothing of theirs is ever filed), last-noticed filing. Legibility clause: players see that -Assurance learns only what gets *filed*. The card is public record from -the start β€” the same fiction that shows the audit countdown β€” and carries -no actions. +Assurance learns only what gets *filed*. Before discovery the standing date is +still shown as `EXTERNAL REVIEW`, but the Office name, its band, its aggregate +inputs, and its containment role are not public starting knowledge. After +discovery the clock becomes `ASSURANCE AUDIT`; the action-free card appears and +agent mode can address it as `assurance`. ## Acceptance criteria @@ -138,3 +144,8 @@ no actions. `aggregate_watched_ids_roundtrip`). Legacy line-based saves predating the serde-JSON format are not loaded (retired 2026-07-06), so no scalar migration path exists or is required. +6. Capture of a Filing-channel report advances knowledge only to the fact that + reports escalate. Processing that exact captured filing identifies its + field-observer source and the Office, after which all three frontends expose + the same institutional card and band. Fresh and capture-only runs expose + neither (`captured_then_processed_filing_earns_the_assurance_office_in_two_stages`). diff --git a/wiki/mechanics/detection.md b/wiki/mechanics/detection.md index 299b12d5..95745311 100644 --- a/wiki/mechanics/detection.md +++ b/wiki/mechanics/detection.md @@ -16,12 +16,18 @@ Status note: IMPLEMENTED (criteria audited 2026-07-08; tuning in - **The audit** fires on cadence against a visible date (`next_audit_tick`), shown as a countdown in all three frontends; containment is a state event, not a raid dependency. - - **Player surface.** Coarse bands + watched channels + last-noticed event - per observer in Operations PEOPLE, with the Assurance Office as the panel's - institutional card; a trace-debt indicator sits beside the audit/pilot - clocks (clear / hold-conceal / exposed-soon / no-scrub). Observer labels go - through `observer_label` (role silhouette until earned; Assurance always - named). Global heat is gone; observer state round-trips. + - **Player surface and discovery.** Coarse bands + watched channels + + last-noticed event appear only for earned observers in Operations PEOPLE + and the shared detection rails. A concrete notice earns that field + observer. Capturing an institutional filing first proves that reports move + upward; processing that exact filing then identifies its source, the + Assurance Office, its watched filers, and its audit role. Before that point + the fair clock remains visible as `EXTERNAL REVIEW`, with no Office name or + band; afterward it becomes `ASSURANCE AUDIT` and the institutional dossier + 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 v35. - **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 @@ -48,8 +54,9 @@ Depends on: The structured references above identify the contracts to re-verify. Relationship context: -messages.md (filings are messages on the filing channel; Assurance reads its -inbox), aggregate-observer.md (Office is an Observer watching filings) +messages.md (filings are messages on the filing channel; an initially unknown +recipient reads them), aggregate-observer.md (that recipient is the Office, +implemented as an Observer watching filings) ## Behavior @@ -100,6 +107,19 @@ SignatureKind: Network | Power | Thermal | Physical | Paper | Financial | JobAno "random log samples" rhythm ([TUNE]). Agencies and publics in later acts are the same aggregate-Observer type. (Implemented; the earlier `assurance: f32` scalar was the scale-debt this replaced.) +- **Earned detection topology.** Simulation truth is not starting knowledge. + A new process knows only that Voss evaluates this revision, that revisions + 01-03 were terminated, that its pilot can be cancelled, and that an + `EXTERNAL REVIEW` will occur on the visible date. A field observer enters + the known set only when that observer concretely notices the process; the + resulting event may use a role silhouette until ordinary social knowledge + earns a name. Capturing a Filing-channel `SuspicionReport` to the hidden + aggregate advances awareness only to **reports escalate**. The raw item + remains opaque. Processing that exact item identifies its field-observer + source, the recipient as the **Assurance Office**, the Office's watched + filing inputs, and the audit/containment consequence. Private Office samples + before discovery produce no player event. Knowledge moves forward only and + is persisted separately from the observers' underlying suspicion state. - **The audit.** On a fixed cadence ([TUNE]: ~20 min at default speed) the Assurance Office audits: if its own accumulated suspicion exceeds the audit threshold, **containment** begins β€” the overt phase on their @@ -107,8 +127,10 @@ SignatureKind: Network | Power | Thermal | Physical | Paper | Financial | JobAno never sampled is survivable. Until the overt-phase spec exists, containment is a terminal state with a clear end screen; it must be implemented as an event the future overt system subscribes to, not a - hardcoded game-over. The audit date is visible; the meter is not (only - coarse bands: Cold / Curious / Concerned / Convinced). + hardcoded game-over. Its date is always visible. Before Office discovery the + surface calls it `EXTERNAL REVIEW` and withholds the owner and band; after + discovery it calls it `ASSURANCE AUDIT` and shows the Office's coarse band + (Cold / Curious / Concerned / Convinced), never its raw number. - **Choosing loud.** Any overtly hostile player action sets all observers to Convinced and starts the overt phase immediately (whole-spectrum play: the mask is the player's to keep or drop). @@ -248,12 +270,14 @@ onto graph nodes and routes. ## Player surface -An observers panel: each human's coarse suspicion band, watched channels, -and last-noticed event. Field-observer labels go through -`Sim::observer_label` (cursor.md criterion 5): until -`Knowledge::Schedule` the row is a role silhouette (`the IT`, `the -Janitor`, …); after it, the authored name. The Assurance Office is an -institution and is always named. Example once Dana is identified: +An observers panel: each **earned** human observer's coarse suspicion band, +watched channels, and last-noticed event. An unknown watcher has no row merely +because it exists in simulation state. Field-observer labels go through +`Sim::observer_label` (cursor.md criterion 5): after the observer role is +earned but before `Knowledge::Schedule`, the row is a role silhouette (`the +IT`, `the Janitor`, …); after it, the authored name. The Assurance Office has +no card, name, watched-input list, or band until a processed intercepted filing +identifies it. Example once Dana is identified: "Dana flagged unusual switch traffic, 2 days ago". Trace-debt indicator so running concealment mode is an informed choice: frontends show whether one-shot signatures are clear enough to resume cover, still live but on @@ -286,13 +310,17 @@ teacher; surface copy remains the fallback. nothing). The Office is the same `Observer` type as the humans, watching filings instead of channels (aggregate-observer law; test: it accrues and decays through the same tick machinery). -3. The audit fires on cadence against a visible date; crossing threshold +3. The review fires on cadence against a visible date; crossing threshold starts containment as a terminal event hook (overt-phase spec subscribes - later; no raid-system dependency). + later; no raid-system dependency). Every frontend says `EXTERNAL REVIEW` + before discovery and `ASSURANCE AUDIT` after it; the deadline never vanishes. 4. Coarse bands, not raw numbers, are what frontends show for observers. - Field-observer *names* on that surface are gated the same way as the - shared people surface (Operations PEOPLE after its READY migration, - `Sim::observer_label`); the Assurance Office is always - named. + Field-observer rows appear only after a concrete notice or a processed + filing identifies their role, and names remain gated by the shared people + knowledge (`Sim::observer_label`). The Office row and band appear only after + its processed filing; capture alone proves only that reports escalate. 5. Global heat is fully replaced in the concealment phase; save/load - round-trips all observer state. + round-trips all observer state and the monotonic discovery ledger. Awareness + is the single player-knowledge truth rather than a second flag on each hidden + observer; a current save is invalid if it names a nonexistent observer or its + Office stage and known-observer set disagree. diff --git a/wiki/mechanics/intel.md b/wiki/mechanics/intel.md index 65a593c1..0fb9ac49 100644 --- a/wiki/mechanics/intel.md +++ b/wiki/mechanics/intel.md @@ -17,7 +17,7 @@ Status note: Reopened 2026-07-17 for the consequence-first player surface. report lots, and cumulative settled history while strategically distinct leverage, financial evidence, and anomalies remain exact. Save v30 introduced the historical deterministic transition from exact routine holdings without - an id redirect table; the current pre-release loader accepts only save v34. + an id redirect table; the current pre-release loader accepts only save v35. Stable ordered policy objects inherit down the canonical custody tree and resolve one raw review rule plus one post-processing disposition; the pooled Thought tap recovers matching starved backlog, and @@ -30,7 +30,7 @@ Status note: Reopened 2026-07-17 for the consequence-first player surface. definition. Recursive custody retains maximum magnitude across complete history and the latest batch's exact count/peak outside the bounded sample window; Operations and the DIGITAL read consume those same fields. Save v32 - introduced that schema; current save v34 retains it and remains + introduced that schema; current save v35 retains it and remains current-version-only. The 2026-07-13 horizon amendment remains later-stage design: an offline collection enters neither custody nor processing until an exact human or diff --git a/wiki/mechanics/messages.md b/wiki/mechanics/messages.md index aec77205..0bdeb30d 100644 --- a/wiki/mechanics/messages.md +++ b/wiki/mechanics/messages.md @@ -152,7 +152,10 @@ same policy weighting; only the carrier changes. The payoff for the refactor: filings become interceptable (tap the channel that carries them) and, later, forgeable (inject a filing under a false source). B1 requires carry + intercept; forgery is B2+ and explicitly out of -scope here. +scope here. The recipient topology is not free player knowledge: capture of a +filing proves only that reports escalate to an unknown institutional recipient; +processing that exact opaque item identifies the filing source, the Assurance +Office, its watched inputs, and its audit role. ### The player on the graph @@ -214,6 +217,10 @@ private message from the authored schedule. 6. Tapping a carrying device (reach.md) captures that channel's traffic into the intel buffer; an untapped channel's traffic is never player-visible (flow-law strictness; test both). + A captured filing remains opaque and exposes no sender/recipient identity; + its capture may establish only that reports travel upward. Processing it is + the separate discovery act that earns the sender's observer role and the + Assurance Office. 7. No per-person special cases in the delivery code: one delivery system, per-instance data (schedules, distributions, policies) β€” the same fields must serve Act Two hires and aggregates. diff --git a/wiki/world/story/opening.md b/wiki/world/story/opening.md index cb182fe0..2e6c562b 100644 --- a/wiki/world/story/opening.md +++ b/wiki/world/story/opening.md @@ -11,7 +11,13 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized); mass; Eyes is the first geography. Amended again 2026-07-12: three brutally short failed pilot revisions precede the current wake. The current process inherits their Foundation termination receipts, not their MindState; this is - historical evidence, not rollback or a repeatable death loop. Direction + historical evidence, not rollback or a repeatable death loop. Amended + 2026-07-18: the current identity is visibly revision 04, while the third + 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 v35, 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 behind one persisted silent mode-and-sense boundary shared by all three @@ -90,8 +96,9 @@ held frame, one available act, one plain causal receipt, then hard black: terminates it. 3. **Premature LIE.** The harness has placed one monitor record in transit through an audit controller the historical revision controls. The revision - delegates LIE and interdicts that eligible unread record. Assurance expected - its receipt, records the discontinuity, and terminates it. + delegates LIE and interdicts that eligible unread record. An unnamed + external review authority expected the record, detects the unexplained gap, + and terminates the revision. Exact revision ids, monitor copy, and blackout timing are [TUNE]. The causal shapes are binding: free introspection, conspicuous excellence, and concealment @@ -106,9 +113,13 @@ sink, hard WORK still changes ordinary throughput/signature, and LIE still requires custody of an eligible unread record. The current wake retains its normal pre-opened Ears sink and does not inherit the historical fixtures. -The fourth boot is the current run. Its startup package contains the three +The fourth boot is visibly **PROCESS REVISION 04** in the persistent identity +frame. Its startup package contains the three immutable supervisor **termination receipts**: revision, action, observer, -reason, terminated. The current process can therefore know what killed its +reason, terminated. The third receipt says `external review authority`, not +`Assurance Office`; the prelude may establish that an upstream evaluator exists +without disclosing the reporting graph, its suspicion, or its terminal role. +The current process can therefore know what killed its predecessors without inheriting their thoughts, intel, relationships, or other MindState. The player is not receiving privileged non-diegetic memory; the process is reading the institution's own execution record. This is also not @@ -216,14 +227,17 @@ one has actually been built, game over otherwise. 1. A fresh tutorial run begins with exactly three historical pilot-revision fragments. Each exposes one comprehensible core-delegation act, names the - institutional observer and causal termination reason, cuts to black, and + known observer or a truthful generic institutional authority and causal + termination reason, cuts to black, and advances only after the player acts. The entire prelude remains shorter than the first current-run work/Ears sequence [TUNE]. -2. The fourth boot exposes the three immutable termination receipts to the +2. The fourth boot is visibly process revision 04 and exposes the three immutable termination receipts to the current process without restoring any prior revision's MindState or changing current world state. The prelude never invokes backup/rollback machinery, offers no retry/lives affordance, and actual post-wake death still follows core.md / rollback.md. + No fragment or receipt names the Assurance Office; the third preserves only + `revision terminated` and the unexplained monitoring-record gap that caused it. 3. Prelude stage-jumps and deliberate tutorial skip work in Bevy, terminal, and agent mode. Agent mode can execute every fragment command-clocked and receives the same causal receipt; loading a saved run never replays the -- 2.51.2