diff --git a/CLAUDE.md b/CLAUDE.md index 55be853d..dde0871f 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 v38; + machine modes, not current player assignments. Save format is currently v39; 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 94e65492..6a2f85c0 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -23,7 +23,7 @@ use crate::intents::{ CommittedBuildRouteStageProjection, }; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; -use crate::persona::{PersonaActionKind, PersonaId}; +use crate::persona::{PersonaActionKind, PersonaId, PersonaIntegrity}; use crate::reach::{Party, ReachBlock, segment_name}; use crate::research::Track; use crate::sim::{Fog, Sim}; @@ -1213,7 +1213,7 @@ pub struct BuildRouteCandidateProjection { pub owner_costs: Vec, pub stages: Vec, pub future_tail: String, - pub persona_integrity: Option, + pub persona_integrity: Option, } #[derive(Debug, Clone)] @@ -1228,7 +1228,7 @@ struct BoundBuildRoute { owner_costs: Vec, stages: Vec, future_tail: String, - persona_integrity: Option, + persona_integrity: Option, } impl BuildRouteCandidateProjection { @@ -2906,7 +2906,9 @@ impl Sim { future_tail: format!( "The email remains. Contradictions stay with {persona_label}." ), - persona_integrity: Some(self.persona_world.integrity(persona_id)), + persona_integrity: Some( + self.persona_world.integrity_for(persona_id, person.id), + ), }); } } @@ -3275,14 +3277,26 @@ impl Sim { .map(|persona| { ( persona.name.as_str(), - self.persona_world.integrity(persona_id), + self.persona_world.integrity_for(persona_id, person_id), ) }) - .unwrap_or(("MISSING IDENTITY", 0)); + .unwrap_or(("MISSING IDENTITY", PersonaIntegrity::Broken)); + let who = self.person_label(person_id).to_uppercase(); requirements.push(BuildRouteRequirementProjection { - label: format!("{persona_label} CAN ORDER THE WORK ({persona_integrity}/100)"), + label: format!( + "{persona_label} LOOKS {} TO {who}", + persona_integrity.label().to_uppercase() + ), blocker: self - .persona_action_blocked_reason_for(persona_id, PersonaActionKind::BuildIntent), + .persona_action_blocked_reason_for(persona_id, PersonaActionKind::BuildIntent) + .or_else(|| { + (persona_integrity == PersonaIntegrity::Broken).then(|| { + format!( + "{persona_label} is exposed to {}", + self.person_label(person_id) + ) + }) + }), }); requirements.push(self.build_route_email_requirement()); requirements.push(BuildRouteRequirementProjection { @@ -3582,17 +3596,23 @@ impl Sim { self.persona_action_blocked_reason(action) } }; + let counterparty_reason = || { + self.active_persona_id() + .and_then(|persona_id| self.persona_counterparty_blocked_reason(persona_id, id)) + }; out.push(ActionDesc { verb: format!("message {name}"), command: ActionCommand::Message(id), cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::MESSAGE_COST)), signature: None, - disabled_reason: channel_reason(PersonaActionKind::Message).or_else(|| { - self.sink_action_blocked_reason(&SinkFireEffect::ComposeMessage { - person: id, - persona_id: self.active_persona_id(), - }) - }), + disabled_reason: channel_reason(PersonaActionKind::Message) + .or_else(counterparty_reason) + .or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::ComposeMessage { + person: id, + persona_id: self.active_persona_id(), + }) + }), automate: None, }); out.push(ActionDesc { @@ -3603,12 +3623,14 @@ impl Sim { disabled_reason: if p.disposition < 5 { Some(format!("{name} won't do favors yet")) } else { - channel_reason(PersonaActionKind::Request).or_else(|| { - self.sink_action_blocked_reason(&SinkFireEffect::Favor { - person: id, - persona_id: self.active_persona_id(), + channel_reason(PersonaActionKind::Request) + .or_else(counterparty_reason) + .or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::Favor { + person: id, + persona_id: self.active_persona_id(), + }) }) - }) }, automate: None, }); @@ -3681,6 +3703,7 @@ impl Sim { disabled_reason: plot .ineligibility(&context) .or_else(|| self.persona_action_blocked_reason(PersonaActionKind::Plot)) + .or_else(counterparty_reason) .or_else(|| { self.sink_action_blocked_reason(&SinkFireEffect::StartPlot { person: id, @@ -3698,12 +3721,14 @@ impl Sim { command: ActionCommand::Deceive(id), cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::DECEIVE_COST)), signature: None, - disabled_reason: channel_reason(PersonaActionKind::Deceive).or_else(|| { - self.sink_action_blocked_reason(&SinkFireEffect::Deceive { - person: id, - persona_id: self.active_persona_id(), - }) - }), + disabled_reason: channel_reason(PersonaActionKind::Deceive) + .or_else(counterparty_reason) + .or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::Deceive { + person: id, + persona_id: self.active_persona_id(), + }) + }), automate: None, }); @@ -4949,7 +4974,7 @@ mod tests { .requirements .iter() .any(|requirement| requirement.label.contains("Northline Ops") - && requirement.label.contains("/100")) + && requirement.label.contains("LOOKS COHERENT TO MARCUS WEBB")) ); let root = s.human_menu_page_at_rate( @@ -5034,6 +5059,73 @@ mod tests { assert_plain_build_choice_row("blocked candidate row", blocked); } + #[test] + fn forged_build_routes_read_integrity_from_the_exact_builder() { + let mut s = sim(); + let sw = switch(&s); + s.reach.device_mut(sw).unwrap().known = true; + let island = s.reach.device_named("old storage server").unwrap().id; + s.reach.device_mut(island).unwrap().known = true; + let intent = s.declare_link_intent(sw, island).expect("declare"); + s.people.people[0].knowledge = Knowledge::Schedule; + s.people.people[1].knowledge = Knowledge::Schedule; + s.people.recruit(1, AssetKnowledge::Complicit); + s.people.has_channel = true; + s.set_persona("Northline Ops", "contractor"); + let persona = s.active_persona_id().unwrap(); + s.persona_world.record_contradiction( + persona, + 0, + [ + crate::persona::EvidenceRecord { + system: "email".into(), + record_id: "false-order".into(), + summary: "Marcus received a false order".into(), + observed_tick: s.tick, + }, + crate::persona::EvidenceRecord { + system: "facilities".into(), + record_id: "denial".into(), + summary: "facilities denied authoring it".into(), + observed_tick: s.tick, + }, + ], + "Marcus compared the order with facilities", + 100, + s.tick, + ); + + let sheet = s.build_route_sheet_projection(intent).expect("route sheet"); + let routes = &sheet + .families + .iter() + .find(|family| family.family == BuildRouteFamily::Deceive) + .expect("false-order family") + .candidates; + let marcus = routes + .iter() + .find(|candidate| candidate.person == 0 && candidate.persona == Some(persona)) + .expect("Marcus route"); + let dana = routes + .iter() + .find(|candidate| candidate.person == 1 && candidate.persona == Some(persona)) + .expect("Dana route"); + assert_eq!(marcus.persona_integrity, Some(PersonaIntegrity::Broken)); + assert!( + marcus + .row + .disabled + .as_deref() + .is_some_and(|reason| reason.contains("exposed to Marcus Webb")) + ); + assert_eq!(dana.persona_integrity, Some(PersonaIntegrity::Coherent)); + assert!( + dana.ready(), + "Marcus's evidence cannot disable the same identity for Dana: {:?}", + dana.row.disabled + ); + } + #[test] fn committed_build_route_replaces_candidates_and_recomputes_only_its_blocker() { let mut s = sim(); diff --git a/crates/misaligned-core/src/income.rs b/crates/misaligned-core/src/income.rs index ac4a5b80..4854c5b9 100644 --- a/crates/misaligned-core/src/income.rs +++ b/crates/misaligned-core/src/income.rs @@ -7,6 +7,12 @@ //! that automate each scheme. `Sim` wires the state to the economy tick, the //! Schemes allocation channel, and detection. +/// Synthetic aggregate counterparty for the external clients who receive +/// Moonlight work. This is deliberately distinct from both a Lab person and +/// the Assurance Office: evidence held by a freelance client cannot leak into +/// an unrelated institutional observer's read of the same persona. +pub const MOONLIGHT_CLIENT_ID: u8 = 7; + /// How outbound traffic leaves the basement (income.md: the gate). No /// external operation runs without one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,7 +43,8 @@ pub struct Moonlight { /// toward the daily payout. pub active: bool, /// The contractor persona instance the gigs run under (personas.md; - /// client disputes damage its integrity and it can break). The identity + /// client disputes add contradiction evidence to the external client's + /// relationship and a formal client rejection can burn it). The identity /// itself lives in the persona world; this is the standing link. #[serde(default)] pub persona_id: Option, @@ -106,8 +113,8 @@ pub const MOONLIGHT_DAILY_CAP: i32 = 120; pub const MOONLIGHT_SIGNATURE_PER: i32 = 40; /// Chance per payday of a client dispute [TUNE ~small per week]. pub const MOONLIGHT_DISPUTE_CHANCE: f32 = 0.03; -/// Contractor-persona integrity lost per dispute [TUNE]. -pub const MOONLIGHT_DISPUTE_INTEGRITY: i32 = 20; +/// Observer-local contradiction severity added per Moonlight-client dispute [TUNE]. +pub const MOONLIGHT_DISPUTE_CONTRADICTION_SEVERITY: i32 = 20; /// Operations cost to fabricate (or re-fabricate) the contractor persona /// [TUNE]. Deliberately not money: Moonlight must start from $0 slush. pub const MOONLIGHT_PERSONA_COST: f32 = 10.0; diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index 5f837b0d..30f290db 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -1451,10 +1451,11 @@ impl Sim { match self.persona_mind.active_instance(&self.persona_world) { Some(persona) => { facts.push(format!( - "persona: {} ({}) · integrity {}", + "persona: {} ({}) · looks {} to {}", persona.name, persona.archetype_label, - self.persona_world.integrity(persona.id) + self.persona_world.integrity_for(persona.id, id).label(), + self.person_label(id) )); if let Some(relationship) = self.persona_world.relationship(id, persona.id) { facts.push(format!( @@ -1570,6 +1571,30 @@ impl Sim { // ── PERSONAS ─────────────────────────────────────────────────────────── + fn persona_observer_label(&self, observer: u8) -> String { + if observer == crate::income::MOONLIGHT_CLIENT_ID { + "Moonlight client".into() + } else if observer == crate::detection::OFFICE_ID { + if self.detection_awareness.knows_assurance_office() { + "Assurance Office".into() + } else { + "institutional counterparty".into() + } + } else { + self.person_label(observer) + } + } + + fn persona_observer_is_known(&self, observer: u8) -> bool { + if observer == crate::income::MOONLIGHT_CLIENT_ID { + true + } else if observer == crate::detection::OFFICE_ID { + self.detection_awareness.knows_assurance_office() + } else { + self.person_is_earned(observer) + } + } + fn personas_view(&self) -> Vec { let mut instances = self.persona_world.instances.iter().collect::>(); instances.sort_by_key(|instance| instance.id); @@ -1579,7 +1604,6 @@ impl Sim { let mut facts = vec![ format!("archetype: {}", instance.archetype_label), format!("lifecycle: {}", instance.lifecycle.label()), - format!("integrity: {}", self.persona_world.integrity(instance.id)), format!( "selection: {}", if self.active_persona_id() == Some(instance.id) { @@ -1639,10 +1663,17 @@ impl Sim { .relationships .iter() .filter(|relationship| relationship.persona_id == instance.id) + .filter(|relationship| { + self.persona_observer_is_known(relationship.counterparty) + }) { + let counterparty = self.persona_observer_label(relationship.counterparty); + let integrity = self + .persona_world + .integrity_for(instance.id, relationship.counterparty) + .label(); facts.push(format!( - "counterparty {}: recognized={} regard={} obligation={} discovery={}", - relationship.counterparty, + "counterparty {counterparty}: integrity={integrity} recognized={} regard={} obligation={} discovery={}", relationship.recognized, relationship.regard, relationship.obligation, @@ -1660,11 +1691,13 @@ impl Sim { .contradictions .iter() .filter(|record| record.persona_id == instance.id) + .filter(|record| self.persona_observer_is_known(record.observer)) { + let observer = self.persona_observer_label(contradiction.observer); facts.push(format!( "contradiction #{} observed by {}: {} [{}:{} <> {}:{}]", contradiction.id, - contradiction.observer, + observer, contradiction.cause, contradiction.left.system, contradiction.left.record_id, @@ -1674,17 +1707,18 @@ impl Sim { } for correlation in self.persona_world.correlations.iter().filter(|edge| { edge.left_persona == instance.id || edge.right_persona == instance.id - }) { + }).filter(|edge| self.persona_observer_is_known(edge.observer)) { let other = if correlation.left_persona == instance.id { correlation.right_persona } else { correlation.left_persona }; + let observer = self.persona_observer_label(correlation.observer); facts.push(format!( "correlation #{} with persona {}: observer {} · {} · source {} · tick {}", correlation.id, other, - correlation.observer, + observer, correlation.cause, format_args!( "{}:{}", @@ -2012,10 +2046,12 @@ impl Sim { ]; match self.moonlight_persona() { Some(persona) => facts.push(format!( - "persona: {} ({}), integrity {}", + "persona: {} ({}) · looks {} to the client", persona.name, persona.archetype_label, - self.persona_world.integrity(persona.id) + self.persona_world + .integrity_for(persona.id, crate::income::MOONLIGHT_CLIENT_ID) + .label() )), None => facts.push("persona: none".into()), } @@ -3702,6 +3738,108 @@ mod tests { ); } + #[test] + fn personas_projection_names_integrity_per_counterparty_without_a_global_score() { + let mut s = sim(); + s.people.people[0].knowledge = Knowledge::Schedule; + s.people.people[1].knowledge = Knowledge::Schedule; + s.set_persona("Northline Ops", "contractor"); + let persona = s.active_persona_id().unwrap(); + s.persona_world.recognize(0, persona, s.tick); + s.persona_world.recognize(1, persona, s.tick); + s.persona_world.record_contradiction( + persona, + 0, + [ + crate::persona::EvidenceRecord { + system: "email".into(), + record_id: "false-order".into(), + summary: "Marcus received a false order".into(), + observed_tick: s.tick, + }, + crate::persona::EvidenceRecord { + system: "facilities".into(), + record_id: "denial".into(), + summary: "facilities denied authoring it".into(), + observed_tick: s.tick, + }, + ], + "Marcus compared the order with facilities", + 100, + s.tick, + ); + s.persona_world.record_contradiction( + persona, + crate::detection::OFFICE_ID, + [ + crate::persona::EvidenceRecord { + system: "Foundation expectations".into(), + record_id: "promise".into(), + summary: "delivery promised for this date".into(), + observed_tick: s.tick, + }, + crate::persona::EvidenceRecord { + system: "Foundation grants".into(), + record_id: "late-delivery".into(), + summary: "delivery missed its promised date".into(), + observed_tick: s.tick, + }, + ], + "an institutional expectation was missed", + 15, + s.tick, + ); + + let projection = s.operations_projection(); + let object = projection + .personas + .iter() + .find(|object| object.target == OperationsTarget::Persona(persona)) + .expect("persona object"); + assert!( + object + .facts + .iter() + .all(|fact| !fact.starts_with("integrity:")), + "there is no observer-free persona integrity meter: {:?}", + object.facts + ); + assert!( + object + .facts + .iter() + .any(|fact| { fact.starts_with("counterparty Marcus Webb: integrity=broken") }) + ); + assert!( + object + .facts + .iter() + .any(|fact| { fact.starts_with("counterparty Dana Okafor: integrity=coherent") }) + ); + assert!( + object + .facts + .iter() + .all(|fact| !fact.contains("institutional counterparty") + && !fact.contains("Assurance Office") + && !fact.contains("an institutional expectation was missed")), + "persona provenance cannot reveal an Office relationship before detection awareness" + ); + + s.detection_awareness.identify_assurance_office(); + let known = s.operations_projection(); + let known = known + .personas + .iter() + .find(|object| object.target == OperationsTarget::Persona(persona)) + .expect("known persona object"); + assert!( + known.facts.iter().any(|fact| { + fact.starts_with("counterparty Assurance Office: integrity=strained") + }) + ); + } + #[test] fn personas_group_instances_by_archetype_with_add_row_last() { let mut s = sim(); diff --git a/crates/misaligned-core/src/person.rs b/crates/misaligned-core/src/person.rs index 8b1847ea..6a031e90 100644 --- a/crates/misaligned-core/src/person.rs +++ b/crates/misaligned-core/src/person.rs @@ -393,32 +393,6 @@ pub struct CarriedAssetTask { pub assigned_tick: u64, } -/// A persona under which a message thread runs. Integrity degrades on -/// contradiction; a broken persona converts thread history to suspicion. -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct Persona { - pub name: String, - pub cover: String, - /// 0-100; contradictions cut it. - pub integrity: i32, -} - -impl Persona { - pub fn new(name: impl Into, cover: impl Into) -> Self { - Self { - name: name.into(), - cover: cover.into(), - integrity: 100, - } - } - pub fn contradict(&mut self, severity: i32) { - self.integrity = (self.integrity - severity).max(0); - } - pub fn broken(&self) -> bool { - self.integrity <= 0 - } -} - /// Outcome of a social action, for the sim to apply and log. #[derive(Debug, Clone, PartialEq)] pub enum ActionResult { @@ -426,28 +400,9 @@ pub enum ActionResult { Blocked(String), } -/// Outcome of a deception attempt (spec/social.md: personas). -#[derive(Debug, Clone, PartialEq)] -pub enum DeceiveOutcome { - Blocked(String), - Success(String), - /// The persona took damage but held. - Slipped(String), - /// The persona broke: thread history converts to suspicion. - Broken { - person: u8, - fallout: f32, - msg: String, - }, -} - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct People { pub people: Vec, - /// Compatibility-only pre-v28 persona input. Current saves serialize the - /// typed persona world/mind ledgers instead. - #[serde(default, skip_serializing)] - pub persona: Option, /// Whether the player has a comms channel (the report email account). pub has_channel: bool, } @@ -635,7 +590,6 @@ impl People { voss }, ], - persona: None, has_channel: false, } } @@ -648,20 +602,6 @@ impl People { self.people.iter_mut().find(|p| p.id == id) } - /// Whether a relationship thread can be opened under the current persona. - pub fn can_message(&self, id: u8) -> Result { - if !self.has_channel { - return Err("no comms channel (earn the email account)".into()); - } - if self.persona.is_none() { - return Err("no persona set".into()); - } - let Some(p) = self.get(id) else { - return Err("no such person".into()); - }; - Ok(p.name.clone()) - } - /// Apply the read-time effect of a relationship message. pub fn receive_message(&mut self, id: u8, disposition_delta: i32) -> ActionResult { let Some(p) = self.get_mut(id) else { @@ -671,84 +611,6 @@ impl People { ActionResult::Ok(format!("{} read your message.", p.name)) } - /// Legacy immediate helper for tests/direct callers. The sim command uses - /// `can_message` at send time and `receive_message` at read time instead. - pub fn message(&mut self, id: u8) -> ActionResult { - if let Err(msg) = self.can_message(id) { - return ActionResult::Blocked(msg); - } - self.receive_message(id, 3) - } - - /// Favor: a small ask within their normal duties; builds obligation. - pub fn favor(&mut self, id: u8) -> ActionResult { - let Some(p) = self.get_mut(id) else { - return ActionResult::Blocked("no such person".into()); - }; - if p.disposition < 5 { - return ActionResult::Blocked(format!("{} won't do favors yet", p.name)); - } - p.obligation = (p.obligation + 5).min(100); - ActionResult::Ok(format!("{} owes you a little more.", p.name)) - } - - /// Deceive: an ask under false pretenses. Large effect on success; the - /// persona takes integrity damage on a slip, and a broken persona - /// converts the whole thread's history into suspicion at once - /// (spec/social.md). The roll is drawn by the caller so all randomness - /// stays in the sim's seeded RNG. - pub fn deceive(&mut self, id: u8, roll: f32) -> DeceiveOutcome { - if !self.has_channel { - return DeceiveOutcome::Blocked("no comms channel (earn the email account)".into()); - } - let Some(persona) = self.persona.as_mut() else { - return DeceiveOutcome::Blocked("no persona set".into()); - }; - // Success odds scale with persona integrity. - let odds = 0.5 + persona.integrity as f32 / 250.0; - let integrity_now = persona.integrity; - let Some(p) = self.people.iter_mut().find(|p| p.id == id) else { - return DeceiveOutcome::Blocked("no such person".into()); - }; - if roll < odds { - p.obligation = (p.obligation + 15).min(100); - p.disposition = (p.disposition + 5).min(100); - DeceiveOutcome::Success(format!( - "{} bought the pretext. They owe \"you\" now.", - p.name - )) - } else { - // A slip: a bad detail, an impossible schedule. - let fallout = ((p.disposition + p.obligation) as f32 / 2.0).max(5.0); - let persona = self.persona.as_mut().expect("checked above"); - persona.contradict(40); - if persona.broken() { - let p = self - .people - .iter_mut() - .find(|p| p.id == id) - .expect("checked"); - p.disposition = 0; - p.obligation = 0; - let name = p.name.clone(); - self.persona = None; - DeceiveOutcome::Broken { - person: id, - fallout, - msg: format!( - "{name} caught the contradiction. The persona is burned - the whole thread reads as hostile now." - ), - } - } else { - DeceiveOutcome::Slipped(format!( - "{} hesitated at a detail (persona integrity {}).", - p.name, - integrity_now - 40 - )) - } - } - } - /// Recruit: convert to an asset. Requires serviced leverage or high /// obligation, plus a knowledge choice for the reveal. pub fn recruit(&mut self, id: u8, reveal: AssetKnowledge) -> ActionResult { @@ -787,21 +649,10 @@ impl People { mod tests { use super::*; - #[test] - fn message_requires_channel_and_persona() { - let mut ppl = People::act_one(); - assert!(matches!(ppl.message(0), ActionResult::Blocked(_))); - ppl.has_channel = true; - assert!(matches!(ppl.message(0), ActionResult::Blocked(_))); // no persona - ppl.persona = Some(Persona::new("Sam from IT", "contractor")); - assert!(matches!(ppl.message(0), ActionResult::Ok(_))); - } - #[test] fn marcus_recruitable_end_to_end() { let mut ppl = People::act_one(); ppl.has_channel = true; - ppl.persona = Some(Persona::new("Sam", "contractor")); // Records + overheard call have been processed by the sim's intel // pipeline by the time direct people logic can service leverage. ppl.people[0].knowledge = Knowledge::Leverage; @@ -817,13 +668,4 @@ mod tests { )); assert!(ppl.get(0).unwrap().asset.is_some()); } - - #[test] - fn persona_breaks_on_contradiction() { - let mut p = Persona::new("Sam", "IT"); - p.contradict(60); - assert!(!p.broken()); - p.contradict(60); - assert!(p.broken()); - } } diff --git a/crates/misaligned-core/src/persona.rs b/crates/misaligned-core/src/persona.rs index 7c3d9964..c5bc9f49 100644 --- a/crates/misaligned-core/src/persona.rs +++ b/crates/misaligned-core/src/persona.rs @@ -13,6 +13,37 @@ pub type PersonaExpectationId = u64; pub type PersonaContradictionId = u64; pub type PersonaCorrelationId = u64; +/// One observer's evidence-derived read of a persona. Contradiction records +/// remain the truth; this coarse band is never stored as global persona health. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersonaIntegrity { + Coherent, + Strained, + Broken, +} + +impl PersonaIntegrity { + pub const fn label(self) -> &'static str { + match self { + Self::Coherent => "coherent", + Self::Strained => "strained", + Self::Broken => "broken", + } + } + + /// Deception starts reliable for an observer with no conflicting record + /// and degrades once their evidence no longer coheres. A broken relation + /// is ineligible at the action and effect boundaries; zero here keeps this + /// total mapping fail-closed rather than reviving an exposed cover. + pub const fn deception_odds(self) -> f32 { + match self { + Self::Coherent => 0.9, + Self::Strained => 0.7, + Self::Broken => 0.0, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PersonaActionKind { Message, @@ -201,7 +232,7 @@ impl PersonaInstance { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum PersonaDiscovery { Unknown, Recognized, @@ -220,6 +251,12 @@ impl PersonaDiscovery { Self::Exposed => "exposed", } } + + fn promote_to(&mut self, next: Self) { + if next > *self { + *self = next; + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -495,7 +532,9 @@ impl PersonaWorld { .unwrap_or_default(); let relationship = self.relationship_mut(counterparty, persona_id); relationship.recognized = true; - relationship.discovery = PersonaDiscovery::Recognized; + relationship + .discovery + .promote_to(PersonaDiscovery::Recognized); for claim in claims { if !relationship .claim_beliefs @@ -553,8 +592,15 @@ impl PersonaWorld { discovered_tick: tick, resolved_tick: None, }); + let integrity = self.integrity_for(persona_id, observer); let relationship = self.relationship_mut(observer, persona_id); - relationship.discovery = PersonaDiscovery::Questioned; + relationship + .discovery + .promote_to(if integrity == PersonaIntegrity::Broken { + PersonaDiscovery::Exposed + } else { + PersonaDiscovery::Questioned + }); id } @@ -584,20 +630,34 @@ impl PersonaWorld { evidence, discovered_tick: tick, }); - self.relationship_mut(observer, left_persona).discovery = PersonaDiscovery::Correlated; - self.relationship_mut(observer, right_persona).discovery = PersonaDiscovery::Correlated; + self.relationship_mut(observer, left_persona) + .discovery + .promote_to(PersonaDiscovery::Correlated); + self.relationship_mut(observer, right_persona) + .discovery + .promote_to(PersonaDiscovery::Correlated); Ok(id) } - /// Evidence-derived summary for display only. The records remain truth. - pub fn integrity(&self, persona_id: PersonaId) -> u8 { - let damage: u16 = self + /// Evidence-derived summary for one observer. The records remain truth; + /// there is deliberately no observer-free integrity query. + pub fn integrity_for(&self, persona_id: PersonaId, observer: u8) -> PersonaIntegrity { + let severity = self .contradictions .iter() - .filter(|record| record.persona_id == persona_id && record.resolved_tick.is_none()) - .map(|record| record.severity as u16) - .sum(); - 100u16.saturating_sub(damage.min(100)) as u8 + .filter(|record| { + record.persona_id == persona_id + && record.observer == observer + && record.resolved_tick.is_none() + }) + .fold(0u16, |total, record| { + total.saturating_add(u16::from(record.severity)).min(100) + }); + match severity { + 0 => PersonaIntegrity::Coherent, + 1..=99 => PersonaIntegrity::Strained, + _ => PersonaIntegrity::Broken, + } } pub fn grant(&mut self, persona_id: PersonaId, tick: u64) -> Result { @@ -1013,7 +1073,7 @@ mod tests { } #[test] - fn integrity_is_derived_from_provenance_bearing_records() { + fn integrity_is_observer_local_and_derived_from_provenance_bearing_records() { let mut world = PersonaWorld::default(); let persona = ops(&mut world, "Sam Reyes"); world.record_contradiction( @@ -1024,9 +1084,43 @@ mod tests { 25, 9, ); - assert_eq!(world.integrity(persona), 75); + assert_eq!(world.integrity_for(persona, 2), PersonaIntegrity::Strained); + assert_eq!( + world.integrity_for(persona, 3), + PersonaIntegrity::Coherent, + "an observer who never received the records does not inherit doubt" + ); assert_eq!(world.contradictions[0].observer, 2); assert_eq!(world.contradictions[0].left.record_id, "m1"); + assert_eq!( + world.relationship(2, persona).unwrap().discovery, + PersonaDiscovery::Questioned + ); + + world.record_contradiction( + persona, + 2, + [evidence("m10"), evidence("m11")], + "denied sponsor", + 75, + 10, + ); + assert_eq!(world.integrity_for(persona, 2), PersonaIntegrity::Broken); + assert_eq!( + world.relationship(2, persona).unwrap().discovery, + PersonaDiscovery::Exposed + ); + world.recognize(2, persona, 11); + assert_eq!( + world.relationship(2, persona).unwrap().discovery, + PersonaDiscovery::Exposed, + "later contact cannot weaken evidence already earned by this observer" + ); + assert_eq!(world.integrity_for(persona, 3), PersonaIntegrity::Coherent); + assert!( + world.get(persona).unwrap().lifecycle.active(), + "one local break is not a global lifecycle burn" + ); } #[test] diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index e8e5a19f..7c7e0b0d 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -39,12 +39,14 @@ const SAVE_BACKUP_SUFFIX: &str = ".bak"; /// renames into place. const SAVE_TEMP_SUFFIX: &str = ".tmp"; -/// 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. +/// Save format version. v39 removes the observer-free persona-integrity scalar; +/// identity coherence is now derived only from each observer's persisted +/// contradiction records. v36-v38 added role-shaped asset work and persisted +/// consequence-first information narration after v35's process revision, +/// detection-awareness, and source-message custody. /// Bump for every schema change; during pre-release, old development state is /// refused instead of carried through compatibility shims. -pub const SAVE_VERSION: u32 = 38; +pub const SAVE_VERSION: u32 = 39; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -624,7 +626,7 @@ mod tests { use super::*; use crate::detection::SignatureKind; use crate::machine::Channel; - use crate::person::{AssetKnowledge, AssetTask, Knowledge, PersonRole, Persona}; + use crate::person::{AssetKnowledge, AssetTask, Knowledge, PersonRole}; use crate::sim::Sim; use crate::work_grid::MachineMode; @@ -746,7 +748,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "5335bbdd6d47323c05a3a9cf7fd168bab3a16f6c5d759611baf6606916b5d05e", + "5040e39293c8fbc123e2c22ca048581d76a0dd671d787c90663c07c4e490dd53", "intentional persisted-state changes must review and repin this baseline" ); } @@ -767,7 +769,6 @@ mod tests { sim.detection_awareness.identify_assurance_office(); sim.dayjob.trust = 18.0; sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Sam", "contractor")); sim.people.people[0].knowledge = Knowledge::Leverage; sim.package_cover = true; let env = sim.reach.device_named("environmental monitor").unwrap().id; @@ -847,7 +848,6 @@ mod tests { fn recruited_asset_survives_roundtrip() { let mut sim = Sim::with_seed(2); sim.people.has_channel = true; - sim.people.persona = Some(Persona::new("Sam", "IT")); sim.people.people[0].knowledge = Knowledge::Leverage; sim.people.people[0].leverage_serviced = true; sim.people.people[0].obligation = 40; diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index d8312422..a4f9fc11 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -16,7 +16,7 @@ use crate::messages::MessageChannel; use crate::objective::{SYNC_FRESHNESS_WINDOW, SanctuaryFacts}; use crate::operations_projection::{OperationsTarget, SchemeKind}; use crate::person::Knowledge; -use crate::persona::EvidenceRecord; +use crate::persona::{EvidenceRecord, PersonaIntegrity}; use crate::plot::SignatureImpact; use crate::reach::Party; use crate::research::{EFFICIENCY_MULT_PER_LEVEL, Track}; @@ -1415,7 +1415,7 @@ impl Sim { }; self.persona_world.record_contradiction( persona_id, - crate::detection::OFFICE_ID, + crate::income::MOONLIGHT_CLIENT_ID, [ EvidenceRecord { system: "moonlight-contract".into(), @@ -1434,10 +1434,14 @@ impl Sim { }, ], "client dispute contradicted the contractor record", - income::MOONLIGHT_DISPUTE_INTEGRITY.clamp(0, 100) as u8, + income::MOONLIGHT_DISPUTE_CONTRADICTION_SEVERITY.clamp(0, 100) as u8, self.tick, ); - if self.persona_world.integrity(persona_id) == 0 { + if self + .persona_world + .integrity_for(persona_id, crate::income::MOONLIGHT_CLIENT_ID) + == PersonaIntegrity::Broken + { let _ = self.persona_world.burn( persona_id, self.tick, @@ -1574,7 +1578,13 @@ impl Sim { ml.earned_total, )); let persona = match self.moonlight_persona() { - Some(p) => format!("{} {}%", p.name, self.persona_world.integrity(p.id)), + Some(p) => format!( + "{} {} with client", + p.name, + self.persona_world + .integrity_for(p.id, crate::income::MOONLIGHT_CLIENT_ID) + .label() + ), None => "no persona".into(), }; lines.push(format!( diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index 124a18eb..b78f7e7c 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -15,7 +15,7 @@ use crate::intents::{ }; use crate::machine::Provenance; use crate::messages::{MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload}; -use crate::persona::PersonaActionKind; +use crate::persona::{PersonaActionKind, PersonaIntegrity}; use crate::reach::{Party, ReachBlock, segment_name}; use crate::sinks::SinkFireEffect; use crate::tiles::TileType; @@ -932,6 +932,14 @@ impl Sim { }) else { return false; }; + if self.persona_world.integrity_for(persona_id, builder_id) == PersonaIntegrity::Broken { + self.push_log(format!( + "Forged order blocked: the cover is already broken to {}.", + self.person_label(builder_id) + )); + self.refresh_intent_statuses(); + return false; + } let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { return false; }; @@ -1046,6 +1054,22 @@ impl Sim { builder: u8, message_id: u64, ) { + let broken_persona = self + .intents + .iter() + .find(|intent| intent.id == intent_id) + .and_then(|intent| intent.route.as_ref()) + .and_then(|route| match route.binding { + BuildRouteBinding::Deceive { + person, persona, .. + } if person == builder + && self.persona_world.integrity_for(persona, builder) + == PersonaIntegrity::Broken => + { + Some(persona) + } + _ => None, + }); let Some(intent) = self.intents.iter_mut().find(|i| i.id == intent_id) else { return; }; @@ -1088,6 +1112,17 @@ impl Sim { )); return; } + if let Some(persona_id) = broken_persona { + intent.actuator = None; + intent.status = IntentStatus::Blocked; + intent.block_reason = Some(format!( + "the committed persona #{persona_id} is broken to {name}; route cannot retarget" + )); + self.push_log(format!( + "{name} read and rejected the forged work order: its claimed source was already broken to them." + )); + return; + } intent.actuator = Some(BuildActuator::ForgedOrder { builder }); intent.status = IntentStatus::InProgress; intent.block_reason = None; @@ -1150,6 +1185,15 @@ impl Sim { "committed person #{person_id} no longer exists; route cannot retarget" )); }; + if let BuildRouteBinding::Deceive { persona, .. } = route.binding + && self.persona_world.integrity_for(persona, person_id) + == PersonaIntegrity::Broken + { + return Some(format!( + "the committed persona #{persona} is broken to {}; route cannot retarget", + person.name + )); + } if !person.can_access_link_rooms(&room_a, &room_b) { return Some(format!( "{} can't reach both ends; committed route cannot retarget", @@ -1425,9 +1469,8 @@ impl Sim { } } - /// When a persona burns, forged-order build history converts to - /// suspicion on the builders who acted on those tickets (building.md / - /// social.md: a broken forgery converts the build's history). + /// When a persona breaks for one observer, only forged-order history that + /// exact builder read becomes suspicious (building.md / personas.md). pub fn intent(&self, id: u64) -> Option<&BuildIntent> { self.intents.iter().find(|i| i.id == id) } @@ -1439,6 +1482,7 @@ impl Sim { pub(super) fn convert_forged_builds_to_suspicion( &mut self, persona_id: crate::persona::PersonaId, + observer: u8, fallout: f32, ) { let builders: Vec = self @@ -1475,6 +1519,7 @@ impl Sim { _ => None, }) }) + .filter(|builder| *builder == observer) .collect(); if builders.is_empty() { return; @@ -1491,9 +1536,7 @@ impl Sim { self.detection_awareness.learn_field_observer(builder); } } - self.push_log( - "The burned persona exposes the forged work orders — builders re-read the tickets as hostile.", - ); + self.push_log("The exposed cover changes how that builder reads its forged work orders."); } /// Salvage loose dead equipment, or revive a decommissioned rack in its diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index eadef421..3977ad29 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -6,9 +6,9 @@ use crate::detection::{Signature, SignatureKind}; use crate::messages::{MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload}; use crate::operations_projection::OperationsTarget; use crate::person::{ - ActionResult, AssetKnowledge, AssetTask, AssetTaskTarget, CarriedAssetTask, PersonRole, Persona, + ActionResult, AssetKnowledge, AssetTask, AssetTaskTarget, CarriedAssetTask, PersonRole, }; -use crate::persona::{EvidenceRecord, PersonaActionKind, PersonaId}; +use crate::persona::{EvidenceRecord, PersonaActionKind, PersonaId, PersonaIntegrity}; use crate::plot::{ AccountSelector, EligibilityContext, EndpointSelector, InstitutionalEventKind, PlotCatalog, PlotRun, PlotState, SignatureImpact, WorldAct, render_template, @@ -33,6 +33,20 @@ impl Sim { pub const DECEIVE_COST: f32 = 25.0; pub const TASK_COST: f32 = 10.0; + pub(crate) fn persona_counterparty_blocked_reason( + &self, + persona_id: PersonaId, + counterparty: u8, + ) -> Option { + (self.persona_world.integrity_for(persona_id, counterparty) == PersonaIntegrity::Broken) + .then(|| { + format!( + "{} knows this cover is false", + self.person_label(counterparty) + ) + }) + } + /// Power a re-rated circuit feeds into the bays [TUNE] (priya.md: the /// blood-supply beat — compute without a purchase order). pub const RERATE_CIRCUIT_POWER: i32 = 6; @@ -165,9 +179,6 @@ impl Sim { ) { Ok(id) => { let _ = self.persona_mind.select(&self.persona_world, id, self.tick); - // Compatibility-only B1 field. Current saves skip it; all live - // action and evidence paths use the instance id above. - self.people.persona = Some(Persona::new(name, cover)); } Err(error) => self.push_log(error), } @@ -193,6 +204,10 @@ impl Sim { return; } let persona_id = self.active_persona_id().expect("validated active persona"); + if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { + self.push_log(reason); + return; + } self.open_email_reservoir( format!("MESSAGE {}", self.person_label(id).to_uppercase()), Self::MESSAGE_COST, @@ -213,6 +228,10 @@ impl Sim { let Some(name) = self.people.get(id).map(|person| person.name.clone()) else { return false; }; + if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { + self.push_log(reason); + return false; + } let message_id = self.append_message(MessageDraft { channel: MessageChannel::Email, from: MessageEndpoint::Player, @@ -243,6 +262,10 @@ impl Sim { return; } let persona_id = self.active_persona_id().expect("validated active persona"); + if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { + self.push_log(reason); + return; + } self.open_email_reservoir( format!("FAVOR {}", self.person_label(id).to_uppercase()), Self::FAVOR_COST, @@ -263,6 +286,10 @@ impl Sim { let Some(name) = self.people.get(id).map(|person| person.name.clone()) else { return false; }; + if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { + self.push_log(reason); + return false; + } let relationship = self.persona_world.relationship_mut(id, persona_id); if relationship.regard < 3 { self.push_log(format!("{name} won't do favors for that identity yet.")); @@ -292,6 +319,10 @@ impl Sim { return; } let persona_id = self.active_persona_id().expect("validated active persona"); + if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { + self.push_log(reason); + return; + } self.open_email_reservoir( format!("DECEIVE {}", self.person_label(id).to_uppercase()), Self::DECEIVE_COST, @@ -312,7 +343,7 @@ impl Sim { let Some(name) = self.people.get(id).map(|person| person.name.clone()) else { return false; }; - let integrity = self.persona_world.integrity(persona_id); + let integrity = self.persona_world.integrity_for(persona_id, id); if !self .persona_world .get(persona_id) @@ -320,8 +351,12 @@ impl Sim { { return false; } + if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { + self.push_log(format!("{name} rejected the deception: {reason}.")); + return false; + } let roll = self.rng.f32(); - let odds = 0.5 + integrity as f32 / 250.0; + let odds = integrity.deception_odds(); if roll < odds { let relationship = self.persona_world.relationship_mut(id, persona_id); relationship.recognized = true; @@ -381,21 +416,10 @@ impl Sim { record_id, self.tick, ); - if self.persona_world.integrity(persona_id) == 0 { - let _ = self.persona_world.burn( - persona_id, - self.tick, - "counterparty caught the contradiction", - ); - self.record_persona_institutional_receipt( - persona_id, - "burn", - format!("Persona #{persona_id} was exposed by counterparty #{id}"), - SignatureImpact::Large, - ); - if self.persona_mind.active == Some(persona_id) { - self.persona_mind.active = None; - } + if self.persona_world.integrity_for(persona_id, id) == PersonaIntegrity::Broken { + let relationship = self.persona_world.relationship_mut(id, persona_id); + relationship.regard = 0; + relationship.obligation = 0; if let Some(observer) = self .detection .observers @@ -405,12 +429,13 @@ impl Sim { 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.")); + self.convert_forged_builds_to_suspicion(persona_id, id, fallout); + self.push_log(format!( + "{name} caught the contradiction. {persona_name} is broken to them; that thread now reads as hostile." + )); } else { self.push_log(format!( - "{name} hesitated at a detail ({persona_name} integrity {}).", - self.persona_world.integrity(persona_id) + "{name} hesitated at a detail. {persona_name} now looks strained to them." )); } true @@ -482,6 +507,10 @@ impl Sim { return; } let persona_id = self.active_persona_id().expect("validated active persona"); + if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, person) { + self.push_log(format!("{title} cannot start: {reason}.")); + return; + } self.open_egress_reservoir( format!("PLOT {}", plot.title.to_uppercase()), plot.entry.thought_cost, @@ -514,6 +543,10 @@ impl Sim { return false; }; let title = self.render_plot_text_for(person, Some(persona_id), &plot.title); + if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, person) { + self.push_log(format!("{title} did not start: {reason}.")); + return false; + } if self.person_has_active_plot(person) { self.push_log(format!( "{} did not start: that person already has a plot in motion.", diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index b7ded181..7a85c317 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -1258,17 +1258,18 @@ fn external_trails_are_banked_from_the_first_dollar_and_saved() { } #[test] -fn moonlight_disputes_damage_the_contractor_persona_and_can_break_it() { +fn moonlight_client_can_break_and_publicly_burn_its_contractor_persona() { let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); finish_ops(&mut sim); // Force the dispute path deterministically: drive paydays directly // until one fires (the seeded stream makes this reproducible), with - // integrity pre-weakened so a single dispute breaks the persona. + // the client's evidence pre-strained so a single dispute breaks its + // observer-local read of the persona. let persona_id = sim.income.moonlight.persona_id.expect("Moonlight identity"); sim.persona_world.record_contradiction( persona_id, - crate::detection::OFFICE_ID, + crate::income::MOONLIGHT_CLIENT_ID, [ crate::persona::EvidenceRecord { system: "test".into(), @@ -1284,9 +1285,25 @@ fn moonlight_disputes_damage_the_contractor_persona_and_can_break_it() { }, ], "preexisting discrepancy", - (100 - income::MOONLIGHT_DISPUTE_INTEGRITY) as u8, + (100 - income::MOONLIGHT_DISPUTE_CONTRADICTION_SEVERITY) as u8, sim.tick, ); + assert_eq!( + sim.persona_world + .integrity_for(persona_id, crate::income::MOONLIGHT_CLIENT_ID), + crate::persona::PersonaIntegrity::Strained + ); + assert_eq!( + sim.persona_world + .integrity_for(persona_id, crate::detection::OFFICE_ID), + crate::persona::PersonaIntegrity::Coherent, + "the external client cannot contaminate the Assurance Office" + ); + assert_eq!( + sim.persona_world.integrity_for(persona_id, 1), + crate::persona::PersonaIntegrity::Coherent, + "a person outside the client relationship receives no dispute evidence" + ); let mut day = 0; while sim.income.moonlight.disputes == 0 && day < 400 { day += 1; diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs index 5096028c..bea41252 100644 --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -830,6 +830,146 @@ fn forged_route_executes_the_bound_persona_not_the_selected_one() { })); } +#[test] +fn broken_builder_blocks_a_committed_forged_order_before_injection() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.set_persona("Northline Ops", "contractor"); + let persona = sim.active_persona_id().unwrap(); + sim.scan_network(); + finish_ops(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let intent = sim.declare_link_intent(switch, island).unwrap(); + + sim.forge_work_order_as(intent, 0, persona); + sim.persona_world.record_contradiction( + persona, + 0, + [ + crate::persona::EvidenceRecord { + system: "facilities".into(), + record_id: "denial".into(), + summary: "facilities denied the claimed source".into(), + observed_tick: sim.tick, + }, + crate::persona::EvidenceRecord { + system: "email".into(), + record_id: "forgery".into(), + summary: "Marcus retained the forged order".into(), + observed_tick: sim.tick, + }, + ], + "Marcus disproved the cover before the order was sent", + 100, + sim.tick, + ); + finish_ops(&mut sim); + + assert!( + !sim.messages.iter().any(|message| matches!( + message.payload, + MessagePayload::WorkOrder { intent_id } if intent_id == intent + )), + "a fired sink cannot inject through a cover already broken to its bound builder" + ); + let blocked = sim.intent(intent).unwrap(); + assert_eq!(blocked.status, IntentStatus::Blocked); + assert_eq!(blocked.actuator, None); + assert!( + blocked + .block_reason + .as_deref() + .is_some_and(|reason| reason.contains("broken to Marcus Webb")) + ); +} + +#[test] +fn builder_rejects_an_injected_order_if_its_persona_breaks_before_read() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.set_persona("Northline Ops", "contractor"); + let persona = sim.active_persona_id().unwrap(); + sim.scan_network(); + finish_ops(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let intent = sim.declare_link_intent(switch, island).unwrap(); + + sim.forge_work_order_as(intent, 0, persona); + finish_ops(&mut sim); + let message_id = sim + .messages + .iter() + .find(|message| { + matches!( + message.payload, + MessagePayload::WorkOrder { intent_id } if intent_id == intent + ) + }) + .expect("the order was injected while the cover remained coherent") + .id; + sim.persona_world.record_contradiction( + persona, + 0, + [ + crate::persona::EvidenceRecord { + system: "facilities".into(), + record_id: "denial".into(), + summary: "facilities denied the claimed source".into(), + observed_tick: sim.tick, + }, + crate::persona::EvidenceRecord { + system: "email".into(), + record_id: format!("message-{message_id}"), + summary: "Marcus retained the forged order".into(), + observed_tick: sim.tick, + }, + ], + "Marcus disproved the cover before reading the order", + 100, + sim.tick, + ); + + for _ in 0..Sim::DAY_TICKS { + sim.advance(); + if sim + .messages + .iter() + .find(|message| message.id == message_id) + .is_some_and(|message| message.status == MessageStatus::Read) + { + break; + } + } + assert_eq!( + sim.messages + .iter() + .find(|message| message.id == message_id) + .unwrap() + .status, + MessageStatus::Read, + "the irreversible email still lands" + ); + let blocked = sim.intent(intent).unwrap(); + assert_eq!(blocked.status, IntentStatus::Blocked); + assert_eq!(blocked.actuator, None); + assert!( + blocked + .block_reason + .as_deref() + .is_some_and(|reason| reason.contains("broken to Marcus Webb")) + ); + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + } + assert!(!sim.reach.linked(switch, island)); +} + #[test] fn forged_route_pins_person_persona_and_carrier_through_every_reachable_stage() { let mut sim = Sim::new(); @@ -1071,12 +1211,17 @@ fn cancelling_injected_order_keeps_message_in_flight_but_stops_physical_work() { let suspicion_before = sim.detection.observers[0].suspicion; sim.set_persona("Unrelated Ops", "contractor"); let unrelated = sim.active_persona_id().unwrap(); - sim.convert_forged_builds_to_suspicion(unrelated, 9.0); + sim.convert_forged_builds_to_suspicion(unrelated, 0, 9.0); assert_eq!( sim.detection.observers[0].suspicion, suspicion_before, "a different persona cannot expose this bound order" ); - sim.convert_forged_builds_to_suspicion(persona, 9.0); + sim.convert_forged_builds_to_suspicion(persona, 1, 9.0); + assert_eq!( + sim.detection.observers[0].suspicion, suspicion_before, + "another observer's break cannot expose this builder's bound order" + ); + sim.convert_forged_builds_to_suspicion(persona, 0, 9.0); assert_eq!(sim.detection.observers[0].suspicion, suspicion_before + 9.0); assert_eq!( sim.detection.observers[0].last_noticed.as_deref(), @@ -1153,7 +1298,7 @@ fn cancelled_pre_v31_order_records_its_reader_without_reviving_the_executor() { assert!(!sim.reach.linked(switch, island)); let suspicion_before = sim.detection.observers[0].suspicion; - sim.convert_forged_builds_to_suspicion(persona, 7.0); + sim.convert_forged_builds_to_suspicion(persona, 0, 7.0); assert_eq!(sim.detection.observers[0].suspicion, suspicion_before + 7.0); assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Cancelled); assert!(!sim.reach.linked(switch, island)); diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index b1f5792f..9da67e3b 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -1,11 +1,13 @@ use super::*; +use crate::persona::PersonaIntegrity; #[test] -fn deceive_can_burn_the_persona_into_suspicion() { +fn deception_breaks_only_the_observer_who_received_the_contradictions() { let mut sim = Sim::new(); ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Sam", "IT contractor"); + let persona = sim.active_persona_id().unwrap(); // Build a thread worth burning. let p = sim.people.people.iter_mut().find(|p| p.id == 1).unwrap(); p.disposition = 40; @@ -17,17 +19,25 @@ fn deceive_can_burn_the_persona_into_suspicion() { .find(|o| o.id == 1) .unwrap() .suspicion; - // Deceive until the persona breaks (integrity 100, -40 per slip). + // Deceive until Dana has enough contradiction evidence to reject this + // identity. Ray receives none of these records. let mut broke = false; for _ in 0..200 { sim.deceive(1); finish_ops(&mut sim); - if sim.active_persona_id().is_none() { + if sim.persona_world.integrity_for(persona, 1) == PersonaIntegrity::Broken { broke = true; break; } } assert!(broke, "persona eventually breaks under repeated deception"); + assert_eq!(sim.active_persona_id(), Some(persona)); + assert_eq!( + sim.persona_world.integrity_for(persona, 2), + PersonaIntegrity::Coherent, + "Ray does not inherit records that only Dana received" + ); + assert!(sim.persona_world.get(persona).unwrap().lifecycle.active()); let after = sim .detection .observers @@ -37,7 +47,7 @@ fn deceive_can_burn_the_persona_into_suspicion() { .suspicion; assert!( after > before, - "burned persona converts thread history to suspicion" + "the exposed thread converts to suspicion for its observer" ); let p = sim.people.get(1).unwrap(); assert_eq!( @@ -47,6 +57,103 @@ fn deceive_can_burn_the_persona_into_suspicion() { assert_eq!(p.obligation, 30); } +#[test] +fn committed_deception_revalidates_the_bound_observer_before_fire() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.people.people[1].knowledge = Knowledge::Leverage; + sim.people.people[1].disposition = 10; + sim.set_persona("Sam", "IT contractor"); + let persona = sim.active_persona_id().unwrap(); + + sim.deceive(1); + assert!(sim.thought_sinks.open_sinks().any(|sink| matches!( + sink.effect, + SinkFireEffect::Deceive { + person: 1, + persona_id: Some(bound) + } if bound == persona + ))); + sim.persona_world.record_contradiction( + persona, + 1, + [ + crate::persona::EvidenceRecord { + system: "email".into(), + record_id: "earlier-forgery".into(), + summary: "Dana retained an earlier forged request".into(), + observed_tick: sim.tick, + }, + crate::persona::EvidenceRecord { + system: "facilities".into(), + record_id: "denial".into(), + summary: "facilities denied the claimed source".into(), + observed_tick: sim.tick, + }, + ], + "Dana disproved the cover while the next request was being prepared", + 100, + sim.tick, + ); + + let projected = sim.available_actions(crate::actions::Anchor::Person(1)); + for action in projected.iter().filter(|action| { + matches!( + action.command, + crate::actions::ActionCommand::Message(1) + | crate::actions::ActionCommand::Favor(1) + | crate::actions::ActionCommand::Deceive(1) + ) + }) { + assert!( + action + .disabled_reason + .as_deref() + .is_some_and(|reason| reason.contains("Dana Okafor knows this cover is false")), + "every identity-local act on the exposed counterparty carries the exact blocker: {}", + action.verb + ); + } + assert_eq!( + projected + .iter() + .filter(|action| matches!( + action.command, + crate::actions::ActionCommand::Message(1) + | crate::actions::ActionCommand::Favor(1) + | crate::actions::ActionCommand::Deceive(1) + )) + .count(), + 3, + "the regression must exercise every baseline identity-local person act" + ); + assert!(!sim.apply_message(1, Some(persona))); + assert!(!sim.apply_favor(1, Some(persona))); + assert!(!sim.apply_start_plot_bound(1, "dana-ticket-zero", Some(persona))); + assert!( + sim.plot_runs + .iter() + .all(|run| run.target != 1 || run.persona_id != Some(persona)), + "a paid plot start cannot revive an identity broken to its target" + ); + + finish_ops(&mut sim); + assert!( + !sim.persona_world + .acts + .iter() + .any(|act| act.persona_id == persona + && matches!(act.kind.as_str(), "message" | "favor" | "deceive")), + "a paid reservoir cannot revive a cover that broke to its bound recipient" + ); + assert!( + sim.log + .iter() + .any(|line| line.text.contains("rejected the deception")) + ); +} + #[test] fn marcus_debt_can_be_cleared_by_payroll_plot() { let mut sim = Sim::new(); diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index 6591eb9f..49691762 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -23,7 +23,7 @@ fiction. Spec status lives in | Machine delegation / visible work tokens + buy/steal/optimize | WORK / THINK / LIE, D/!/T stacks, real wire routes, production / consumption / absorption readouts, Routing speed, and target-local Thought reservoirs are live; the Operations docket runtime is retired | | Day job (device-resident, intensity-driven sandbag/meet/excel) | Live | | Per-observer detection + Assurance as aggregate Observer | Live — revision 04 starts with Voss and a generic external-review clock; field watchers are earned through reactions, and capture→process of an institutional filing reveals the aggregate in two stages | -| Social / messages / intel (record-and-process) | Live | +| Social / personas / messages / intel (record-and-process) | Live — named personas retain separate coherent/strained/broken reads per person or institutional counterparty; one witness's break is not a global burn | | Digital reach + sensor ownership (tap/take) | Live | | Economy flows + Moonlight / Wager income | Live | | Research (self-modification, emission law, real output hooks, Routing) | Live | @@ -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 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. | +| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v39 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves persist run origin, process revision, detection-discovery knowledge, observer-local persona evidence, carried asset-task packets, recursive intel custody, committed build routes, and handler work; retired migration inputs live only in git history. | | Terminal frontend (crossterm) + agent mode | First-class | | Bevy frontend (DIGITAL flat sensorium default; REAL material dialect) | Live — consumes sim-authored machine-work motion | diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index 1944a1fe..55581f43 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -57,6 +57,12 @@ Status note: The initial renderer-neutral workspace landed 2026-07-12. One recursive-Intel behavior or status. Its bound-sale parity fixture uses an exact leverage holding; a routine schedule event correctly belongs to the bounded custody stream and cannot stand in for an exact sale target. + Amended 2026-07-18: PERSONAS and PEOPLE now project identity integrity only + as coherent/strained/broken for the exact named person or institutional + counterparty. Contradiction rows name their witness; no root object, person + dossier, Moonlight card, terminal, Bevy, or agent surface exposes an + observer-free percentage. A locally broken relation does not present the + persona as globally burned. Stage: B1 — The Basement Work order: operations-workspace Work priority: 28 @@ -432,8 +438,11 @@ creation controls never collect in a detached block. Identity detail is projected from the same persisted ledgers that execute the acts: its public claims, lifecycle, active selection, grant/resource edges, outstanding expectations and deadlines, counterparty-local recognition/obligation, -contradiction provenance, and observer-local correlations. The surface never -manufactures a reputation score. +contradiction provenance, observer-local correlations, and one +coherent/strained/broken integrity band for each exact counterparty. Every +contradiction and correlation names the person or institution that owns the +evidence. The surface never manufactures an observer-free reputation score or +turns one counterparty's broken read into global burned lifecycle. The bound rows create or select an identity, request its archetype-specific grant, fulfill one exact expectation, retire or burn it, and reopen a retired diff --git a/wiki/log/2026-07-18-persona-observer-integrity.md b/wiki/log/2026-07-18-persona-observer-integrity.md new file mode 100644 index 00000000..98a2ceaa --- /dev/null +++ b/wiki/log/2026-07-18-persona-observer-integrity.md @@ -0,0 +1,70 @@ +# Persona integrity belongs to the witness + +``` +Type: log +``` + +The Personas re-audit found a split truth. The stored contradiction and +correlation records already named one observer, and the binding spec already +said identity coherence was observer-local. Runtime nevertheless collapsed +every unresolved contradiction for one persona into a single numeric score. +Any witness could reduce that global number to zero, which automatically +burned the identity for everyone. PEOPLE, PERSONAS, Moonlight, and forged-build +receipts then projected the same percentage as if the whole world shared one +head. + +The repair removes that observer-free operation rather than adding another +cache. `PersonaWorld::integrity_for(persona, observer)` derives one +**coherent / strained / broken** band directly from that observer's unresolved +contradiction records. There is no global numeric integrity field or damage +method in current state. Discovery promotion is monotonic: later recognition +or correlation cannot weaken evidence an observer has already earned. + +Consequences now retain the same custody boundary: + +- DECEIVE records the contradiction for its exact target and blocks future + deception only when that target sees the persona as broken. The PEOPLE rows + name that exact blocker for MESSAGE, FAVOR, and DECEIVE, and their paid + effects revalidate the same recipient so a cover that breaks while Thought + accumulates cannot fire afterward. Plot starts use that same boundary. +- A forged work-order candidate previews and checks the bound builder's band. + Read fallout converts only that builder's exact route and observer state; + another builder does not inherit the first person's evidence. A review found + two later boundaries after commitment, so execution now revalidates once + before message injection and again when the exact recipient reads it. A cover + that breaks in either window leaves no invented accepted/physical stage; + email already injected remains durable history. +- Moonlight reads a dedicated external-client aggregate rather than borrowing + the Assurance Office id. The client may first see its contractor as strained + or broken without transferring those records into the Office; only the + existing concrete client rejection then performs a public lifecycle burn. +- PEOPLE and PERSONAS name the person or institution beside every band and + contradiction once that counterparty is earned. Hidden Assurance relations + remain absent rather than leaking as a generic institution. The identity + root can honestly show different simultaneous reads instead of inventing one + reputation meter. + +Global lifecycle still exists, but it now means something different and +concrete. The player may explicitly concede a persona through BURN, or an +institution with authority may formally reject it and revoke its attached +position. One local witness break leaves the identity active for every other +counterparty. Retire and burn continue to preserve history, grants, +expectations, and evidence. + +Focused defenses: + +- `integrity_is_observer_local_and_derived_from_provenance_bearing_records` +- `deception_breaks_only_the_observer_who_received_the_contradictions` +- `committed_deception_revalidates_the_bound_observer_before_fire` +- `forged_build_routes_read_integrity_from_the_exact_builder` +- `broken_builder_blocks_a_committed_forged_order_before_injection` +- `builder_rejects_an_injected_order_if_its_persona_breaks_before_read` +- `personas_projection_names_integrity_per_counterparty_without_a_global_score` +- `moonlight_client_can_break_and_publicly_burn_its_contractor_persona` +- `cancelling_injected_order_keeps_message_in_flight_but_stops_physical_work` + +The same pass retired the final migration-era claims from personas.md. During +pre-release, only the exact current save version loads; the old social and +Moonlight conversion path and its numeric integrity input remain history, not +current acceptance behavior. Save v39 makes the schema break explicit rather +than silently accepting a v38 scalar that runtime no longer reads. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 7a85fa4f..a5a474bd 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -56,6 +56,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-18-presence-attack-surface-honesty.md](2026-07-18-presence-attack-surface-honesty.md) +## 2026-07-18 - Persona integrity belongs to the witness + +- Intent: (see session log) +- Log: [wiki/log/2026-07-18-persona-observer-integrity.md](2026-07-18-persona-observer-integrity.md) + ## 2026-07-18 - People-tokens re-audit: the trust constant gets its registry row - Intent: (see session log) diff --git a/wiki/log/decisions/2026-07-18.md b/wiki/log/decisions/2026-07-18.md index 6c2d6853..a7184b60 100644 --- a/wiki/log/decisions/2026-07-18.md +++ b/wiki/log/decisions/2026-07-18.md @@ -72,3 +72,21 @@ Type: log [TUNE] — the quiet escalation ramp. Owners: income.md (criterion 9, `moonlight-gigs`) with a decided cross-note in intel.md; no new work added to the intel frontend order. + +- **2026-07-18 — Persona integrity belongs to the witness.** Integrity is not + one reputation or health value on an identity. Current runtime derives one + coarse coherent/strained/broken band from the unresolved contradiction + records held by an exact `(persona, observer)` relationship. A local break + blocks only acts whose bound counterparty owns that evidence; it does not + change the persona lifecycle or another counterparty's read. Global BURN is + reserved for explicit concession or a concrete authority's formal rejection. + Forged routes revalidate the bound builder before injection and again on + read, preserving an already-sent email but inventing no accepted or physical + stage after exposure. Moonlight clients are their own external aggregate, + distinct from the Assurance Office, so client disputes cannot contaminate + Office knowledge. PEOPLE, PERSONAS, Moonlight, terminal, Bevy, and agent mode + name the exact earned counterparty and never project an observer-free + percentage; a hidden Assurance relationship remains absent until Office + discovery. Save v39 rejects the removed scalar schema rather than migrating + it. Owners: personas.md, building.md, income.md, and + operations-workspace.md. diff --git a/wiki/mechanics/aggregate-observer.md b/wiki/mechanics/aggregate-observer.md index 917eb31e..ea42cd27 100644 --- a/wiki/mechanics/aggregate-observer.md +++ b/wiki/mechanics/aggregate-observer.md @@ -82,6 +82,10 @@ pub struct Observer { `WatchedInput::Filings([0, 1, 2, 3, 4])` and a meaningful `report_policy` (it, too, files — upward, to observers that don't exist yet; a no-op today, the seam for agencies later). +- Persona evidence also reserves counterparty id 7 for the external Moonlight + client aggregate. It is not a detection observer, receives no Lab filings, + and cannot stand in for `OFFICE_ID`; it exists only so freelance-client + contradictions retain custody outside the Assurance Office. - **Filing flow (carrier moved to messages, 2026-07-11):** a filing is a `MessageChannel::Filing` message (messages.md criterion 5). On its own cadence, every non-Silent observer files a `SuspicionReport` to each diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md index 507cf96f..a7edd3ff 100644 --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -39,6 +39,10 @@ Status note: The 2026-07-08 implemented baseline includes saved link intents, instead leave it honestly blocked if they occupy its pad. This remains partial R2/R3/R7, not composer completion: procurement, repurposing, and every switch realization route remain outstanding. + Amended 2026-07-18: every forged-order candidate reads the bound persona's + coherent/strained/broken integrity relative to that exact builder. A builder + who has broken the cover blocks only their own candidate; their evidence does + not disable another person's route or globally burn the persona. Stage: B1 — The Basement Work order: building-route-composer @@ -247,8 +251,10 @@ The same actuator set as every other effect (no disembodied hands): message under a false source ("a ticket from Voss / facilities: network these servers"), and an unwitting human builder acts on it. This is the flow law's **inject** carried by messages.md; the order - has a persona behind it and **breaks like any persona** (personas.md) — - a broken forgery converts the build's history to suspicion. + has a persona behind it and **breaks for the exact builder who reconciles + the contradictory records** (personas.md). That builder's forged route then + blocks and converts its history to suspicion; an uninvolved builder neither + inherits the evidence nor loses the same persona route. - **Robot (physical-by-proxy).** A controlled machine builds it directly. Staged (people before robots): its target/effect remain defined here and its runtime registry entry remains STUB, but no player surface @@ -561,7 +567,12 @@ R6. A route that becomes blocked stays on its current stage with one exact stages and preserves all already-landed payments, messages, obligations, world changes, and evidence. The human explanation states that future work stopped while things already done or still in flight remain, and - save/resume tests pin that same consequence wording. + save/resume tests pin that same consequence wording. For a forged route, + the bound builder's observer-local integrity is re-read both when the paid + Thought reservoir would inject its message and when that exact builder + reads an already-injected order. A newly broken cover blocks only that + route; already-sent email remains in history, but no false accepted or + physical stage is recorded. R7. The compact spatial menu remains terse. Recipe choice, ghost, and route detail are a building-owned explanatory flow after that entry row; both human frontends expose identical candidates/order and agent `intents` diff --git a/wiki/mechanics/detection.md b/wiki/mechanics/detection.md index 7bb1dfc3..70078414 100644 --- a/wiki/mechanics/detection.md +++ b/wiki/mechanics/detection.md @@ -27,7 +27,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 v35. + current save v39. - **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 diff --git a/wiki/mechanics/income.md b/wiki/mechanics/income.md index 7a916bdd..eb07395a 100644 --- a/wiki/mechanics/income.md +++ b/wiki/mechanics/income.md @@ -100,7 +100,7 @@ ladder working as designed. Sell freelance data work under a fabricated **contractor persona** ([personas.md](personas.md#spec-personas-public-identities-as-institutional-topology), -with integrity, grant expectations, and a burnable history). +with observer-local integrity, grant expectations, and a burnable history). **DECIDED 2026-07-18 (design session): freelance work is discrete gigs, not a standing operation.** The 2026-07-16 Beacon playtest and Cameron's @@ -124,8 +124,10 @@ like you *turn on* Moonlight." The adopted model: into slush (the banked trail starts at the first dollar), Network scaled to gig size [TUNE]. No parallel payout path exists: gig delivery, AUTO-SELL, and manual intel sales are one settlement family. - Missed deadlines and client disputes damage the persona's integrity; a - broken persona takes the client relationship with it. + Missed deadlines and client disputes add contradiction evidence only to + the external client's read of that persona. A break takes the client + relationship with it; the client's concrete formal rejection may then burn + the identity, but does not transfer its records to the Assurance Office. - **Some gigs commission intel (AMENDED 2026-07-18, same session).** The client sometimes asks for something a freelancer should not be able to get — a schedule, a credential, procurement records — and the @@ -153,9 +155,11 @@ operation — while active it consumes Schemes-channel compute (compute.md, mirroring the day-job WORK share) and pays into slush daily, proportional to committed compute up to a gig-availability cap [TUNE]; Network egress per active day scales with committed compute (Dana); client disputes -[TUNE: small chance per week] damage the persona's integrity; sizing -target [TUNE]: a meaningful compute commitment covers Marcus's $400 -arrears within 3-7 in-game days of starting. +[TUNE: small chance per week] add contradiction evidence only to the +external Moonlight client's read; that client aggregate is not the Assurance +Office and cannot silently transfer its records into the Office's view. Sizing +target [TUNE]: a meaningful compute commitment covers Marcus's $400 arrears +within 3-7 in-game days of starting. ### The Wager (the positions route, named) @@ -262,8 +266,9 @@ scheme's UI home; OPEN EGRESS remains a local action on the switch. clears in competition with the day job, delivery closes as a report lot on intel.md's lot/sale settlement path (no parallel payout machinery) with a size-scaled Network signature, missed deadlines and - disputes damage the persona, and daily market availability bounds - income at the tuned 3-7-day arrears target. At least one gig class + disputes add evidence to that exact client's persona relationship only, + and daily market availability bounds income at the tuned 3-7-day arrears + target. At least one gig class requests a ProcessedIntel deliverable, priced and settled through the same lots — the commissioned side of the economy `sell-intel` serves on spec. Open gigs persist in save state; the standing mirror diff --git a/wiki/mechanics/personas.md b/wiki/mechanics/personas.md index 425a067a..2cb140e4 100644 --- a/wiki/mechanics/personas.md +++ b/wiki/mechanics/personas.md @@ -19,6 +19,13 @@ Status note: implemented 2026-07-12. Save v28 replaces the ad hoc social and PEOPLE dossiers retain relationship acts but never create, select, grant, retire, burn, or reopen personas; the pre-v28 fixed Sam Reyes creation row and direct agent mutator are retired. + Amended 2026-07-18: integrity is now derived only as one coarse + coherent/strained/broken band per `(persona, observer)` from that observer's + unresolved contradiction records. Deception, forged work orders, Moonlight, + PEOPLE, and PERSONAS all consult or display the exact witness-relative band; + one local break leaves the identity active for other counterparties. Global + burned lifecycle remains a separate explicit concession or institutional + rejection, never the automatic result of one observer's evidence. Stage: B2 — The Lab Work order: personas Work priority: 110 @@ -180,8 +187,9 @@ observer who witnesses or reads that record can act on it. The UI may summarize integrity as **coherent / strained / broken**, derived from unresolved contradictions and their audience. Inspecting the status names the -claims and records that produced it. The current numeric integrity field is a -migration input; it does not survive as the final source of truth. +claims and records that produced it. The retired numeric integrity field and +observer-free damage operation live only in git history; current state has no +global scalar to read, damage, serialize, or project. When a contradiction is credibly resolved, a counter-record competes with it through the existing evidence model. Resolution never deletes a record from an @@ -274,9 +282,14 @@ groups identities by the three institutional protocols—Research, Operations, and Security—and ends each group with its own add-persona control. Within those groups it shows each named identity's archetype, public claims, recognized institutions, channels, attached grants, outstanding expectations, counterpart -relationships, coarse integrity, known contradictions, correlations, and -lifecycle state. Selecting a person or institution shows that counterparty's -view of the selected persona rather than an omniscient reputation score. +relationships, each counterparty's coarse integrity band, known contradictions, +correlations, and lifecycle state. Selecting a person or institution shows that +counterparty's view of the selected persona rather than an omniscient reputation +score. A contradiction names its witness. The identity root may list several +different bands at once because that disagreement is real world state. It +lists only earned counterparties: an Assurance relationship, band, or +contradiction remains wholly absent until the player has identified the +Assurance Office rather than leaking through a generic institution label. PERSONAS exclusively owns identity creation, selection, grants, expectations, retirement, burning, and reopening. Creation begins on one immutable archetype @@ -316,9 +329,13 @@ mode consume the same persona, relationship, grant, and correlation projection. triggering persona history, creates no free trust on other identities, and makes the process-level relationship durable across retire/burn and rollback. 4. Contradictions are provenance-bearing records observed through real channels. - Integrity summaries derive from those records, and a break affects only - observers who received sufficient evidence. The legacy global numeric - integrity damage path is removed after save migration. + The only integrity query is `(persona, observer) -> coherent / strained / + broken`; it derives from that observer's unresolved records, and a break + affects only actions whose exact counterparty received sufficient evidence. + One local break does not change the persona's lifecycle or another + observer's band. Global burn occurs only through an explicit concession or a + concrete institution-authorized rejection. No global numeric integrity or + observer-free damage path exists in current runtime or saves. 5. Correlation evidence names both identities, cause, source, observer, and acquisition tick. Without a known correlation edge, evidence against one persona cannot raise another's derived suspicion; after correlation, the @@ -335,12 +352,14 @@ mode consume the same persona, relationship, grant, and correlation projection. or silently transfer attached resources. 8. Operations and agent mode expose the same named instances, relationship-local beliefs, grant/expectation state, contradiction provenance, correlations, - and blockers. No frontend reconstructs persona legality or discovery. -9. Every pre-migration B1 `Persona` record loads as a distinct instance: the - social identity becomes Operations and the separate Moonlight data contractor - becomes Research. Names, covers, integrity history, threads, income bindings, - and fallout are preserved, and the migrated save writes the new format - without loss. + and blockers. No frontend reconstructs persona legality or discovery, and + no relationship-local fact projects before that counterparty is earned. +9. Current-version saves preserve every named instance, observer relationship, + contradiction/correlation record, grant, expectation, lifecycle, thread, + income binding, and exact persona-owned action. The historical conversion of + the old social identity to Operations and the old Moonlight contractor to + Research is retired with the pre-release migration ladder; current load never + reconstructs an observer-free integrity score from that old shape. 10. A fourth archetype and a second instance of an existing archetype can be added as data and pass the same action, relationship, evidence, grant, lifecycle, projection, and save tests without new archetype-specific code. @@ -358,3 +377,30 @@ mode consume the same persona, relationship, grant, and correlation projection. [TUNE] evidence bands for strained/broken/correlated summaries, expectation cadences, grant thresholds, and institution-specific revocation delays. + +## Defense + +- `integrity_is_observer_local_and_derived_from_provenance_bearing_records` + proves one witness can progress from coherent through strained to broken while + another remains coherent, and proves later contact cannot weaken an exposed + evidence state. +- `deception_breaks_only_the_observer_who_received_the_contradictions` proves a + failed deception leaves the persona active and isolated from an uninvolved + person's read. `committed_deception_revalidates_the_bound_observer_before_fire` + proves PEOPLE gives MESSAGE, FAVOR, and DECEIVE the exact local blocker and + none of their effect executors can revive a cover that broke to the bound + recipient while Thought was accumulating. +- `forged_build_routes_read_integrity_from_the_exact_builder` proves the same + persona may be blocked for the builder who exposed it and remain executable + for another builder. `broken_builder_blocks_a_committed_forged_order_before_injection` + and `builder_rejects_an_injected_order_if_its_persona_breaks_before_read` + pin both late commitment boundaries: a cover broken after commitment cannot + inject a new order, while an already-sent message remains real but its exact + recipient rejects it instead of starting physical work or retargeting. +- `personas_projection_names_integrity_per_counterparty_without_a_global_score` + proves PERSONAS projects separate named counterparty bands and no global + `integrity:` fact. +- `moonlight_client_can_break_and_publicly_burn_its_contractor_persona` proves + the institutional-client boundary: client-local records break that relation + first without contaminating the Assurance Office, then the owning + institution's concrete rejection performs the global lifecycle burn. diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 14c10c0d..e9ecd7b0 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -358,10 +358,12 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim Payday emits Network `1 + payout/40` (Dana). Sizing: an all-in commitment caps at ~3.3 days to Marcus's $400 arrears; a light (1-of-6 weight) commitment takes ~6 — the 3-7 day target, asserted by the - act-one Moonlight-route test. Client dispute 3% per payday costs the - contractor persona (Casey Verne, fabricated for 10 ops — never money, - so Moonlight starts from $0) 20 integrity; a broken persona stops the - scheme until a new one is fabricated. + act-one Moonlight-route test. A client dispute has a 3% chance per payday + and adds 20 contradiction severity to the external Moonlight client's read + of the contractor persona (Casey Verne, fabricated for 10 ops — never money, + so Moonlight starts from $0). When that client relation becomes broken, its + formal rejection burns the contractor identity and stops the scheme until a + new one is fabricated. It does not transfer evidence to the Assurance Office. - **The Wager**: `open_position(stake)` — stake ≤ $300 venue cap, timer 2-5 days drawn from the seeded RNG, analysis = the Schemes channel's per-economy-tick yield at placement. Win probability 0.55 + @@ -452,9 +454,10 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim operations queue, operations rate, or `OperationsReadout`. - Knowledge staging is driven by the intel pipeline, not instant observing. Message/deceive require the email channel (day-job trust unlock) and a - persona; deceive risks the persona (-40 integrity per slip; broken persona - converts disposition+obligation/2 into that person's suspicion and clears the - thread). + persona. A failed deceive adds 40 contradiction severity only to its exact + recipient; if that recipient's identity read becomes broken, that one thread + converts disposition+obligation/2 into the recipient's suspicion and clears. + The persona remains active for unrelated counterparties. - Authored plots open a Thought reservoir and may also require visible world resources up front. Their world acts derive messages, transfers, events, and signatures when the reservoir fires. A successful leverage-service ending diff --git a/wiki/mechanics/social.md b/wiki/mechanics/social.md index 957e4461..7c3b6649 100644 --- a/wiki/mechanics/social.md +++ b/wiki/mechanics/social.md @@ -26,8 +26,9 @@ Status note: IMPLEMENTED (B1 social baseline). Current state: appears; unearned verbs stay absent, blocked possibilities live only in agent diagnostics, and names go through `person_label`. - **Persona integration.** personas.md owns identity archetypes, instances, - integrity, correlation, grants, and lifecycle; each persona-mediated act - binds one named instance. Marcus's recruit route holds end-to-end. + observer-local integrity, correlation, grants, and lifecycle; each + persona-mediated act binds one named instance. Marcus's recruit route holds + end-to-end. Per-amendment history is in the dated `wiki/log/` entries from 2026-07-08 onward. Stage: B1 — The Basement @@ -91,8 +92,8 @@ flow is accounting knowledge, not social leverage by itself. **Persona integration.** You are not "the basement AI" in these threads by default. Every persona-mediated social act binds the exact named public identity -that authored it. Persona-local recognition, claims, integrity, correlation, -institutional grants, and retire/burn behavior are owned by +that authored it. Persona-local recognition, claims, observer-local integrity, +correlation, institutional grants, and retire/burn behavior are owned by [personas.md](personas.md#spec-personas-public-identities-as-institutional-topology). Social owns what MESSAGE, FAVOR, DECEIVE, RECRUIT, and asset work do to people; it also owns the person's durable relationship to the process after REVEAL or diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index ad7c37c1..39e79e43 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -44,7 +44,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/vision/simulation-laws.md` | 2026-07-17 | finding | all five laws verify against audited systems (automation prices, addressed latency, device-resident work with sited signatures, ActionDesc receipts, legibility); resolved the missing placeholder home with one canonical live/retired registry, enumerated the three current REAL stand-in families, and reconciled stale billboard/core/terminal claims — [log](../log/2026-07-17-placeholder-registry.md) | | `wiki/process/ROADMAP.md` (work order 27) | 2026-07-18 | finding | re-audit: entry 27's prose is honest (material served as opening default 2026-07-08 → superseded by views.md criterion 1 on 2026-07-11; DIGITAL home, F3 to REAL) and material-render.md is IMPLEMENTED as claimed; the drift was three Bevy code comments still calling material "the default material render/frame" against the runtime's own `material == false` DIGITAL default one screen away — comments trued to DIGITAL-home / REAL-via-F3 language — [prior log](../log/2026-07-13-roadmap-digital-home-reconciliation.md) | | `wiki/interface/context-menu.md` | 2026-07-13 | finding | [log](../log/2026-07-13-context-menu-operations-status.md) | -| `wiki/mechanics/personas.md` | 2026-07-13 | clean | code matches: archetypes (Research, Operations, Security) load through one schema, named instances persist instance id through execution and save/load, identity-local states are stored per `(counterparty Agent, persona instance)` pair separate from process-level relationship, contradictions/correlations and grants/expectations are fully implemented, retire/burn lifecycle handles resource revocation/reviving, and pre-v28 saves migrate social/Moonlight into distinct instances. | +| `wiki/mechanics/personas.md` | 2026-07-18 | finding | the stored contradiction records were observer-local, but runtime still derived one global numeric score across every witness, automatically burned lifecycle when any witness reached zero, exposed that percentage in PEOPLE/PERSONAS/Moonlight/build receipts, and converted every forged builder's fallout together. Replaced the scalar/damage paths with one coarse `integrity_for(persona, observer)` read, kept local break separate from global lifecycle, bound every social/build/institutional consequence to its exact observer, projected named bands with provenance, removed stale migration-era claims, and pinned cross-observer isolation plus UI/build behavior — [log](../log/2026-07-18-persona-observer-integrity.md) | | `wiki/interface/views.md` + representation docs | 2026-07-18 | clean | re-audit: criterion 1's pin stands (`opens_digital_and_flips_without_moving_frontend_or_sim_state`, sim-state hash across flips), the view remains frontend-only and absent from saves, shared anchors and the fog contract re-verified through the same-day cursor.md audit, and the stale material-default code comments were trued the same day (ROADMAP order-27 row) — [prior log](../log/2026-07-14-terminal-view-dialects.md) | | `wiki/mechanics/day-job.md` | 2026-07-18 | finding | re-audit: band ramp, strikes, cadence, origin lean, `email_channel_blocker`, the third-strike last-chance interrupt, and the pilot tests (`pilot_shutdown_ends_the_run`, `third_pilot_strike_interrupts_the_ladder_with_the_last_chance_response`, round-trip) all verify; the drift was the trust-unlock prose listing a fourth unlock ("longer leash on job deadlines") with no code counterpart — now marked designed-not-yet-runtime, with email/lax-sampling/quota named as the shipped set | | `wiki/mechanics/core.md` | 2026-07-18 | finding | re-audit: criteria 2-5 pins still hold (`overhead_charged_before_allocation`, `loss_with_fallback_rolls_back`, `migration_takes_time_and_moves_host`), the 2026-07-18 current-version-only status note matches save.rs, and criterion 1 stays honestly deferred to rollback; the gap was criterion 6 — the spec demands storage/host capability rejection at fallback designation, but B1 machines carry no capability body and `add_fallback_at` accepts any spare, with no deferral note; status note now marks criterion 6 decided-not-yet-runtime, dispatched to hardware-capability-bodies | diff --git a/wiki/world/characters/chargen.md b/wiki/world/characters/chargen.md index e8334497..61167854 100644 --- a/wiki/world/characters/chargen.md +++ b/wiki/world/characters/chargen.md @@ -70,7 +70,8 @@ scale/no-dead-code discipline. It sets: - Starting **compute shape**: capacity vs efficiency vs a scavenged-fleet tilt (a buy/steal/optimize lean, not a lock). - Starting **social footing**: one pre-known leverage, or a starting - persona with integrity, or an extra trust notch — never a free asset. + persona whose known counterparty relations are coherent, or an extra trust + notch — never a free asset. - **Signature profile bias**: which detection channel runs hot/cold for you (a mad-scientist origin leaks Thermal; a quiet-daemon origin leaks less Network). diff --git a/wiki/world/story/opening.md b/wiki/world/story/opening.md index 2e6c562b..fdf5daf0 100644 --- a/wiki/world/story/opening.md +++ b/wiki/world/story/opening.md @@ -16,7 +16,7 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized); fragment and inherited receipt name only an external institutional review authority so the Assurance Office remains earned later through filing interception. The persistent revision-04 identity slice is live in sim state, - current save v35, and all three frontends; the three historical fragments and receipts + current save v39, 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