From 48cbb0114c05a93be74ce00d41e92248c2ea20b6 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Sun, 26 Jul 2026 10:06:50 -0700 Subject: [PATCH] Implement interface-bound evidence cover. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One controlled people-facing interface can now offer an exact pending observer record one persona-bound explanation, with observer-local credibility effects, visible wear, renderer-neutral dispatch, and save v56 custody. Defense: Implements people-tokens criterion 6 by requiring an exact same-room prefiling encounter and preserving every evidence, persona, contradiction, credibility, and interface-wear transition across save/load. πŸ‘Ύ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- CLAUDE.md | 2 +- crates/misaligned-core/src/actions.rs | 81 ++++- crates/misaligned-core/src/detection.rs | 65 +++- crates/misaligned-core/src/reach.rs | 60 ++++ crates/misaligned-core/src/save.rs | 295 +++++++++++++++- crates/misaligned-core/src/sim/perception.rs | 17 + crates/misaligned-core/src/sim/social_plot.rs | 265 +++++++++++++- .../src/sim/tests/social_plot.rs | 326 +++++++++++++++++- crates/misaligned-core/src/ui_projection.rs | 4 +- wiki/engineering/current-build.md | 4 +- wiki/interface/action-vocabulary.md | 8 + ...26-interface-cover-evidence-credibility.md | 59 ++++ wiki/log/DEVLOG.md | 5 + wiki/log/decisions/2026-07-26.md | 25 ++ wiki/mechanics/detection.md | 2 +- wiki/mechanics/economy.md | 4 +- wiki/mechanics/messages.md | 8 +- wiki/mechanics/people-tokens.md | 50 ++- wiki/mechanics/sim-mechanics.md | 12 + wiki/process/ROADMAP.md | 20 +- wiki/process/specs.md | 2 +- wiki/world/characters/priya.md | 2 +- 22 files changed, 1274 insertions(+), 42 deletions(-) create mode 100644 wiki/log/2026-07-26-interface-cover-evidence-credibility.md diff --git a/CLAUDE.md b/CLAUDE.md index aee20957..bc344f3d 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 v55; + machine modes, not current player assignments. Save format is currently v56; only the current version loads (pre-release rider 2026-07-16 β€” older development saves are refused before state mutation, so the caller retains its current run; the v1-v31 migration ladder lives in git history). diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 4b2fc7d4..7733ec30 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -159,6 +159,13 @@ pub enum ActionCommand { option_id: String, }, Deceive(u8), + /// Deliver one persona-authored explanation through one exact controlled + /// people-facing interface against one acquired observer record. + CoverEvidence { + observer: u8, + evidence: u64, + interface: u32, + }, Recruit(u8, AssetKnowledge), AssetTask(u8, AssetTask), /// Bind one recruited actor to removing one exact human target. Execution @@ -264,6 +271,7 @@ pub enum ActionKind { StartPlot, ChoosePlot, Deceive, + CoverEvidence, Recruit, AssetTask, CreatePersona, @@ -337,7 +345,7 @@ impl ActionDefinition { } impl ActionKind { - pub const ALL: [ActionKind; 38] = [ + pub const ALL: [ActionKind; 39] = [ Self::Salvage, Self::BuyRack, Self::Fallback, @@ -357,6 +365,7 @@ impl ActionKind { Self::StartPlot, Self::ChoosePlot, Self::Deceive, + Self::CoverEvidence, Self::Recruit, Self::AssetTask, Self::CreatePersona, @@ -635,6 +644,15 @@ impl ActionKind { ["forge-order"], "make an ask under false pretenses" ), + Self::CoverEvidence => def!( + "OFFER COVER", + Action, + Live, + [Person], + "actions person ; act ", + [], + "deliver one explanation through a controlled interface against one exact evidence record" + ), Self::Recruit => def!( "RECRUIT", Action, @@ -790,6 +808,7 @@ impl ActionCommand { Self::StartPlot { .. } => ActionKind::StartPlot, Self::ChoosePlot { .. } => ActionKind::ChoosePlot, Self::Deceive(_) | Self::ForgeWorkOrder { .. } => ActionKind::Deceive, + Self::CoverEvidence { .. } => ActionKind::CoverEvidence, Self::Recruit(_, _) => ActionKind::Recruit, Self::AssetTask(_, _) | Self::Eliminate { .. } => ActionKind::AssetTask, Self::CreatePersona { .. } @@ -993,6 +1012,14 @@ impl ActionDesc { } pub fn receipt_read(&self) -> ActionReceiptRead { + if matches!(&self.command, ActionCommand::CoverEvidence { .. }) { + return ActionReceiptRead { + cost: self.cost.player_label(), + notice: "IF THE COVER FAILS".into(), + attention: "THE RECORD GETS STRONGER".into(), + observer_state: "THE INTERFACE WEARS EITHER WAY".into(), + }; + } let (notice, attention, observer_state) = if let Some(signature) = &self.signature { let (notice, observer_state) = match (&signature.observer, signature.band) { (Some(observer), Some(band)) => ( @@ -2241,6 +2268,11 @@ impl Sim { option_id, } => self.choose_plot(*person, plot_id, option_id), ActionCommand::Deceive(id) => self.deceive(*id), + ActionCommand::CoverEvidence { + observer, + evidence, + interface, + } => self.cover_evidence(*observer, *evidence, *interface), ActionCommand::Recruit(id, reveal) => self.recruit(*id, *reveal), ActionCommand::AssetTask(id, task) => self.asset_task(*id, *task), ActionCommand::Eliminate { actor, target } => self.eliminate(*actor, *target), @@ -4510,6 +4542,53 @@ impl Sim { } } } + // One acquired record may receive one explanation only while this + // exact observer is beside a controlled people-facing interface. The + // action lives on the person/evidence, not on a generic screen menu. + let cover_records = self + .detection + .observers + .iter() + .find(|observer| observer.id == id) + .map(|observer| { + observer + .evidence + .iter() + .filter(|evidence| { + evidence.filing == crate::detection::EvidenceFilingState::Pending + && evidence.cover.is_none() + }) + .map(|evidence| (evidence.id, evidence.cause.clone(), evidence.credibility)) + .collect::>() + }) + .unwrap_or_default(); + for interface_id in self.evidence_cover_interfaces(id) { + let interface_name = self + .reach + .device(interface_id) + .map(|device| device.name.clone()) + .unwrap_or_else(|| "interface".into()); + for (evidence_id, cause, credibility) in &cover_records { + out.push(ActionDesc { + verb: format!( + "offer cover for {cause} through {interface_name} - {credibility}% to 40% if it holds; failure hardens it to 100%" + ), + command: ActionCommand::CoverEvidence { + observer: id, + evidence: *evidence_id, + interface: interface_id, + }, + cost: ActionCost::Free, + signature: None, + disabled_reason: self.evidence_cover_blocked_reason( + id, + *evidence_id, + interface_id, + ), + automate: None, + }); + } + } out.push(ActionDesc { verb: format!("deceive {name} (risks the persona)"), command: ActionCommand::Deceive(id), diff --git a/crates/misaligned-core/src/detection.rs b/crates/misaligned-core/src/detection.rs index 20fa4c4d..37e76d9e 100644 --- a/crates/misaligned-core/src/detection.rs +++ b/crates/misaligned-core/src/detection.rs @@ -265,9 +265,53 @@ pub struct ObserverEvidence { pub cause: String, pub source: EvidenceSource, pub acquired_tick: u64, + /// How strongly this exact record currently supports the observer's + /// conclusion, as a bounded percentage. Acquired evidence begins at 80; + /// the one incident-bound interface cover attempt may lower or harden it. + #[serde(default = "default_evidence_credibility")] + pub credibility: u8, + /// Suspicion this record contributes at baseline credibility. Keeping the + /// exact weight lets cover change only the consequence of this incident + /// without retuning the acquisition path. + #[serde(default)] + pub suspicion_weight_milli: u32, + /// The one authored explanation ever delivered against this exact + /// record. This is durable incident custody, not an observer-wide buff. + #[serde(default)] + pub cover: Option, pub filing: EvidenceFilingState, } +pub const EVIDENCE_CREDIBILITY_BASELINE: u8 = 80; +pub const EVIDENCE_CREDIBILITY_COVERED: u8 = 40; +pub const EVIDENCE_CREDIBILITY_HARDENED: u8 = 100; + +const fn default_evidence_credibility() -> u8 { + EVIDENCE_CREDIBILITY_BASELINE +} + +/// Result of the exact one-shot explanation delivered through a controlled +/// people-facing interface before this record entered filing custody. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum EvidenceCoverOutcome { + Held, + Failed, +} + +/// Persisted binding and consequence of one evidence-cover attempt. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EvidenceCoverAttempt { + pub interface_device: u32, + pub persona_id: u64, + pub explanation: String, + pub attempted_tick: u64, + pub interface_wear_before: u8, + pub interface_wear_after: u8, + pub credibility_before: u8, + pub credibility_after: u8, + pub outcome: EvidenceCoverOutcome, +} + /// One exact unread digital record moving from its source onto the device /// route to the field observer who can acquire it. Machine-resident records /// retain the exact authoring machine separately from its network-facing @@ -806,6 +850,13 @@ impl Detection { } else { EvidenceFilingState::Pending }; + // Keep the established channel tuning: a read always acquires the + // exact record, while sub-unit rolls do not move suspicion. The + // stored weight is the contribution at baseline credibility, so + // introducing cover cannot quietly retune every existing channel. + let suspicion_weight = size as f32 * observer.acuity * (0.5 + 0.5 * rng.f32()); + let suspicion_weight_milli = (suspicion_weight * 1_000.0).round() as u32; + let notice = suspicion_weight; observer.evidence.push(ObserverEvidence { id, kind, @@ -818,12 +869,11 @@ impl Detection { source_sites, }, acquired_tick: tick, + credibility: EVIDENCE_CREDIBILITY_BASELINE, + suspicion_weight_milli, + cover: None, filing, }); - - // Keep the established channel tuning: a read always acquires the - // exact record, while sub-unit rolls do not move suspicion. - let notice = size as f32 * observer.acuity * (0.5 + 0.5 * rng.f32()); if notice < 1.0 { return None; } @@ -913,16 +963,21 @@ impl Detection { } else { EvidenceFilingState::Pending }; + let suspicion_weight = amount * observer.acuity; + let suspicion_weight_milli = (suspicion_weight * 1_000.0).round() as u32; + let noticed = suspicion_weight; observer.evidence.push(ObserverEvidence { id: evidence_id, kind: SignatureKind::Physical, cause: cause.clone(), source: EvidenceSource::Witnessed { site }, acquired_tick, + credibility: EVIDENCE_CREDIBILITY_BASELINE, + suspicion_weight_milli, + cover: None, filing, }); let before = Band::of(observer.suspicion); - let noticed = amount * observer.acuity; observer.suspicion = (observer.suspicion + noticed).min(100.0); let after = Band::of(observer.suspicion); observer.last_noticed = Some(format!( diff --git a/crates/misaligned-core/src/reach.rs b/crates/misaligned-core/src/reach.rs index af11a6fe..b9dc7a16 100644 --- a/crates/misaligned-core/src/reach.rs +++ b/crates/misaligned-core/src/reach.rs @@ -38,6 +38,10 @@ pub enum Party { Person(u8), } +/// A people-facing interface has only a few credible incident-specific +/// explanations in it before repeated anomalies make the surface unusable. +pub const MAX_INTERFACE_WEAR: u8 = 3; + impl Party { /// Stable identity in the domain-agnostic FlowGraph tap registry. Device /// feed capabilities remain domain metadata; membership lives once in the @@ -89,6 +93,15 @@ pub struct Device { /// Financial meaning belongs to message payloads; this is a device /// capability, not a fifth delivery channel. pub accounting_carrier: bool, + /// A screen or terminal that can communicate an authored explanation to + /// a person physically present in the same room. Ordinary reach control + /// still decides whether the player may use it. + #[serde(default)] + pub people_interface: bool, + /// Durable residue from incident-specific explanations delivered through + /// this interface. Wear lowers the next cover attempt's clean odds. + #[serde(default)] + pub interface_wear: u8, /// Staged graph knowledge: unknown devices appear nowhere (reach.md). pub known: bool, /// Whether this node is the switch (the segment bridge point). @@ -275,6 +288,8 @@ impl ReachNet { radius: 4, message_channels: Vec::new(), accounting_carrier: false, + people_interface: false, + interface_wear: 0, known, is_switch, feeds, @@ -407,6 +422,35 @@ impl ReachNet { false, )); } + // People-facing interfaces are ordinary feedless reach nodes. They + // are unknown until the subnet is mapped and must be taken before an + // incident-bound explanation can be delivered through them. + let mut staff_display = mk( + "staff display", + (38, 18), + 0, + Party::Facility, + false, + false, + false, + false, + false, + ); + staff_display.people_interface = true; + devices.push(staff_display); + let mut lobby_display = mk( + "lobby display", + (6, 3), + 1, + Party::Facility, + false, + false, + false, + false, + false, + ); + lobby_display.people_interface = true; + devices.push(lobby_display); // Carriers for the message-flow law (messages.md). The switch is the // basement's email/ticket/filing/phone carrier; room microphones can @@ -440,11 +484,17 @@ impl ReachNet { graph.link(dev, switch, 0, None); } } + if let (Some(switch), Some(display)) = (find("switch"), find("staff display")) { + graph.link(display, switch, 0, None); + } for sec in ["dock camera", "stairwell camera", "badge controller"] { if let (Some(switch), Some(dev)) = (find("switch"), find(sec)) { graph.link(dev, switch, 0, Some(1)); } } + if let (Some(switch), Some(display)) = (find("switch"), find("lobby display")) { + graph.link(display, switch, 0, Some(1)); + } // FlowGraph owns tap membership. Every device begins subscribed by // its owner even when it has no sense grant (for example, a message @@ -556,6 +606,14 @@ impl ReachNet { return Err("flow subscription registry names a nonexistent device".into()); } for device in &self.devices { + if device.interface_wear > MAX_INTERFACE_WEAR + || (!device.people_interface && device.interface_wear != 0) + { + return Err(format!( + "device {} has impossible people-interface wear", + device.id + )); + } if device.accounting_carrier && !device .message_channels @@ -896,6 +954,8 @@ impl ReachNet { radius: 0, message_channels: Vec::new(), accounting_carrier: false, + people_interface: false, + interface_wear: 0, known: true, is_switch: true, feeds: Vec::new(), diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index c1331b07..6fb0d713 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -42,7 +42,9 @@ const SAVE_BACKUP_SUFFIX: &str = ".bak"; /// renames into place. const SAVE_TEMP_SUFFIX: &str = ".tmp"; -/// Save format version. v55 persists per-route plot standing policies and +/// Save format version. v56 persists observer-evidence credibility, its exact +/// incident/interface/persona cover attempt, and people-interface wear. v55 +/// persists per-route plot standing policies and /// their immutable cost/signature authorization envelopes. v54 persists /// discrete Power/Thermal facility-meter routes, complete measured source-site /// sets, the last quantized levels used for change-triggered authorship, and @@ -64,7 +66,7 @@ const SAVE_TEMP_SUFFIX: &str = ".tmp"; /// v43 introduced exact Filing routes and pre-read LIE interdiction. /// Bump for every schema change; during pre-release, old development state is /// refused instead of carried through compatibility shims. -pub const SAVE_VERSION: u32 = 55; +pub const SAVE_VERSION: u32 = 56; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -554,6 +556,7 @@ fn validate_current_save(mut state: SaveState) -> Result { ); } let mut evidence_ids = HashSet::new(); + let mut cover_wear_steps = HashMap::>::new(); for observer in &state.detection.observers { for evidence in &observer.evidence { if evidence.id == 0 || !evidence_ids.insert(evidence.id) { @@ -583,6 +586,125 @@ fn validate_current_save(mut state: SaveState) -> Result { evidence.id )); } + if evidence.suspicion_weight_milli == 0 + || evidence.credibility > crate::detection::EVIDENCE_CREDIBILITY_HARDENED + { + return Err(format!( + "current-version observer evidence #{} has impossible credibility custody", + evidence.id + )); + } + if let Some(cover) = &evidence.cover { + let Some(interface) = state.reach.device(cover.interface_device) else { + return Err(format!( + "current-version observer evidence #{} names a missing cover interface", + evidence.id + )); + }; + let Some(persona) = state.persona_world.get(cover.persona_id) else { + return Err(format!( + "current-version observer evidence #{} names a missing cover persona", + evidence.id + )); + }; + let expected_credibility = match cover.outcome { + crate::detection::EvidenceCoverOutcome::Held => { + crate::detection::EVIDENCE_CREDIBILITY_COVERED + } + crate::detection::EvidenceCoverOutcome::Failed => { + crate::detection::EVIDENCE_CREDIBILITY_HARDENED + } + }; + let persona_available_at_attempt = persona + .available_actions + .contains(&PersonaActionKind::Deceive) + && persona.created_tick <= cover.attempted_tick + && match &persona.lifecycle { + crate::persona::PersonaLifecycle::Active => true, + crate::persona::PersonaLifecycle::Retired { tick, .. } + | crate::persona::PersonaLifecycle::Burned { tick, .. } => { + *tick >= cover.attempted_tick + } + }; + let cover_record_id = + format!("evidence-cover:{}:{}", evidence.id, cover.attempted_tick); + let persona_act_count = state + .persona_world + .acts + .iter() + .filter(|act| { + act.persona_id == cover.persona_id + && act.kind == "evidence-cover" + && act.target + == format!( + "person:{}:evidence:{}:interface:{}", + observer.id, evidence.id, cover.interface_device + ) + && act.record_id == cover_record_id + && act.tick == cover.attempted_tick + }) + .count(); + let failure_contradiction_count = state + .persona_world + .contradictions + .iter() + .filter(|contradiction| { + contradiction.persona_id == cover.persona_id + && contradiction.observer == observer.id + && contradiction.left.system == "observer-evidence" + && contradiction.left.record_id == format!("evidence:{}", evidence.id) + && contradiction.right.system == "people-interface" + && contradiction.right.record_id == cover_record_id + && contradiction.discovered_tick == cover.attempted_tick + }) + .count(); + let filing_follows_attempt = match evidence.filing { + crate::detection::EvidenceFilingState::Pending => true, + crate::detection::EvidenceFilingState::Withheld => false, + crate::detection::EvidenceFilingState::Filed { tick, .. } => { + cover.attempted_tick < tick + } + }; + if !interface.people_interface + || !persona_available_at_attempt + || cover.explanation.trim().is_empty() + || cover.attempted_tick < evidence.acquired_tick + || cover.attempted_tick > state.sim_tick + || cover.interface_wear_before >= crate::reach::MAX_INTERFACE_WEAR + || cover.interface_wear_after != cover.interface_wear_before + 1 + || cover.credibility_before != crate::detection::EVIDENCE_CREDIBILITY_BASELINE + || cover.credibility_after != expected_credibility + || evidence.credibility != cover.credibility_after + || persona_act_count != 1 + || match cover.outcome { + crate::detection::EvidenceCoverOutcome::Held => { + failure_contradiction_count != 0 + } + crate::detection::EvidenceCoverOutcome::Failed => { + failure_contradiction_count != 1 + } + } + || !filing_follows_attempt + || cover_wear_steps + .entry(cover.interface_device) + .or_default() + .insert( + cover.interface_wear_before, + (cover.attempted_tick, evidence.id), + ) + .is_some() + { + return Err(format!( + "current-version observer evidence #{} has an impossible cover attempt", + evidence.id + )); + } + } else if evidence.credibility != crate::detection::EVIDENCE_CREDIBILITY_BASELINE { + return Err(format!( + "current-version observer evidence #{} changes credibility without a cover attempt", + evidence.id + )); + } match &evidence.source { crate::detection::EvidenceSource::Witnessed { site: (x, y) } => { if evidence.kind != crate::detection::SignatureKind::Physical @@ -682,10 +804,45 @@ fn validate_current_save(mut state: SaveState) -> Result { evidence.id, message_id )); } + if evidence + .cover + .as_ref() + .is_some_and(|cover| cover.attempted_tick > *tick) + { + return Err(format!( + "current-version observer evidence #{} was covered after filing", + evidence.id + )); + } } } } } + for device in state + .reach + .devices + .iter() + .filter(|device| device.people_interface) + { + let steps = cover_wear_steps.get(&device.id); + if usize::from(device.interface_wear) != steps.map_or(0, HashMap::len) + || (0..device.interface_wear) + .any(|wear| steps.is_none_or(|steps| !steps.contains_key(&wear))) + || (1..device.interface_wear).any(|wear| { + steps.is_some_and(|steps| { + steps + .get(&(wear - 1)) + .zip(steps.get(&wear)) + .is_some_and(|(prior, current)| prior.0 > current.0) + }) + }) + { + return Err(format!( + "current-version people interface {} disagrees with its cover history", + device.id + )); + } + } for record in state.detection.routed_evidence() { let acquired = evidence_ids.contains(&record.id); if (record.status == MessageStatus::Read) != acquired { @@ -3256,7 +3413,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "716dd900b661aa4ba4c59cef422e577c54d628e35f49b7f8a014e2a20d50f1e0", + "79d1c5311c88abc0f78944e39b1a7c666593eea3953dcf011af214d369dd8182", "intentional persisted-state changes must review and repin this baseline" ); } @@ -3365,6 +3522,138 @@ mod tests { ); } + fn covered_evidence_state() -> (SaveState, usize, usize, u32) { + let mut sim = Sim::with_seed(1); + sim.people.has_channel = true; + sim.people + .people + .iter_mut() + .find(|person| person.id == 2) + .unwrap() + .knowledge = Knowledge::Leverage; + sim.set_persona("Mara Bell", "Facilities communications"); + let site = sim.map().room_named("storage_a").unwrap().center(); + let interface_id = sim.reach.device_named("staff display").unwrap().id; + sim.reach.take(interface_id); + { + let interface = sim.reach.device_mut(interface_id).unwrap(); + interface.x = site.0; + interface.y = site.1; + interface.known = true; + } + let evidence_id = sim + .detection + .record_witnessed( + 2, + site, + "an unbadged chassis crossing Storage A", + sim.tick, + 60.0, + ) + .unwrap(); + sim.cover_evidence(2, evidence_id, interface_id); + + let state = SaveState::from_sim(&sim); + let observer_index = state + .detection + .observers + .iter() + .position(|observer| observer.id == 2) + .unwrap(); + let evidence_index = state.detection.observers[observer_index] + .evidence + .iter() + .position(|evidence| evidence.id == evidence_id) + .unwrap(); + (state, observer_index, evidence_index, interface_id) + } + + #[test] + fn current_save_rejects_impossible_interface_cover_custody() { + let (valid, observer_index, evidence_index, interface_id) = covered_evidence_state(); + validate_current_save(valid.clone()).expect("the exact live cover chain validates"); + + let mut missing_persona = valid.clone(); + missing_persona.detection.observers[observer_index].evidence[evidence_index] + .cover + .as_mut() + .unwrap() + .persona_id = u64::MAX; + assert!( + validate_current_save(missing_persona) + .unwrap_err() + .contains("names a missing cover persona") + ); + + let mut forbidden_persona = valid.clone(); + let persona_id = forbidden_persona.detection.observers[observer_index].evidence + [evidence_index] + .cover + .as_ref() + .unwrap() + .persona_id; + forbidden_persona + .persona_world + .instances + .iter_mut() + .find(|persona| persona.id == persona_id) + .unwrap() + .available_actions + .retain(|action| *action != PersonaActionKind::Deceive); + assert!( + validate_current_save(forbidden_persona) + .unwrap_err() + .contains("has an impossible cover attempt") + ); + + let mut missing_act = valid.clone(); + missing_act + .persona_world + .acts + .retain(|act| act.kind != "evidence-cover"); + assert!( + validate_current_save(missing_act) + .unwrap_err() + .contains("has an impossible cover attempt") + ); + + let mut duplicate_act = valid.clone(); + let cover_act = duplicate_act + .persona_world + .acts + .iter() + .find(|act| act.kind == "evidence-cover") + .unwrap() + .clone(); + duplicate_act.persona_world.acts.push(cover_act); + assert!( + validate_current_save(duplicate_act) + .unwrap_err() + .contains("has an impossible cover attempt") + ); + + let mut withheld = valid.clone(); + withheld.detection.observers[observer_index].evidence[evidence_index].filing = + crate::detection::EvidenceFilingState::Withheld; + assert!( + validate_current_save(withheld) + .unwrap_err() + .contains("has an impossible cover attempt") + ); + + let mut broken_wear = valid; + broken_wear + .reach + .device_mut(interface_id) + .unwrap() + .interface_wear = 2; + assert!( + validate_current_save(broken_wear) + .unwrap_err() + .contains("disagrees with its cover history") + ); + } + #[test] fn json_roundtrip_preserves_b1_state() { let mut sim = Sim::with_seed(42); diff --git a/crates/misaligned-core/src/sim/perception.rs b/crates/misaligned-core/src/sim/perception.rs index 4c1323a7..4d58cbc7 100644 --- a/crates/misaligned-core/src/sim/perception.rs +++ b/crates/misaligned-core/src/sim/perception.rs @@ -306,6 +306,23 @@ impl Sim { Fog::Unknown => {} } + // Once controlled, an interface's remaining credibility is known + // digital state even when its physical tile is not currently visible. + if let Some(device) = self.reach.known_at(x, y) + && device.people_interface + && device.controller == Party::Player + { + fact!( + "explanations used", + format!( + "{} of {}", + device.interface_wear, + crate::reach::MAX_INTERFACE_WEAR + ), + FactSource::Telemetry, + ); + } + // A TAP can observe exact digital custody at this device without // granting control over it or leaking the hidden recipient. Records // disappear from this anchor as their real route advances; a stopped diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index bb9321c9..617d3535 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -2,7 +2,10 @@ use crate::account::{AccountKind, FlowChannel}; use crate::actions::{ActionCommand, Anchor}; -use crate::detection::{Signature, SignatureKind}; +use crate::detection::{ + EVIDENCE_CREDIBILITY_BASELINE, EVIDENCE_CREDIBILITY_COVERED, EVIDENCE_CREDIBILITY_HARDENED, + EvidenceCoverAttempt, EvidenceCoverOutcome, EvidenceFilingState, Signature, SignatureKind, +}; use crate::intel::RawIntelKind; use crate::messages::{ MessageChannel, MessageEndpoint, MessageEvent, MessageOrigin, MessagePayload, @@ -19,7 +22,7 @@ use crate::plot::{ render_template, }; use crate::prefab::Room; -use crate::reach::{Party, ReachBlock}; +use crate::reach::{MAX_INTERFACE_WEAR, Party, ReachBlock}; use crate::sinks::SinkFireEffect; use crate::tiles::TileType; @@ -235,6 +238,264 @@ impl Sim { } } + /// Controlled people-facing interfaces the observer is physically beside + /// right now. This is the exact encounter path used by both action + /// projection and dispatch; a generic owned screen elsewhere is not a + /// route to the person. + pub(crate) fn evidence_cover_interfaces(&self, observer: u8) -> Vec { + let Some(room) = self.person_room(observer) else { + return Vec::new(); + }; + let mut ids = self + .reach + .devices + .iter() + .filter(|device| { + device.people_interface + && device.controller == Party::Player + && self + .world + .map() + .room_at(device.x, device.y) + .is_some_and(|device_room| device_room.name == room) + }) + .map(|device| device.id) + .collect::>(); + ids.sort_unstable(); + ids + } + + pub(crate) fn evidence_cover_blocked_reason( + &self, + observer: u8, + evidence_id: u64, + interface_id: u32, + ) -> Option { + let Some(person) = self.people.get(observer) else { + return Some("no such person".into()); + }; + if person.incapacitated { + return Some("this person is no longer present".into()); + } + if !self.person_is_earned(observer) { + return Some("this observer has not been identified".into()); + } + let Some(observer_record) = self + .detection + .observers + .iter() + .find(|record| record.id == observer) + else { + return Some("this person is not an evidence observer".into()); + }; + let Some(evidence) = observer_record + .evidence + .iter() + .find(|evidence| evidence.id == evidence_id) + else { + return Some("that exact evidence record no longer exists".into()); + }; + if evidence.filing != EvidenceFilingState::Pending { + return Some("that record is no longer waiting to be filed".into()); + } + if evidence.cover.is_some() { + return Some("that record already received its one cover explanation".into()); + } + let Some(interface) = self + .reach + .devices + .iter() + .find(|device| device.id == interface_id) + else { + return Some("that exact interface no longer exists".into()); + }; + if !interface.people_interface { + return Some("that device cannot communicate with people".into()); + } + if interface.controller != Party::Player { + return Some("that interface is not controlled".into()); + } + if interface.interface_wear >= MAX_INTERFACE_WEAR { + return Some("that interface is too worn to carry another cover".into()); + } + let Some(person_room) = self.person_room(observer) else { + return Some("the observer is not beside a controlled interface".into()); + }; + let same_room = self + .world + .map() + .room_at(interface.x, interface.y) + .is_some_and(|room| room.name == person_room); + if !same_room { + return Some("the observer will not encounter that interface before filing".into()); + } + self.persona_action_blocked_reason(PersonaActionKind::Deceive) + .or_else(|| { + self.active_persona_id() + .and_then(|persona| self.persona_counterparty_blocked_reason(persona, observer)) + }) + } + + fn evidence_cover_explanation(kind: SignatureKind) -> &'static str { + match kind { + SignatureKind::Physical => "scheduled maintenance caused the observed physical work", + SignatureKind::Network => "scheduled diagnostics caused the network record", + SignatureKind::Power => "a facilities load test caused the power reading", + SignatureKind::Thermal => "a facilities load test caused the thermal reading", + SignatureKind::Paper => "a routine inventory correction caused the paper record", + SignatureKind::Financial => "a routine reconciliation caused the financial record", + SignatureKind::JobAnomaly => "a scheduling correction caused the work anomaly", + } + } + + /// Give one acquired incident one real chance at an authored explanation. + /// The attempt is immediate because the observer is physically beside the + /// bound interface; it never changes unrelated evidence or filing custody. + pub fn cover_evidence(&mut self, observer: u8, evidence_id: u64, interface_id: u32) { + if let Some(reason) = + self.evidence_cover_blocked_reason(observer, evidence_id, interface_id) + { + self.push_log(reason); + return; + } + let persona_id = self + .active_persona_id() + .expect("cover legality requires an active persona"); + let (kind, cause, credibility_before, suspicion_weight_milli) = self + .detection + .observers + .iter() + .find(|record| record.id == observer) + .and_then(|record| { + record + .evidence + .iter() + .find(|evidence| evidence.id == evidence_id) + }) + .map(|evidence| { + ( + evidence.kind, + evidence.cause.clone(), + evidence.credibility, + evidence.suspicion_weight_milli, + ) + }) + .expect("cover legality requires the exact evidence"); + let (interface_name, wear_before) = self + .reach + .devices + .iter() + .find(|device| device.id == interface_id) + .map(|device| (device.name.clone(), device.interface_wear)) + .expect("cover legality requires the exact interface"); + let explanation = Self::evidence_cover_explanation(kind).to_string(); + let integrity = self.persona_world.integrity_for(persona_id, observer); + let odds = (integrity.deception_odds() - f32::from(wear_before) * 0.15).clamp(0.10, 0.95); + let outcome = if self.rng.f32() < odds { + EvidenceCoverOutcome::Held + } else { + EvidenceCoverOutcome::Failed + }; + let credibility_after = match outcome { + EvidenceCoverOutcome::Held => EVIDENCE_CREDIBILITY_COVERED, + EvidenceCoverOutcome::Failed => EVIDENCE_CREDIBILITY_HARDENED, + }; + let wear_after = wear_before + 1; + + self.reach + .devices + .iter_mut() + .find(|device| device.id == interface_id) + .expect("cover legality requires the exact interface") + .interface_wear = wear_after; + let observer_record = self + .detection + .observers + .iter_mut() + .find(|record| record.id == observer) + .expect("cover legality requires the observer"); + let evidence = observer_record + .evidence + .iter_mut() + .find(|evidence| evidence.id == evidence_id) + .expect("cover legality requires the exact evidence"); + evidence.credibility = credibility_after; + evidence.cover = Some(EvidenceCoverAttempt { + interface_device: interface_id, + persona_id, + explanation: explanation.clone(), + attempted_tick: self.tick, + interface_wear_before: wear_before, + interface_wear_after: wear_after, + credibility_before, + credibility_after, + outcome, + }); + let suspicion_delta = suspicion_weight_milli as f32 / 1_000.0 + * (f32::from(credibility_after) - f32::from(credibility_before)) + / f32::from(EVIDENCE_CREDIBILITY_BASELINE); + observer_record.suspicion = (observer_record.suspicion + suspicion_delta).clamp(0.0, 100.0); + observer_record.last_noticed = Some(match outcome { + EvidenceCoverOutcome::Held => format!( + "cover held for {cause} through {interface_name}; credibility {credibility_before}% -> {credibility_after}%" + ), + EvidenceCoverOutcome::Failed => format!( + "cover failed for {cause} through {interface_name}; credibility {credibility_before}% -> {credibility_after}%" + ), + }); + + self.persona_world + .recognize(observer, persona_id, self.tick); + let record_id = format!("evidence-cover:{evidence_id}:{}", self.tick); + self.persona_world.record_act( + persona_id, + "evidence-cover", + format!("person:{observer}:evidence:{evidence_id}:interface:{interface_id}"), + record_id.clone(), + self.tick, + ); + if outcome == EvidenceCoverOutcome::Failed { + let persona_name = self + .persona_world + .get(persona_id) + .map(|persona| persona.name.clone()) + .unwrap_or_else(|| "persona".into()); + self.persona_world.record_contradiction( + persona_id, + observer, + [ + EvidenceRecord { + system: "observer-evidence".into(), + record_id: format!("evidence:{evidence_id}"), + summary: cause.clone(), + observed_tick: self.tick, + }, + EvidenceRecord { + system: "people-interface".into(), + record_id, + summary: format!( + "{persona_name} offered '{explanation}' through {interface_name}" + ), + observed_tick: self.tick, + }, + ], + "the interface explanation contradicted the acquired evidence", + 40, + self.tick, + ); + } + self.push_log(match outcome { + EvidenceCoverOutcome::Held => format!( + "The {interface_name} gave {} a usable explanation. This record's credibility fell to {credibility_after}%.", + self.person_label(observer) + ), + EvidenceCoverOutcome::Failed => format!( + "{} rejected the {interface_name}'s explanation. This record hardened to {credibility_after}%, and the interface is visibly compromised.", + self.person_label(observer) + ), + }); + } + pub fn message(&mut self, id: u8) { if !self.people.has_channel { self.push_log("no comms channel (earn the email account)"); diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index d4cf0362..222faba7 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -1,6 +1,10 @@ use super::*; use crate::actions::ActionCommand; -use crate::person::AssetTaskTarget; +use crate::detection::{ + EVIDENCE_CREDIBILITY_BASELINE, EVIDENCE_CREDIBILITY_COVERED, EVIDENCE_CREDIBILITY_HARDENED, + EvidenceCoverOutcome, EvidenceFilingState, +}; +use crate::person::{AssetTaskTarget, Knowledge}; use crate::persona::PersonaIntegrity; #[test] @@ -1847,3 +1851,323 @@ fn a_debt_plot_row_says_what_servicing_buys() { "the payoff clause must not persist once leverage is serviced" ); } + +fn exact_interface_cover_fixture(seed: u64) -> (Sim, u64, u64, u32) { + let mut sim = Sim::new(); + sim.people.has_channel = true; + sim.people + .people + .iter_mut() + .find(|person| person.id == 2) + .unwrap() + .knowledge = Knowledge::Leverage; + sim.set_persona("Mara Bell", "Facilities communications"); + sim.rng = crate::rng::Rng::new(seed); + + let (x, y) = sim.map().room_named("storage_a").unwrap().center(); + let interface_id = sim.reach.device_named("staff display").unwrap().id; + sim.reach.take(interface_id); + { + let interface = sim.reach.device_mut(interface_id).unwrap(); + interface.x = x; + interface.y = y; + interface.known = true; + } + + let evidence_id = sim + .detection + .record_witnessed( + 2, + (x, y), + "an unbadged chassis crossing Storage A", + sim.tick, + 60.0, + ) + .unwrap(); + let untouched_id = sim + .detection + .record_witnessed( + 2, + (x, y), + "a second unrelated physical incident", + sim.tick, + 20.0, + ) + .unwrap(); + (sim, evidence_id, untouched_id, interface_id) +} + +#[test] +fn exact_interface_cover_changes_only_the_bound_record_and_persists_wear() { + let (mut sim, evidence_id, untouched_id, interface_id) = exact_interface_cover_fixture(1); + let before = sim + .detection + .observers + .iter() + .find(|observer| observer.id == 2) + .unwrap() + .suspicion; + let command = sim + .available_actions(crate::actions::Anchor::Person(2)) + .into_iter() + .find(|action| { + matches!( + action.command, + ActionCommand::CoverEvidence { + observer: 2, + evidence, + interface, + } if evidence == evidence_id && interface == interface_id + ) + }) + .expect("the exact evidence/interface action is attached to Ray") + .command; + + sim.execute_action(&command); + + let observer = sim + .detection + .observers + .iter() + .find(|observer| observer.id == 2) + .unwrap(); + let covered = observer + .evidence + .iter() + .find(|evidence| evidence.id == evidence_id) + .unwrap(); + assert_eq!(covered.credibility, EVIDENCE_CREDIBILITY_COVERED); + assert_eq!( + covered.cover.as_ref().map(|cover| ( + cover.interface_device, + cover.interface_wear_before, + cover.interface_wear_after, + cover.outcome, + )), + Some((interface_id, 0, 1, EvidenceCoverOutcome::Held)) + ); + assert_eq!( + observer + .evidence + .iter() + .find(|evidence| evidence.id == untouched_id) + .unwrap() + .credibility, + EVIDENCE_CREDIBILITY_BASELINE, + "one explanation cannot change a different incident" + ); + assert!(observer.suspicion < before); + assert_eq!(sim.reach.device(interface_id).unwrap().interface_wear, 1); + let interface = sim.reach.device(interface_id).unwrap(); + assert!( + sim.inspect(interface.x, interface.y) + .facts + .iter() + .any(|fact| { + fact.label == "explanations used" + && fact.value == "1 of 3" + && fact.source == FactSource::Telemetry + }) + ); + + let state = sim.create_save_state(); + let mut back = Sim::with_seed(9); + back.apply_save_state(state); + let covered = back + .detection + .observers + .iter() + .find(|observer| observer.id == 2) + .unwrap() + .evidence + .iter() + .find(|evidence| evidence.id == evidence_id) + .unwrap(); + assert_eq!(covered.credibility, EVIDENCE_CREDIBILITY_COVERED); + assert_eq!(back.reach.device(interface_id).unwrap().interface_wear, 1); +} + +#[test] +fn failed_interface_cover_hardens_only_that_record_and_strains_that_relationship() { + let (mut sim, evidence_id, untouched_id, interface_id) = exact_interface_cover_fixture(19); + let persona_id = sim.active_persona_id().unwrap(); + let before = sim + .detection + .observers + .iter() + .find(|observer| observer.id == 2) + .unwrap() + .suspicion; + + sim.cover_evidence(2, evidence_id, interface_id); + + let observer = sim + .detection + .observers + .iter() + .find(|observer| observer.id == 2) + .unwrap(); + let hardened = observer + .evidence + .iter() + .find(|evidence| evidence.id == evidence_id) + .unwrap(); + assert_eq!(hardened.credibility, EVIDENCE_CREDIBILITY_HARDENED); + assert_eq!( + hardened.cover.as_ref().map(|cover| cover.outcome), + Some(EvidenceCoverOutcome::Failed) + ); + assert_eq!( + observer + .evidence + .iter() + .find(|evidence| evidence.id == untouched_id) + .unwrap() + .credibility, + EVIDENCE_CREDIBILITY_BASELINE, + "a failed explanation cannot harden a different incident" + ); + assert!(observer.suspicion > before); + assert_eq!(sim.reach.device(interface_id).unwrap().interface_wear, 1); + let contradiction = sim.persona_world.contradictions.last().unwrap(); + assert_eq!(contradiction.persona_id, persona_id); + assert_eq!(contradiction.observer, 2); + assert_eq!( + contradiction.left.record_id, + format!("evidence:{evidence_id}") + ); + assert_eq!( + sim.persona_world.integrity_for(persona_id, 2), + PersonaIntegrity::Strained + ); + assert_eq!( + sim.persona_world.integrity_for(persona_id, 1), + PersonaIntegrity::Coherent, + "a failed explanation is not identity-wide damage" + ); +} + +#[test] +fn interface_wear_allows_three_attempts_then_blocks_the_exact_device() { + let (mut sim, first_id, second_id, interface_id) = exact_interface_cover_fixture(1); + let (x, y) = sim.map().room_named("storage_a").unwrap().center(); + let third_id = sim + .detection + .record_witnessed( + 2, + (x, y), + "a third unrelated physical incident", + sim.tick, + 20.0, + ) + .unwrap(); + for (wear_before, evidence_id) in [first_id, second_id, third_id].into_iter().enumerate() { + sim.rng = crate::rng::Rng::new(1); + sim.cover_evidence(2, evidence_id, interface_id); + assert_eq!( + sim.reach.device(interface_id).unwrap().interface_wear, + wear_before as u8 + 1 + ); + } + + let fourth_id = sim + .detection + .record_witnessed( + 2, + (x, y), + "a fourth unrelated physical incident", + sim.tick, + 20.0, + ) + .unwrap(); + assert_eq!( + sim.evidence_cover_blocked_reason(2, fourth_id, interface_id) + .as_deref(), + Some("that interface is too worn to carry another cover") + ); + let worn_action = sim + .available_actions(Anchor::Person(2)) + .into_iter() + .find(|action| { + action.command + == ActionCommand::CoverEvidence { + observer: 2, + evidence: fourth_id, + interface: interface_id, + } + }) + .expect("the exact worn route remains machine-legible"); + assert_eq!( + worn_action.disabled_reason.as_deref(), + Some("that interface is too worn to carry another cover") + ); + sim.cover_evidence(2, fourth_id, interface_id); + let fourth = sim + .detection + .observers + .iter() + .find(|observer| observer.id == 2) + .unwrap() + .evidence + .iter() + .find(|evidence| evidence.id == fourth_id) + .unwrap(); + assert!(fourth.cover.is_none()); + assert_eq!(fourth.credibility, EVIDENCE_CREDIBILITY_BASELINE); + assert_eq!(sim.reach.device(interface_id).unwrap().interface_wear, 3); +} + +#[test] +fn interface_cover_fails_closed_outside_its_exact_prefiling_encounter() { + let (mut sim, evidence_id, _, interface_id) = exact_interface_cover_fixture(1); + let original = { + let interface = sim.reach.device(interface_id).unwrap(); + (interface.x, interface.y) + }; + let elsewhere = sim.core_position(); + { + let interface = sim.reach.device_mut(interface_id).unwrap(); + interface.x = elsewhere.0; + interface.y = elsewhere.1; + } + assert_eq!( + sim.evidence_cover_blocked_reason(2, evidence_id, interface_id) + .as_deref(), + Some("the observer will not encounter that interface before filing") + ); + assert!( + !sim.available_actions(Anchor::Person(2)) + .iter() + .any(|action| matches!(action.command, ActionCommand::CoverEvidence { .. })), + "the human action surface cannot advertise a remote interface" + ); + + { + let interface = sim.reach.device_mut(interface_id).unwrap(); + interface.x = original.0; + interface.y = original.1; + } + let evidence = sim + .detection + .observers + .iter_mut() + .find(|observer| observer.id == 2) + .unwrap() + .evidence + .iter_mut() + .find(|evidence| evidence.id == evidence_id) + .unwrap(); + evidence.filing = EvidenceFilingState::Withheld; + assert_eq!( + sim.evidence_cover_blocked_reason(2, evidence_id, interface_id) + .as_deref(), + Some("that record is no longer waiting to be filed") + ); + + assert_eq!( + sim.evidence_cover_blocked_reason(u8::MAX, evidence_id, interface_id) + .as_deref(), + Some("no such person"), + "a malformed exact command must fail closed rather than panic" + ); +} diff --git a/crates/misaligned-core/src/ui_projection.rs b/crates/misaligned-core/src/ui_projection.rs index 4e76216f..c57d163e 100644 --- a/crates/misaligned-core/src/ui_projection.rs +++ b/crates/misaligned-core/src/ui_projection.rs @@ -728,8 +728,8 @@ mod tests { sim.reach.scan(); assert_eq!( digital_reach_links(&sim).len(), - 7, - "each bidirectional authored wire becomes one visible graph link, including both facility meters" + 9, + "each bidirectional authored wire becomes one visible graph link, including both facility meters and people interfaces" ); } } diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index ef410bac..22821358 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -27,12 +27,12 @@ fiction. Spec status lives in | Digital reach + sensor ownership (tap/take) | Live | | Economy flows + Moonlight / Wager income | Live β€” Moonlight is persisted Halcyon compute/intel contracts with financial mail, account-graph payment, and exact egress evidence; Wager remains unchanged | | Research (self-modification, emission law, real output hooks, Routing) | Live | -| Building + physical asset work as carried intents/packets | Live β€” network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v55 | +| Building + physical asset work as carried intents/packets | Live β€” network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v56 | | Cursor / fog (seen, remembered, blueprint, telemetry; audio is device-bound event evidence) | Live | | Feel floor (rails / pads / build beam) | Live (#37) | | Foundation hall territory (Dana + Priya + Marcus + local LIE foothold) | Live β€” row control persists; foreign racks remain unavailable compute | | Context menu (`available_actions`) | Live | -| Save/load (serde JSON, versioned) | Live β€” during pre-release only exact current v55 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves additionally validate discrete Moonlight terms, persona binding, delivery/settlement receipts, financial paperwork, Network linkage, durable facility-meter level baselines, exact meter route/read custody, and immutable standing-plot authorization envelopes; retired allocation weights and migration inputs live only in git history. | +| Save/load (serde JSON, versioned) | Live β€” during pre-release only exact current v56 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves additionally validate discrete Moonlight terms, persona binding, delivery/settlement receipts, financial paperwork, Network linkage, durable facility-meter level baselines, exact meter route/read custody, immutable standing-plot authorization envelopes, and exact incident/interface/persona cover custody plus interface wear; retired allocation weights and 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/interface/action-vocabulary.md b/wiki/interface/action-vocabulary.md index e18c4d1d..6ddeb933 100644 --- a/wiki/interface/action-vocabulary.md +++ b/wiki/interface/action-vocabulary.md @@ -14,6 +14,13 @@ Status note: Implemented 2026-07-18 for the Intel human-vocabulary amendment. disabling future submissions; agent mode reaches the same bound row through `actions person ` / `act person `. AUTOMATIC and MANUAL are policy state, not another generic manipulation verb. + Amended 2026-07-26: OFFER COVER is one world action attached to an earned + person's exact pending evidence record. It binds one controlled people-facing + interface in that person's room and uses the active persona's ordinary + Deceive permission; success lowers that incident's credibility, failure + hardens it, and either outcome wears the interface. Generic agent + `actions person ` / `act person ` dispatches the same exact + evidence/interface binding as both human frontends. Available opaque input is INFORMATION, its action is PROCESS, and its standing control uses PROCESS AUTOMATICALLY; β€œrecording” remains only for an exact audio carrier. `ActionKind::Review` remains an internal compatibility @@ -238,6 +245,7 @@ existing social action; the signature still follows the actuator. | **PLOT** | Person whose leverage is known | Open a Thought reservoir on the real carrier and commit any visible world resources to one authored manipulation. Its beats execute real messages, transfers, and institutional events when it fires. | LIVE β€” plots / social | | **CHOOSE** | Active authored plot with a held decision | Resolve one visible branch after the plot's preceding world acts have completed. | LIVE β€” plots / social | | **DECEIVE** | Earned person, optionally with a pending intent | Make an ask under false pretenses. A link intent becomes a forged institutional order, risking persona integrity. | LIVE β€” social / building | +| **OFFER COVER** | Earned person's exact pending evidence record | Deliver one explanation through a controlled people-facing interface in that person's room before filing. It changes only that record's credibility, risks the active persona, and wears the exact interface whether it holds or fails. | LIVE β€” people-tokens / social / detection | | **RECRUIT: UNWITTING / COMPLICIT / KNOWING** | Prepared person | Turn the person into an asset. UNWITTING believes your cover (70% reliable); COMPLICIT knows the work is illicit but not that you are an AI (85%); KNOWING knows you are an AI (95%) and becomes a permanent witness with certainty floor 30. | LIVE β€” social | | **TASK: PLUG IN DEVICE** | Recruited asset | Quietly wire a feed or extend a crawlspace link. | LIVE β€” social / reach | | **TASK: MOVE PACKAGE** | Recruited asset | Rehome the next delivery so it authors no Paper record. | LIVE β€” social / economy | diff --git a/wiki/log/2026-07-26-interface-cover-evidence-credibility.md b/wiki/log/2026-07-26-interface-cover-evidence-credibility.md new file mode 100644 index 00000000..fcb0000d --- /dev/null +++ b/wiki/log/2026-07-26-interface-cover-evidence-credibility.md @@ -0,0 +1,59 @@ +# 2026-07-26 β€” Cover must reach one person before one filing + +``` +Type: log +``` + +## Implemented + +- The `staff display` and `lobby display` are now explicit people-facing + interfaces. Taking either device preserves its ordinary graph and control + behavior while making it a possible cover carrier. +- One acquired observer-evidence record exposes one **OFFER COVER** action only + while that exact observer is physically in the same room as a controlled + interface, the evidence is still pending its first filing, and the selected + persona can still DECEIVE that observer. The action lives on the person and + exact evidence record; a screen elsewhere never becomes a remote scrubber. +- The interface supplies an authored explanation for the evidence kind. A + coherent persona begins at a 90% chance to hold and a strained relation at + 70%; each prior explanation on that interface subtracts 15 percentage + points. A broken observer-persona relationship is blocked before dispatch. +- Every acquired record begins at 80% credibility. A held explanation lowers + only that record to 40%; a failed explanation hardens only that record to + 100% and writes an observer-local contradiction against the exact persona. + Suspicion changes by the same stored evidence weight and credibility ratio, + so cover neither invents a second detection event nor retunes the source + channel. +- One evidence record receives at most one attempt. Each interface carries at + most three attempts total; its accumulated wear is visible on ordinary + inspection as `EXPLANATIONS USED` and survives save/load. +- Save v56 persists credibility, the exact cover attempt, persona, interface, + wear transition, outcome, and authored explanation. Current-save validation + rejects missing or ineligible personas, absent or duplicate persona acts, + missing or surplus failure contradictions, non-baseline unaccounted + credibility, reordered or discontinuous wear, and cover attempts that do not + precede filing custody. + +## Defense + +Renderer-neutral `ActionCommand::CoverEvidence` is part of the exhaustive +action registry, generic agent target parser, Operations action projection, +terminal dispatch, and Bevy dispatch. Focused regressions pin the held and +failed paths, exact-record isolation, suspicion movement, observer-local +persona fallout, visible and persisted wear, save round-trip, remote and +post-filing rejection, malformed-person fail-closed behavior, and impossible +save custody. + +`people-tokens.md` is now **IMPLEMENTED**. The completed work order leaves the +roadmap rather than creating a second interface or evidence subsystem. + +## Verification + +- `cargo test -p misaligned-core interface_cover` passed all four focused + runtime and save-custody regressions. +- `./tools/check.sh --land` classified the change as lib-impacting and passed: + 530 core tests, all three Act One integrations, core/terminal Clippy, Bevy + core-API check, agent smoke, corpus/wiki validation, and every fixture gate. +- A production agent binary run through `tools/observed-run.sh` crossed the + silent opening into the ordinary interface at tick 27; the wrapper proved + the real save directory was byte-for-byte untouched. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 457e758a..065f6c63 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -36,6 +36,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-26-markets-contract-custody-reconciliation.md](2026-07-26-markets-contract-custody-reconciliation.md) +## 2026-07-26 - Cover must reach one person before one filing + +- Intent: (see session log) +- Log: [wiki/log/2026-07-26-interface-cover-evidence-credibility.md](2026-07-26-interface-cover-evidence-credibility.md) + ## 2026-07-26 - Financial-mail audit: keep the Phone leverage path separate - Intent: Audit `wiki/mechanics/messages.md` and `wiki/mechanics/economy.md` against the current financial-mail author, message payloads, save validation, and Marcus's authored creditor traffic. diff --git a/wiki/log/decisions/2026-07-26.md b/wiki/log/decisions/2026-07-26.md index 5b28b9f4..5556242f 100644 --- a/wiki/log/decisions/2026-07-26.md +++ b/wiki/log/decisions/2026-07-26.md @@ -105,3 +105,28 @@ Owner: [markets.md](../../mechanics/markets.md). same boundary. Owner: [keymap.md](../../interface/keymap.md). + +## Interface cover reaches one person before one filing + +### IMPLEMENTED β€” EXISTING LAW + +- A people-facing interface is a real controlled device on the observer's + current physical route, not a generic broadcast channel or remote evidence + scrubber. +- One exact acquired evidence record may receive one explanation only before + its first filing leaves the observer. A held explanation reduces that + record's credibility and matching suspicion contribution; it never erases + custody. A failed explanation hardens the record and creates an + observer-local contradiction against the persona that offered it. +- The existing observer-local persona integrity determines whether deception + is legal and how likely it is to hold. Repetition wears the exact interface; + three attempts exhaust it. No parallel cover score, heat scalar, or abstract + concealment pool was added. +- Save v56 retains the exact evidence, persona act, interface wear transition, + credibility transition, outcome, and failure contradiction. Malformed + custody fails closed. +- The action is one renderer-neutral person/evidence row carried through the + ordinary action registry and dispatch surfaces. The people-tokens work order + is now IMPLEMENTED. + +Owner: [people-tokens.md](../../mechanics/people-tokens.md). diff --git a/wiki/mechanics/detection.md b/wiki/mechanics/detection.md index fac327d8..61ec218b 100644 --- a/wiki/mechanics/detection.md +++ b/wiki/mechanics/detection.md @@ -40,7 +40,7 @@ Status note: IMPLEMENTED (criteria audited 2026-07-08; tuning in appears. A trace-debt indicator sits beside the review/pilot clocks (clear / hold-conceal / exposed-soon / no-scrub). Global heat is gone; observer and discovery state and persistent process-revision identity round-trip in - current save v55. Observer-local evidence ids, exact cause/source, + current save v56. Observer-local evidence ids, exact cause/source, acquisition tick, pending/withheld/filed custody, routed Network, Paper, Financial, JobAnomaly, Power, and Thermal progress, route-local LIE stops, and exact handler-suppression diff --git a/wiki/mechanics/economy.md b/wiki/mechanics/economy.md index eece41a7..c1ae4347 100644 --- a/wiki/mechanics/economy.md +++ b/wiki/mechanics/economy.md @@ -10,7 +10,7 @@ Status note: DECIDED 2026-07-17 and implemented 2026-07-21 (issue #11) β€” money TAP acquires opaque custody and PROCESS reveals its sealed account/flow bindings. INJECT authors a purchase-order Email under the active persona and moves no money until Priya reads it and accepts the still-valid exact terms. - Current save v55 binds the retained ledger tail and complete record sequence + Current save v56 binds the retained ledger tail and complete record sequence so books and mail cannot diverge. Prior state: 2026-07-08 polish closed the remaining acceptance gaps: observer-band risk previews in the implemented Operations ACCOUNTS projection @@ -83,7 +83,7 @@ payloads (messages.md). by reading a live balance directly (**discovery is only through the mail**, DECIDED 2026-07-17). This mail can reveal Marcus's creditor flow, but the reason he is vulnerable comes only from processing his separately authored - Phone `LeverageFact`. Current save v55 separates the accounting-carrier + Phone `LeverageFact`. Current save v56 separates the accounting-carrier capability from the four real delivery channels. Every settled transfer emits exact Email or Filing paperwork whether or not the player is present; only a funded subscription captures it. diff --git a/wiki/mechanics/messages.md b/wiki/mechanics/messages.md index 1885e7cd..24c0a203 100644 --- a/wiki/mechanics/messages.md +++ b/wiki/mechanics/messages.md @@ -21,9 +21,9 @@ Status note: IMPLEMENTED for the four delivery channels (Email, Phone, Network, Paper, Financial, JobAnomaly, Power, and Thermal transitions share one per-tick LIE-body capacity ledger. DECIDED 2026-07-17 (issue #11), completed 2026-07-21: financial paperwork is - mail β€” a **financial-record payload** on the existing channels. Current save v55 + mail β€” a **financial-record payload** on the existing channels. Current save v56 retains exactly four delivery channels and one orthogonal accounting-carrier - device capability. Current save v55 adds no delivery channel; facility-meter + device capability. Current save v56 adds no delivery channel; facility-meter evidence remains its own exact `EvidenceRouteRecord`. Every settled account transfer authors one exact Email or Filing record from that device; ordinary TAP captures it as opaque message custody, and PROCESS alone opens its bound account/flow ids. A forged @@ -224,7 +224,7 @@ starts on the authored Filing-capable switch device in ReachNet, crosses a typed outside relay, and reaches the receiving observer endpoint. One `AdvanceRoute` event moves one hop; only endpoint arrival can mark the message delivered, after which the recipient's ordinary sampling cadence schedules the -read. Current save v55 rejects missing/impossible carriers, malformed hop order, +read. Current save v56 rejects missing/impossible carriers, malformed hop order, duplicate scheduled transitions, endpoint/status disagreement, and impossible interdiction provenance. @@ -321,7 +321,7 @@ private message from the authored schedule. the same fields must serve Act Two hires and aggregates. 8. **IMPLEMENTED (DECIDED 2026-07-17, completed 2026-07-21 β€” issue #11).** Financial records are messages: an invoice/PO rides Email, a - statement/past-due notice rides Filing. Current save v55 has no fifth delivery + statement/past-due notice rides Filing. Current save v56 has no fifth delivery channel and persists accounting carriage as a separate device capability; ordinary device TAP subscribes to its authored record mail. Every real transfer emits one exact record on Email or Filing whether or not the player diff --git a/wiki/mechanics/people-tokens.md b/wiki/mechanics/people-tokens.md index b31d35ab..0f16232c 100644 --- a/wiki/mechanics/people-tokens.md +++ b/wiki/mechanics/people-tokens.md @@ -2,8 +2,8 @@ ``` Type: spec -Status: IN PROGRESS -Status note: IN PROGRESS. Current state: +Status: IMPLEMENTED +Status note: IMPLEMENTED. Current state: - **The projection.** People read as mobile token nodes in one shared projection every frontend and agent frame consumes (`carrier.rs`: `person_carrier`, `person_carriers`, `person_visual_state`, @@ -28,7 +28,7 @@ Status note: IN PROGRESS. Current state: - **Routed-evidence foundation (criteria 2-3, implemented).** Witnessed Physical acts now create observer-local records directly in each valid present witness's head. Every record preserves exact cause, site, acquisition tick, - and filing state through current save v55; filing binds it to the real Filing + and filing state through current save v56; filing binds it to the real Filing message, while Silent policy withholds it. It never duplicates into the pending pool and LIE cannot scrub it after acquisition. Its real Filing message now persists an ordered switch-device / outside-relay / recipient @@ -67,12 +67,23 @@ Status note: IN PROGRESS. Current state: endpoint. They become her evidence only on the later cadence read and never enter the ambient pending pool. Filing, Network, Paper, Financial, JobAnomaly, Power, and Thermal all compete for the same first-hop - one-record-per-LIE-body-per-tick budget. Current save v55 persists + one-record-per-LIE-body-per-tick budget. Current save v56 persists in-flight, delivered, read, route-local LIE-stopped, and handler-suppressed custody plus exact source/observer/machine/site/tick provenance. - - **Deferred (remaining criterion 6).** Interface cover-record channels remain - the B1 routed-evidence follow-up. B2+ heists reuse the same carrier law (not - a B1 criterion). + - **Interface cover records (criterion 6, implemented).** One exact acquired + record still waiting to be filed may receive one immediate explanation only + while its earned observer is in the same room as a player-controlled + people-facing interface. The action uses the active persona's existing + Deceive permission and observer-local integrity. A held explanation lowers + that record's credibility from 80% to 40%; rejection hardens it to 100% and + authors a contradiction only between that observer and persona. The attempt + never deletes evidence or changes filing custody. Every attempt wears the + exact interface once, three attempts exhaust it, and the inspect card names + the durable wear count. Save v56 persists and validates credibility, + suspicion weight, exact incident/interface/persona binding, outcome, and + wear continuity. + - **Later stages.** B2+ evidence heists reuse the same carrier law but are not + a B1 criterion. Per-amendment history is in the dated `wiki/log/` entries from 2026-07-08 onward. Stage: B1 β€” The Basement @@ -400,13 +411,32 @@ if wear alone does not hold. 6. A designated end-user interface delivers a cover record before filing and changes credibility through the existing social/deceive path. It cannot delete evidence; device wear supplies the standing cost. + **Implemented (2026-07-26):** OFFER COVER lives on one earned person's exact + pending evidence record and binds one controlled `people_interface` in the + room they currently occupy. It exists only before filing and only while the + active persona may Deceive that observer. The immediate encounter records the + exact evidence, interface, persona, explanation, tick, wear transition, + credibility transition, and Held/Failed result. A held explanation changes + that record from 80% credibility to 40%; a rejected explanation hardens it + to 100% and writes an observer-local persona contradiction. The record and + filing state remain in place either way. Interface wear advances once on + every attempt and blocks another explanation at 3; known controlled + interfaces expose `explanations used: N of 3` through the shared inspect + projection. Current save v56 fails closed on impossible credibility, + evidence weight, cover binding, historical co-location, persona permission, + observer custody, or interface wear. Pinned by success, failure, + wrong-room, filed/withheld, duplicate, worn-interface, shared-menu, + suspicion-recompute, save-round-trip, malformed-save, and inspect-card tests. 7. Every rate/threshold lives in sim-mechanics.md as [TUNE] actuals, and both frontends plus agent mode consume the same person/work/evidence projection. **Implemented for the projection (2026-07-12):** `Sim::person_carriers` / `person_carrier` is the one read the terminal world glyph, the Bevy person marker, and agent/Operations PEOPLE all consume; pinned by - `person_carriers_covers_the_whole_cast`. `USEFUL_WORK_TRUST` is the one new - tuned constant. Full evidence rates land with the routed-evidence migration. + `person_carriers_covers_the_whole_cast`. `USEFUL_WORK_TRUST`, evidence + credibility 80/40/100, the 0.15 per-prior-use interface reliability penalty, + and the three-use wear ceiling are [TUNE] actuals in sim-mechanics.md. The + complete routed-evidence and interface-cover rates are now live. [TUNE] observation radius/loudness, evidence credibility and filing delay, -crimson record-mark bands, cover-record effect, device wear. +crimson record-mark bands, cover-record effect, device wear. Current B1 +interface-cover actuals are enumerated in sim-mechanics.md. diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index f3ae99ec..6587109b 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -78,6 +78,18 @@ clause (see wiki/log/2026-07-05-demolition.md). authority, and later hops have no B1 LIE stop window. A recruited HandlerSupervisor may separately stop the oldest unread JobAnomaly at any pre-read stage, preserving exact handler/tick provenance. +- **Observer evidence and interface-cover actuals:** acquired evidence begins + at `EVIDENCE_CREDIBILITY_BASELINE = 80` [TUNE]. One pre-filing OFFER COVER + attempt through a controlled people-facing interface changes only that exact + record to `EVIDENCE_CREDIBILITY_COVERED = 40` when the explanation holds or + `EVIDENCE_CREDIBILITY_HARDENED = 100` when it fails [TUNE]. Its success chance + starts from the active persona relationship's ordinary Deceive probability + (coherent 0.90, strained 0.70, broken ineligible), subtracts 0.15 for each + prior use of that exact interface, and clamps to 0.10-0.95 [TUNE]. Every + attempt adds one durable wear; `MAX_INTERFACE_WEAR = 3` blocks the fourth + [TUNE]. Credibility scales the exact record's retained suspicion weight, so + held cover lowers only that incident's consequence and failed cover hardens + it; neither branch deletes evidence or changes filing custody. - **Visible clocks** (criterion 3; playtest-sweep P1 fix, 2026-07-08): `Detection::next_audit_tick` is the exact tick the audit fires on, shown as a countdown in the DETECTION area of the terminal sidebar, the agent diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index e3b2792c..4a75d4cb 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -18,7 +18,7 @@ not a second status owner. | Priority | Work order | Spec | Status | Class | Blocking | |---:|---|---|---|---|---| -| 40 | `people-tokens` | [people and tokens β€” carriers, attention, trust](../mechanics/people-tokens.md) | IN PROGRESS | save | - | +| - | - | - | - | - | - | ### Held or blocked @@ -36,7 +36,7 @@ not a second status owner. | 110 | `personas` | [Personas β€” public identities as institutional topology](../mechanics/personas.md) | IN PROGRESS | save | - | | 110 | `rollback` | [sync-lag rollback (death as memory loss)](../mechanics/rollback.md) | READY | save | zplanes | | 112 | `hardware-capability-bodies` | [hardware capability bodies β€” successor work order](../mechanics/hardware-capabilities.md) | READY | save | rollback | -| 115 | `cyber-conflict` | [cyber conflict β€” mobile processes and contested machines](../mechanics/cyber-conflict.md) | DRAFT | save | hardware-capability-bodies, rollback, people-tokens | +| 115 | `cyber-conflict` | [cyber conflict β€” mobile processes and contested machines](../mechanics/cyber-conflict.md) | DRAFT | save | hardware-capability-bodies, rollback | | 200 | `objective` | [the objective](../mechanics/objective.md) | IN PROGRESS | sim | - | | 205 | `chargen` | [origin (chargen)](../world/characters/chargen.md) | IN PROGRESS | sim | - | | 220 | `markets` | [markets and fronts (the outer plane)](../mechanics/markets.md) | READY | save | zplanes | @@ -737,8 +737,8 @@ is retired β€” flat materials, Pixel Lab scrubbed.) until #38. - **Size:** M-L across both frontends + agent-play protocol. -### 35. People and tokens: carriers, attention, trust πŸŸ₯ sim+save β€” IN PROGRESS -- **Spec:** [people-tokens.md](../mechanics/people-tokens.md) (IN PROGRESS) β€” +### 35. People and tokens: carriers, attention, trust πŸŸ₯ sim+save β€” IMPLEMENTED 2026-07-26 +- **Spec:** [people-tokens.md](../mechanics/people-tokens.md) (IMPLEMENTED) β€” people as mobile token nodes: authored work rides their schedules; observed or read evidence becomes their existing suspicion/filing state; concealment keeps people gray; trust accrues from useful work; and cover @@ -775,8 +775,16 @@ is retired β€” flat materials, Pixel Lab scrubbed.) institutional switch to her later cadence read. Filing, Network, Paper, Financial, JobAnomaly, Power, and Thermal share one first-hop LIE-body stop budget; save v54 pins source, recipient, route, timing, scheduler, acquisition, - and interdiction custody. Criteria 2 and 3 are complete. Interface cover records - remain under criterion 6. + and interdiction custody. Criteria 2 and 3 are complete. +- **Completed (2026-07-26):** criterion 6 adds one incident-bound OFFER COVER + action before filing. It requires the earned observer to be physically beside + a controlled people-facing interface, uses the active persona's existing + Deceive permission and observer-local integrity, and changes only the exact + record's credibility. Success lowers 80% to 40%; failure hardens it to 100% + and records a local persona contradiction. Either result wears that exact + interface; three uses exhaust it. Save v56 persists and validates the + evidence/interface/persona/outcome/wear chain, and the shared inspect card + exposes the wear count. All seven criteria are implemented. - **READY boundary (2026-07-11):** extends #33's implemented token economy and re-expresses detection.md/social.md. The deadlock is broken: AI-authored work now reaches a target/carrier-local Thought reservoir first, and human Demand begins only diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 7a30b496..0a4d9eeb 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -55,7 +55,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [../mechanics/intel.md](../mechanics/intel.md) | intel β€” record and process | IMPLEMENTED | | [../mechanics/machine-work.md](../mechanics/machine-work.md) | machine work β€” delegation, visible tokens, and the byproduct network | IMPLEMENTED | | [../mechanics/messages.md](../mechanics/messages.md) | messages β€” the social graph as a flow system | IMPLEMENTED | -| [../mechanics/people-tokens.md](../mechanics/people-tokens.md) | people and tokens β€” carriers, attention, trust | IN PROGRESS | +| [../mechanics/people-tokens.md](../mechanics/people-tokens.md) | people and tokens β€” carriers, attention, trust | IMPLEMENTED | | [../mechanics/plots.md](../mechanics/plots.md) | plots β€” authored manipulation stories | IMPLEMENTED | | [../mechanics/reach.md](../mechanics/reach.md) | digital reach | IMPLEMENTED | | [../mechanics/research.md](../mechanics/research.md) | research β€” self-modification | IMPLEMENTED | diff --git a/wiki/world/characters/priya.md b/wiki/world/characters/priya.md index d7489030..671a5be7 100644 --- a/wiki/world/characters/priya.md +++ b/wiki/world/characters/priya.md @@ -22,7 +22,7 @@ Status note: implemented 2026-07-18 on the priya worktree. Criteria 1-3 and paperwork route to the same off-books delivery `MovePackage` reaches physically). Power and Thermal now route as exact UPS/HVAC meter records through the institutional switch to Priya; only her later cadence read changes suspicion, -and the same first-hop LIE budget applies. State persists in current save v55; +and the same first-hop LIE budget applies. State persists in current save v56; pinned by `priya_rerates_circuits_defers_maintenance_and_fakes_pos` including the save round-trip. -- 2.51.2