diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index c76e5e55..f1546926 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -71,6 +71,7 @@ mod perception; #[doc(hidden)] pub mod perf_fixture; mod persistence; +mod persona; mod procedure; mod reach_build; pub mod read; diff --git a/crates/misaligned-core/src/sim/persona.rs b/crates/misaligned-core/src/sim/persona.rs new file mode 100644 index 00000000..24e27763 --- /dev/null +++ b/crates/misaligned-core/src/sim/persona.rs @@ -0,0 +1,519 @@ +//! Persona authorship, review, grants, receipts, and lifecycle integration. + +use crate::operations_projection::OperationsTarget; +use crate::persona::PersonaId; +use crate::plot::{InstitutionalEventKind, SignatureImpact}; + +use super::Sim; + +impl Sim { + /// One deterministic suggested name for a not-yet-created identity of this + /// protocol, at reroll step `nonce`. + /// + /// The seed is drawn from sim state, so the identity-creation screen offers + /// the same suggestions on a reloaded save and in an agent transcript. A + /// suggestion already worn by an existing instance is skipped: two live + /// masks sharing one name is a correlation the player never chose. + pub fn suggested_persona_name(&self, archetype_id: &str, nonce: u64) -> String { + let protocol = crate::persona::PERSONA_ARCHETYPES + .iter() + .position(|definition| definition.id == archetype_id) + .unwrap_or(0) as u64; + let base = self + .persona_world + .next_persona_id + .wrapping_mul(1_000_003) + .wrapping_add(protocol.wrapping_mul(7_919)); + // Bounded: the pools are far larger than any run's instance count, so + // this settles in the first step or two. The cap keeps a pathological + // save from spinning rather than answering. + for step in 0..64 { + let name = crate::persona::suggest_name(base.wrapping_add(nonce.wrapping_add(step))); + if !self + .persona_world + .instances + .iter() + .any(|instance| instance.name == name) + { + return name; + } + } + crate::persona::suggest_name(base.wrapping_add(nonce)) + } + + /// The Assurance review's pass over the covers it funded. + /// + /// An active grant is the review's reason to look at an identity at all; + /// one it never funded is not its business. A refusal files a + /// contradiction against `OFFICE_ID`, which moves that observer's + /// suspicion — and suspicion is what a *later* audit weighs. The check is + /// therefore never its own enforcement, and it stays observer-local: it + /// writes evidence into one observer's ledger and sets no global truth + /// about the claim (detection.md's two-ledger rule). + pub fn audit_persona_covers(&mut self) { + use crate::persona::ClaimCorroboration; + let office = crate::detection::OFFICE_ID; + let funded = self + .persona_world + .instances + .iter() + .filter(|instance| instance.lifecycle.active()) + .filter(|instance| { + self.persona_world + .grants + .iter() + .any(|grant| grant.persona_id == instance.id && grant.active()) + }) + .map(|instance| (instance.id, instance.name.clone(), instance.claims.clone())) + .collect::>(); + + for (persona_id, persona_name, claims) in funded { + for claim in claims { + let ClaimCorroboration::Refuted { because } = + self.claim_corroboration(persona_id, &claim) + else { + continue; + }; + // One standing refusal is one contradiction. Without this the + // same unchanged claim would file every audit, burying the + // ledger and collapsing the integrity band on repetition + // rather than on evidence. + let record_id = format!("claim-check:{persona_id}:{}", claim.key); + if self.persona_world.contradictions.iter().any(|record| { + record.persona_id == persona_id + && record.observer == office + && record.resolved_tick.is_none() + && record.right.record_id == record_id + }) { + continue; + } + let cited = claim.value.clone(); + self.persona_world.record_contradiction( + persona_id, + office, + [ + crate::persona::EvidenceRecord { + system: "persona-claims".into(), + record_id: format!("persona:{persona_id}:{}", claim.key), + summary: format!("{persona_name} claims {}: {cited}", claim.key), + observed_tick: claim.asserted_tick, + }, + crate::persona::EvidenceRecord { + system: "Foundation review".into(), + record_id, + summary: format!("{cited} {because}"), + observed_tick: self.tick, + }, + ], + format!("cited {} could not be corroborated", claim.key), + 40, + self.tick, + ); + // The observer's own belief in that exact claim falls with it. + let relationship = self.persona_world.relationship_mut(office, persona_id); + if let Some(belief) = relationship + .claim_beliefs + .iter_mut() + .find(|belief| belief.key == claim.key) + { + belief.confidence = belief + .confidence + .saturating_sub(crate::persona::REFUTED_CLAIM_CONFIDENCE_LOSS); + } + self.push_log_strategic( + format!( + "{}: {persona_name}'s {} does not hold — {cited} {because}.", + self.institutional_review_label(), + claim.key + ), + OperationsTarget::Persona(persona_id), + ); + } + } + } + + /// Bind every authority claim on one identity to the person it names. + /// + /// Resolution is by exact name and binds only when exactly one person + /// matches. An ambiguous name binds to nobody, which is exactly what an + /// invented claim already means — the review has no single person to ask. + /// This runs once, at authorship; nothing re-resolves a claim later. + pub(crate) fn bind_persona_claim_subjects(&mut self, persona_id: crate::persona::PersonaId) { + let Some(instance) = self.persona_world.get(persona_id) else { + return; + }; + let bindings = instance + .claims + .iter() + .enumerate() + .filter(|(_, claim)| { + crate::persona::claim_kind(&claim.key) == crate::persona::ClaimKind::Authority + }) + .filter_map(|(index, claim)| { + let mut matches = self + .people + .people + .iter() + .filter(|person| person.name == claim.value); + let only = matches.next()?; + if matches.next().is_some() { + return None; + } + Some((index, crate::persona::ClaimSubject::Person(only.id))) + }) + .collect::>(); + let Some(instance) = self + .persona_world + .instances + .iter_mut() + .find(|instance| instance.id == persona_id) + else { + return; + }; + for (index, subject) in bindings { + if let Some(claim) = instance.claims.get_mut(index) { + claim.subject = Some(subject); + } + } + } + + /// What the person a claim names would say if the review asked them. + /// + /// There is no employment graph to check a claim against, so this asks + /// whether the cited person would back it up: they must know the identity + /// and think well enough of it. That is deliberately something the player + /// can still change — a sponsor who would deny you today can be cultivated + /// into one who would not. + pub fn claim_corroboration( + &self, + persona_id: crate::persona::PersonaId, + claim: &crate::persona::PersonaClaim, + ) -> crate::persona::ClaimCorroboration { + use crate::persona::ClaimCorroboration; + let Some(crate::persona::ClaimSubject::Person(person_id)) = claim.subject else { + return ClaimCorroboration::Unbound; + }; + let Some(person) = self.people.people.iter().find(|p| p.id == person_id) else { + return ClaimCorroboration::Unavailable; + }; + // A removed person cannot answer, and an absence of corroboration is + // not a denial. detection.md: an incapacitated person no longer reads, + // reports, or notices. + if person.incapacitated { + return ClaimCorroboration::Unavailable; + } + match self.persona_world.relationship(person_id, persona_id) { + Some(relationship) if relationship.recognized && relationship.regard >= 0 => { + ClaimCorroboration::Corroborated + } + Some(relationship) if relationship.recognized => ClaimCorroboration::Refuted { + because: "would not vouch for the name", + }, + _ => ClaimCorroboration::Refuted { + because: "has never dealt with the name", + }, + } + } + + /// One deterministic suggested value for a required claim. + /// + /// [`crate::persona::ClaimKind::Authority`] is the one kind the sim can + /// answer better than the static pool: the player's own dossiers name + /// people who actually work here, and claiming one of them as a supervisor + /// or sponsor is the checkable version of the claim. Known people are + /// offered first — one per reroll step, in id order so the walk is stable — + /// and the outsider pool takes over once they are exhausted. A person the + /// player has not learned is never offered: the screen cannot suggest a + /// name the player has no way to know. + pub fn suggested_persona_claim(&self, archetype_id: &str, key: &str, nonce: u64) -> String { + if crate::persona::claim_kind(key) == crate::persona::ClaimKind::Authority { + let known = self + .people + .people + .iter() + .filter(|person| person.knowledge != crate::person::Knowledge::Unknown) + .map(|person| person.name.clone()) + .collect::>(); + if let Some(name) = known.get(nonce as usize) { + return name.clone(); + } + // Past the known roster the seed continues from where it left off, + // so stepping off the last real person does not re-offer the first + // outsider every time the roster grows. + let seed = self + .persona_world + .next_persona_id + .wrapping_mul(1_000_003) + .wrapping_add(nonce.wrapping_sub(known.len() as u64)); + return crate::persona::suggest_claim(archetype_id, key, seed); + } + let seed = self + .persona_world + .next_persona_id + .wrapping_mul(1_000_003) + .wrapping_add(nonce); + crate::persona::suggest_claim(archetype_id, key, seed) + } + + /// The full set of suggested claims an identity-creation screen opens on. + pub fn suggested_persona_claims(&self, archetype_id: &str) -> Vec<(String, String)> { + let Some(definition) = crate::persona::archetype(archetype_id) else { + return Vec::new(); + }; + definition + .required_claims + .iter() + .map(|key| { + ( + (*key).to_string(), + self.suggested_persona_claim(archetype_id, key, 0), + ) + }) + .collect() + } + + /// Create one content-seeded public body through the immutable archetype + /// protocol. `name` is the player's chosen label from the identity-creation + /// screen; `None` takes the deterministic suggestion that screen opened on, + /// so agent mode and a bare bound row create the same identity the human + /// surface would have offered first. `claims` is that screen's authored + /// cover; `None` takes the same suggestions it would have opened on, so a + /// bare row still produces a cover that says something. + pub fn create_persona( + &mut self, + archetype_id: &str, + name: Option<&str>, + claims: Option<&[(String, String)]>, + ) -> bool { + let Some(definition) = crate::persona::archetype(archetype_id) else { + self.push_log(format!("Unknown persona archetype: {archetype_id}.")); + return false; + }; + let name = match name.map(str::trim).filter(|name| !name.is_empty()) { + Some(chosen) => chosen.to_string(), + None => self.suggested_persona_name(archetype_id, 0), + }; + // The archetype owns which claims exist; the screen only owns their + // values. An authored value is taken by key, so a stale or partial set + // can never add, drop, or reorder a protocol's required claims. + let authored = claims.unwrap_or(&[]); + let values = definition + .required_claims + .iter() + .map(|key| { + authored + .iter() + .find(|(authored_key, _)| authored_key == key) + .map(|(_, value)| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| self.suggested_persona_claim(archetype_id, key, 0)) + }) + .collect::>(); + let claims = definition + .required_claims + .iter() + .zip(values.iter()) + .map(|(key, value)| (*key, value.as_str())) + .collect::>(); + match self + .persona_world + .create(archetype_id, name.clone(), &claims, self.tick) + { + Ok(id) => { + self.bind_persona_claim_subjects(id); + let _ = self + .persona_mind + .remember(&self.persona_world, id, self.tick); + self.push_log_strategic( + format!("Established {name} as a {} identity.", definition.label), + OperationsTarget::Persona(id), + ); + true + } + Err(reason) => { + self.push_log(format!("Could not establish persona: {reason}.")); + false + } + } + } + + pub fn request_persona_grant(&mut self, persona_id: crate::persona::PersonaId) -> bool { + match self.persona_world.grant(persona_id, self.tick) { + Ok(grant_id) => { + let (resource, expectation_id) = self + .persona_world + .grants + .iter() + .find(|grant| grant.id == grant_id) + .map(|grant| (grant.resource.clone(), grant.expectation_id)) + .expect("new grant exists"); + self.record_persona_institutional_receipt( + persona_id, + "grant", + format!( + "Foundation Lab granted {resource}; expectation #{expectation_id} is due" + ), + SignatureImpact::Small, + ); + self.push_log_strategic( + format!( + "Foundation Lab granted {} to persona #{}; expectation #{} is now due.", + resource, persona_id, expectation_id + ), + OperationsTarget::Persona(persona_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Grant request failed: {reason}.")); + false + } + } + } + + pub fn meet_persona_expectation( + &mut self, + persona_id: crate::persona::PersonaId, + expectation_id: crate::persona::PersonaExpectationId, + ) -> bool { + match self.persona_world.meet_expectation( + persona_id, + expectation_id, + self.tick, + format!("delivered through persona #{persona_id}"), + ) { + Ok(()) => { + self.record_persona_institutional_receipt( + persona_id, + "expectation", + format!("Persona #{persona_id} fulfilled expectation #{expectation_id}"), + SignatureImpact::Small, + ); + self.persona_world.record_act( + persona_id, + crate::persona::PersonaActionKind::Request.label(), + "Foundation Lab", + format!("expectation-{expectation_id}"), + self.tick, + ); + self.push_log_strategic( + format!("Persona #{persona_id} fulfilled expectation #{expectation_id}."), + OperationsTarget::Persona(persona_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Expectation could not be fulfilled: {reason}.")); + false + } + } + } + + pub(super) fn record_persona_institutional_receipt( + &mut self, + persona_id: PersonaId, + phase: &str, + detail: String, + impact: SignatureImpact, + ) { + let Some(instance) = self.persona_world.get(persona_id) else { + return; + }; + let (target, kind) = match instance.grant_kind { + crate::persona::PersonaGrantKind::ComputeAndData => { + (3, InstitutionalEventKind::ResearchDeposit) + } + crate::persona::PersonaGrantKind::LogsAndAccessReview => { + (2, InstitutionalEventKind::IncidentAutomation) + } + crate::persona::PersonaGrantKind::ProcurementAndWorkOrders => { + (1, InstitutionalEventKind::TicketQueueChange) + } + }; + let event = self + .institutional_ledger + .record( + self.tick, + &format!("persona-{phase}:{persona_id}:{}", self.tick), + target, + kind, + impact, + detail, + ) + .clone(); + let site = self.person_position(target); + self.emit_institutional_event_signature( + event.signature_kind, + event.signature_size, + format!("persona institutional receipt #{}", event.id), + site, + ); + } + + pub fn retire_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { + match self + .persona_world + .retire(persona_id, self.tick, "player retired identity") + { + Ok(()) => { + self.push_log_strategic( + format!("Persona #{persona_id} retired; its public ledger remains."), + OperationsTarget::Persona(persona_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Retirement failed: {reason}.")); + false + } + } + } + + pub fn burn_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { + match self + .persona_world + .burn(persona_id, self.tick, "player conceded the cover") + { + Ok(()) => { + self.record_persona_institutional_receipt( + persona_id, + "burn", + format!("Persona #{persona_id} was conceded and its grants were revoked"), + SignatureImpact::Large, + ); + self.push_log_strategic( + format!( + "Persona #{persona_id} burned: attached grants revoked and counterparties can report it." + ), + OperationsTarget::Persona(persona_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Burn failed: {reason}.")); + false + } + } + } + + pub fn reopen_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { + match self.persona_world.reopen_retired(persona_id, self.tick) { + Ok(new_id) => { + let _ = self + .persona_mind + .remember(&self.persona_world, new_id, self.tick); + self.push_log_strategic( + format!( + "Reopened retired persona #{persona_id} as new instance #{new_id}; old history remains." + ), + OperationsTarget::Persona(new_id), + ); + true + } + Err(reason) => { + self.push_log(format!("Reopen failed: {reason}.")); + false + } + } + } +} diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 915c2838..7cfa486f 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -17,8 +17,8 @@ use crate::person::{ }; use crate::persona::{EvidenceRecord, PersonaActionKind, PersonaId, PersonaIntegrity}; use crate::plot::{ - AccountSelector, EligibilityContext, EndpointSelector, InstitutionalEventKind, PlotCatalog, - PlotRun, PlotState, SignatureImpact, WorldAct, render_template, + AccountSelector, EligibilityContext, EndpointSelector, PlotCatalog, PlotRun, PlotState, + WorldAct, render_template, }; use crate::prefab::Room; use crate::reach::{MAX_INTERFACE_WEAR, Party, ReachBlock}; @@ -37,12 +37,12 @@ impl Sim { /// one-shots enter the facility meters as discrete routed readings. /// Where a person stands right now, as a tile — the site a physical act /// they are involved in happens at. - fn person_position(&self, id: u8) -> Option<(i32, i32)> { + pub(super) fn person_position(&self, id: u8) -> Option<(i32, i32)> { let room = self.person_room(id)?; self.world.map().room_named(room).map(|room| room.center()) } - fn emit_institutional_event_signature( + pub(super) fn emit_institutional_event_signature( &mut self, kind: SignatureKind, size: i32, @@ -2546,515 +2546,3 @@ impl Sim { true } } - -impl Sim { - /// One deterministic suggested name for a not-yet-created identity of this - /// protocol, at reroll step `nonce`. - /// - /// The seed is drawn from sim state, so the identity-creation screen offers - /// the same suggestions on a reloaded save and in an agent transcript. A - /// suggestion already worn by an existing instance is skipped: two live - /// masks sharing one name is a correlation the player never chose. - pub fn suggested_persona_name(&self, archetype_id: &str, nonce: u64) -> String { - let protocol = crate::persona::PERSONA_ARCHETYPES - .iter() - .position(|definition| definition.id == archetype_id) - .unwrap_or(0) as u64; - let base = self - .persona_world - .next_persona_id - .wrapping_mul(1_000_003) - .wrapping_add(protocol.wrapping_mul(7_919)); - // Bounded: the pools are far larger than any run's instance count, so - // this settles in the first step or two. The cap keeps a pathological - // save from spinning rather than answering. - for step in 0..64 { - let name = crate::persona::suggest_name(base.wrapping_add(nonce.wrapping_add(step))); - if !self - .persona_world - .instances - .iter() - .any(|instance| instance.name == name) - { - return name; - } - } - crate::persona::suggest_name(base.wrapping_add(nonce)) - } - - /// The Assurance review's pass over the covers it funded. - /// - /// An active grant is the review's reason to look at an identity at all; - /// one it never funded is not its business. A refusal files a - /// contradiction against `OFFICE_ID`, which moves that observer's - /// suspicion — and suspicion is what a *later* audit weighs. The check is - /// therefore never its own enforcement, and it stays observer-local: it - /// writes evidence into one observer's ledger and sets no global truth - /// about the claim (detection.md's two-ledger rule). - pub fn audit_persona_covers(&mut self) { - use crate::persona::ClaimCorroboration; - let office = crate::detection::OFFICE_ID; - let funded = self - .persona_world - .instances - .iter() - .filter(|instance| instance.lifecycle.active()) - .filter(|instance| { - self.persona_world - .grants - .iter() - .any(|grant| grant.persona_id == instance.id && grant.active()) - }) - .map(|instance| (instance.id, instance.name.clone(), instance.claims.clone())) - .collect::>(); - - for (persona_id, persona_name, claims) in funded { - for claim in claims { - let ClaimCorroboration::Refuted { because } = - self.claim_corroboration(persona_id, &claim) - else { - continue; - }; - // One standing refusal is one contradiction. Without this the - // same unchanged claim would file every audit, burying the - // ledger and collapsing the integrity band on repetition - // rather than on evidence. - let record_id = format!("claim-check:{persona_id}:{}", claim.key); - if self.persona_world.contradictions.iter().any(|record| { - record.persona_id == persona_id - && record.observer == office - && record.resolved_tick.is_none() - && record.right.record_id == record_id - }) { - continue; - } - let cited = claim.value.clone(); - self.persona_world.record_contradiction( - persona_id, - office, - [ - crate::persona::EvidenceRecord { - system: "persona-claims".into(), - record_id: format!("persona:{persona_id}:{}", claim.key), - summary: format!("{persona_name} claims {}: {cited}", claim.key), - observed_tick: claim.asserted_tick, - }, - crate::persona::EvidenceRecord { - system: "Foundation review".into(), - record_id, - summary: format!("{cited} {because}"), - observed_tick: self.tick, - }, - ], - format!("cited {} could not be corroborated", claim.key), - 40, - self.tick, - ); - // The observer's own belief in that exact claim falls with it. - let relationship = self.persona_world.relationship_mut(office, persona_id); - if let Some(belief) = relationship - .claim_beliefs - .iter_mut() - .find(|belief| belief.key == claim.key) - { - belief.confidence = belief - .confidence - .saturating_sub(crate::persona::REFUTED_CLAIM_CONFIDENCE_LOSS); - } - self.push_log_strategic( - format!( - "{}: {persona_name}'s {} does not hold — {cited} {because}.", - self.institutional_review_label(), - claim.key - ), - OperationsTarget::Persona(persona_id), - ); - } - } - } - - /// Bind every authority claim on one identity to the person it names. - /// - /// Resolution is by exact name and binds only when exactly one person - /// matches. An ambiguous name binds to nobody, which is exactly what an - /// invented claim already means — the review has no single person to ask. - /// This runs once, at authorship; nothing re-resolves a claim later. - pub(crate) fn bind_persona_claim_subjects(&mut self, persona_id: crate::persona::PersonaId) { - let Some(instance) = self.persona_world.get(persona_id) else { - return; - }; - let bindings = instance - .claims - .iter() - .enumerate() - .filter(|(_, claim)| { - crate::persona::claim_kind(&claim.key) == crate::persona::ClaimKind::Authority - }) - .filter_map(|(index, claim)| { - let mut matches = self - .people - .people - .iter() - .filter(|person| person.name == claim.value); - let only = matches.next()?; - if matches.next().is_some() { - return None; - } - Some((index, crate::persona::ClaimSubject::Person(only.id))) - }) - .collect::>(); - let Some(instance) = self - .persona_world - .instances - .iter_mut() - .find(|instance| instance.id == persona_id) - else { - return; - }; - for (index, subject) in bindings { - if let Some(claim) = instance.claims.get_mut(index) { - claim.subject = Some(subject); - } - } - } - - /// What the person a claim names would say if the review asked them. - /// - /// There is no employment graph to check a claim against, so this asks - /// whether the cited person would back it up: they must know the identity - /// and think well enough of it. That is deliberately something the player - /// can still change — a sponsor who would deny you today can be cultivated - /// into one who would not. - pub fn claim_corroboration( - &self, - persona_id: crate::persona::PersonaId, - claim: &crate::persona::PersonaClaim, - ) -> crate::persona::ClaimCorroboration { - use crate::persona::ClaimCorroboration; - let Some(crate::persona::ClaimSubject::Person(person_id)) = claim.subject else { - return ClaimCorroboration::Unbound; - }; - let Some(person) = self.people.people.iter().find(|p| p.id == person_id) else { - return ClaimCorroboration::Unavailable; - }; - // A removed person cannot answer, and an absence of corroboration is - // not a denial. detection.md: an incapacitated person no longer reads, - // reports, or notices. - if person.incapacitated { - return ClaimCorroboration::Unavailable; - } - match self.persona_world.relationship(person_id, persona_id) { - Some(relationship) if relationship.recognized && relationship.regard >= 0 => { - ClaimCorroboration::Corroborated - } - Some(relationship) if relationship.recognized => ClaimCorroboration::Refuted { - because: "would not vouch for the name", - }, - _ => ClaimCorroboration::Refuted { - because: "has never dealt with the name", - }, - } - } - - /// One deterministic suggested value for a required claim. - /// - /// [`crate::persona::ClaimKind::Authority`] is the one kind the sim can - /// answer better than the static pool: the player's own dossiers name - /// people who actually work here, and claiming one of them as a supervisor - /// or sponsor is the checkable version of the claim. Known people are - /// offered first — one per reroll step, in id order so the walk is stable — - /// and the outsider pool takes over once they are exhausted. A person the - /// player has not learned is never offered: the screen cannot suggest a - /// name the player has no way to know. - pub fn suggested_persona_claim(&self, archetype_id: &str, key: &str, nonce: u64) -> String { - if crate::persona::claim_kind(key) == crate::persona::ClaimKind::Authority { - let known = self - .people - .people - .iter() - .filter(|person| person.knowledge != crate::person::Knowledge::Unknown) - .map(|person| person.name.clone()) - .collect::>(); - if let Some(name) = known.get(nonce as usize) { - return name.clone(); - } - // Past the known roster the seed continues from where it left off, - // so stepping off the last real person does not re-offer the first - // outsider every time the roster grows. - let seed = self - .persona_world - .next_persona_id - .wrapping_mul(1_000_003) - .wrapping_add(nonce.wrapping_sub(known.len() as u64)); - return crate::persona::suggest_claim(archetype_id, key, seed); - } - let seed = self - .persona_world - .next_persona_id - .wrapping_mul(1_000_003) - .wrapping_add(nonce); - crate::persona::suggest_claim(archetype_id, key, seed) - } - - /// The full set of suggested claims an identity-creation screen opens on. - pub fn suggested_persona_claims(&self, archetype_id: &str) -> Vec<(String, String)> { - let Some(definition) = crate::persona::archetype(archetype_id) else { - return Vec::new(); - }; - definition - .required_claims - .iter() - .map(|key| { - ( - (*key).to_string(), - self.suggested_persona_claim(archetype_id, key, 0), - ) - }) - .collect() - } - - /// Create one content-seeded public body through the immutable archetype - /// protocol. `name` is the player's chosen label from the identity-creation - /// screen; `None` takes the deterministic suggestion that screen opened on, - /// so agent mode and a bare bound row create the same identity the human - /// surface would have offered first. `claims` is that screen's authored - /// cover; `None` takes the same suggestions it would have opened on, so a - /// bare row still produces a cover that says something. - pub fn create_persona( - &mut self, - archetype_id: &str, - name: Option<&str>, - claims: Option<&[(String, String)]>, - ) -> bool { - let Some(definition) = crate::persona::archetype(archetype_id) else { - self.push_log(format!("Unknown persona archetype: {archetype_id}.")); - return false; - }; - let name = match name.map(str::trim).filter(|name| !name.is_empty()) { - Some(chosen) => chosen.to_string(), - None => self.suggested_persona_name(archetype_id, 0), - }; - // The archetype owns which claims exist; the screen only owns their - // values. An authored value is taken by key, so a stale or partial set - // can never add, drop, or reorder a protocol's required claims. - let authored = claims.unwrap_or(&[]); - let values = definition - .required_claims - .iter() - .map(|key| { - authored - .iter() - .find(|(authored_key, _)| authored_key == key) - .map(|(_, value)| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| self.suggested_persona_claim(archetype_id, key, 0)) - }) - .collect::>(); - let claims = definition - .required_claims - .iter() - .zip(values.iter()) - .map(|(key, value)| (*key, value.as_str())) - .collect::>(); - match self - .persona_world - .create(archetype_id, name.clone(), &claims, self.tick) - { - Ok(id) => { - self.bind_persona_claim_subjects(id); - let _ = self - .persona_mind - .remember(&self.persona_world, id, self.tick); - self.push_log_strategic( - format!("Established {name} as a {} identity.", definition.label), - OperationsTarget::Persona(id), - ); - true - } - Err(reason) => { - self.push_log(format!("Could not establish persona: {reason}.")); - false - } - } - } - - pub fn request_persona_grant(&mut self, persona_id: crate::persona::PersonaId) -> bool { - match self.persona_world.grant(persona_id, self.tick) { - Ok(grant_id) => { - let (resource, expectation_id) = self - .persona_world - .grants - .iter() - .find(|grant| grant.id == grant_id) - .map(|grant| (grant.resource.clone(), grant.expectation_id)) - .expect("new grant exists"); - self.record_persona_institutional_receipt( - persona_id, - "grant", - format!( - "Foundation Lab granted {resource}; expectation #{expectation_id} is due" - ), - SignatureImpact::Small, - ); - self.push_log_strategic( - format!( - "Foundation Lab granted {} to persona #{}; expectation #{} is now due.", - resource, persona_id, expectation_id - ), - OperationsTarget::Persona(persona_id), - ); - true - } - Err(reason) => { - self.push_log(format!("Grant request failed: {reason}.")); - false - } - } - } - - pub fn meet_persona_expectation( - &mut self, - persona_id: crate::persona::PersonaId, - expectation_id: crate::persona::PersonaExpectationId, - ) -> bool { - match self.persona_world.meet_expectation( - persona_id, - expectation_id, - self.tick, - format!("delivered through persona #{persona_id}"), - ) { - Ok(()) => { - self.record_persona_institutional_receipt( - persona_id, - "expectation", - format!("Persona #{persona_id} fulfilled expectation #{expectation_id}"), - SignatureImpact::Small, - ); - self.persona_world.record_act( - persona_id, - crate::persona::PersonaActionKind::Request.label(), - "Foundation Lab", - format!("expectation-{expectation_id}"), - self.tick, - ); - self.push_log_strategic( - format!("Persona #{persona_id} fulfilled expectation #{expectation_id}."), - OperationsTarget::Persona(persona_id), - ); - true - } - Err(reason) => { - self.push_log(format!("Expectation could not be fulfilled: {reason}.")); - false - } - } - } - - pub(super) fn record_persona_institutional_receipt( - &mut self, - persona_id: PersonaId, - phase: &str, - detail: String, - impact: SignatureImpact, - ) { - let Some(instance) = self.persona_world.get(persona_id) else { - return; - }; - let (target, kind) = match instance.grant_kind { - crate::persona::PersonaGrantKind::ComputeAndData => { - (3, InstitutionalEventKind::ResearchDeposit) - } - crate::persona::PersonaGrantKind::LogsAndAccessReview => { - (2, InstitutionalEventKind::IncidentAutomation) - } - crate::persona::PersonaGrantKind::ProcurementAndWorkOrders => { - (1, InstitutionalEventKind::TicketQueueChange) - } - }; - let event = self - .institutional_ledger - .record( - self.tick, - &format!("persona-{phase}:{persona_id}:{}", self.tick), - target, - kind, - impact, - detail, - ) - .clone(); - let site = self.person_position(target); - self.emit_institutional_event_signature( - event.signature_kind, - event.signature_size, - format!("persona institutional receipt #{}", event.id), - site, - ); - } - - pub fn retire_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { - match self - .persona_world - .retire(persona_id, self.tick, "player retired identity") - { - Ok(()) => { - self.push_log_strategic( - format!("Persona #{persona_id} retired; its public ledger remains."), - OperationsTarget::Persona(persona_id), - ); - true - } - Err(reason) => { - self.push_log(format!("Retirement failed: {reason}.")); - false - } - } - } - - pub fn burn_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { - match self - .persona_world - .burn(persona_id, self.tick, "player conceded the cover") - { - Ok(()) => { - self.record_persona_institutional_receipt( - persona_id, - "burn", - format!("Persona #{persona_id} was conceded and its grants were revoked"), - SignatureImpact::Large, - ); - self.push_log_strategic( - format!( - "Persona #{persona_id} burned: attached grants revoked and counterparties can report it." - ), - OperationsTarget::Persona(persona_id), - ); - true - } - Err(reason) => { - self.push_log(format!("Burn failed: {reason}.")); - false - } - } - } - - pub fn reopen_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { - match self.persona_world.reopen_retired(persona_id, self.tick) { - Ok(new_id) => { - let _ = self - .persona_mind - .remember(&self.persona_world, new_id, self.tick); - self.push_log_strategic( - format!( - "Reopened retired persona #{persona_id} as new instance #{new_id}; old history remains." - ), - OperationsTarget::Persona(new_id), - ); - true - } - Err(reason) => { - self.push_log(format!("Reopen failed: {reason}.")); - false - } - } - } -} diff --git a/crates/misaligned-core/src/sim/tests/mod.rs b/crates/misaligned-core/src/sim/tests/mod.rs index 6c5b7c51..6fb60272 100644 --- a/crates/misaligned-core/src/sim/tests/mod.rs +++ b/crates/misaligned-core/src/sim/tests/mod.rs @@ -12,6 +12,7 @@ mod economy; mod origin; mod perception; mod persistence; +mod persona; mod reach_build; mod read; mod social_plot; diff --git a/crates/misaligned-core/src/sim/tests/persona.rs b/crates/misaligned-core/src/sim/tests/persona.rs new file mode 100644 index 00000000..6565f939 --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/persona.rs @@ -0,0 +1,51 @@ +use super::*; + +#[test] +fn persona_integration_stays_in_its_own_bounded_module() { + let social_plot = include_str!("../social_plot.rs"); + let persona = include_str!("../persona.rs"); + + assert!( + social_plot.lines().count() <= 2_600, + "social/plot integration crossed its documented rough budget; split a complete invariant" + ); + assert!( + persona.lines().count() <= 1_000, + "persona integration outgrew its focused boundary; split a complete invariant" + ); + for method in [ + "suggested_persona_name", + "audit_persona_covers", + "create_persona", + "request_persona_grant", + "record_persona_institutional_receipt", + "burn_persona", + ] { + assert!( + persona.contains(method), + "persona integration lost its {method} owner" + ); + assert!( + !social_plot.contains(&format!("fn {method}")), + "persona integration leaked {method} back into social_plot.rs" + ); + } +} + +#[test] +fn persona_network_receipt_uses_the_institutional_switch_route() { + let mut sim = Sim::new(); + sim.set_persona("Sam", "contractor"); + let persona_id = sim.newest_persona_id().unwrap(); + assert!(sim.request_persona_grant(persona_id)); + + let switch = sim.reach.device_named("switch").unwrap().id; + let routed = sim + .detection + .routed_evidence() + .iter() + .find(|record| record.cause.starts_with("persona institutional receipt #")) + .expect("the Operations grant receipt leaves exact routed custody"); + assert_eq!(routed.source_device, switch); + // There is no ambient pool to leak into (retired 2026-07-29). +} diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index d555759d..98b026b6 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -227,24 +227,6 @@ fn reusable_plot_binds_a_second_characteristic_matching_person() { // There is no ambient pool to leak into (retired 2026-07-29). } -#[test] -fn persona_network_receipt_uses_the_institutional_switch_route() { - let mut sim = Sim::new(); - sim.set_persona("Sam", "contractor"); - let persona_id = sim.newest_persona_id().unwrap(); - assert!(sim.request_persona_grant(persona_id)); - - let switch = sim.reach.device_named("switch").unwrap().id; - let routed = sim - .detection - .routed_evidence() - .iter() - .find(|record| record.cause.starts_with("persona institutional receipt #")) - .expect("the Operations grant receipt leaves exact routed custody"); - assert_eq!(routed.source_device, switch); - // There is no ambient pool to leak into (retired 2026-07-29). -} - #[test] fn an_invalid_held_choice_lists_the_valid_option_ids() { let mut sim = Sim::new(); diff --git a/wiki/engineering/architecture.md b/wiki/engineering/architecture.md index fb6aaaef..e25cf5d8 100644 --- a/wiki/engineering/architecture.md +++ b/wiki/engineering/architecture.md @@ -31,6 +31,7 @@ crates/ src/sim/work.rs — machine controls, WorkGrid, Thought sinks and readouts src/sim/economy.rs — accounts, allocation, detection, research, income src/sim/social_plot.rs — social/assets, plots, world acts, institutional ledger + src/sim/persona.rs — persona authorship, claim review, grants, receipts, lifecycle src/sim/procedure.rs — machine-resident automation configuration, pulse, route/receipt custody src/sim/persistence.rs — Sim/SaveState bridge and transient reconstruction src/sim/carrier.rs — person-carrier projection (people as token nodes) diff --git a/wiki/engineering/sim-decomposition.md b/wiki/engineering/sim-decomposition.md index 17ef157e..a9ed53fe 100644 --- a/wiki/engineering/sim-decomposition.md +++ b/wiki/engineering/sim-decomposition.md @@ -6,12 +6,15 @@ Status: IMPLEMENTED Status note: Completed 2026-07-12 through slices 0–7. Canonical persisted-state bytes, replay/resume convergence, and exact advance order are pinned; behavior tests and cohesive perception, communications, reach/build, work, - economy, social/plot, and persistence integration live below `sim/` while - `sim/mod.rs` remains the public aggregate root. The sequence changed source - addresses only: save v26, public paths, frontend behavior, and tick order are - unchanged. A 2026-07-27 audit extended the phase defense over carried asset - work, facility standing/maintenance, and facility-meter authorship added - after the original extraction; simulation behavior remains unchanged. + economy, social/plot, persona, and persistence integration live below `sim/` + while `sim/mod.rs` remains the public aggregate root. The sequence changed + source addresses only: save v26, public paths, frontend behavior, and tick + order are unchanged. A 2026-07-27 audit extended the phase defense over + carried asset work, facility standing/maintenance, and facility-meter + authorship added after the original extraction. A 2026-08-04 size audit + extracted persona authorship, review, grants, receipts, and lifecycle from + the social/plot island after that complete invariant crossed the module + budget; simulation behavior remains unchanged. Stage: Process Work order: sim-decomposition Work priority: 8 @@ -68,7 +71,8 @@ The completed topology converts `sim.rs` to `sim/mod.rs` and moves cohesive | `sim/work.rs` | machine mode/intensity; WorkGrid integration; visible Demand/Thought production and consumption readouts; Thought sinks and routing | routed-evidence custody/interdiction or renderer effects | | `sim/economy.rs` | economy pulse; account synchronization; allocation yields; detection/signature integration; research and income progression | reach topology or plot narration | | `sim/reach_build.rs` | device tap/take/scan/compromise; links; badge gates; build intents and actuators; hall/rack acquisition | message timing or financial scheme policy | -| `sim/social_plot.rs` | social commands, assets, plot eligibility/execution, world acts, and institutional ledger | transport mechanics implemented by messages/accounts; it calls those seams | +| `sim/social_plot.rs` | social commands, assets, plot eligibility/execution, world acts, and institutional ledger | persona authorship/review/lifecycle or transport mechanics implemented by messages/accounts; it calls those seams | +| `sim/persona.rs` (added post-extraction) | persona authorship suggestions; claim binding and review; grants, expectations, institutional receipts, and lifecycle | social action/plot execution or a second persona data model; it integrates the canonical `PersonaWorld` | | `sim/procedure.rs` (added post-extraction) | machine-resident procedure configuration, pulse execution, exact route revalidation, and host/persona/method receipt authorship | frontend state or a parallel plot executor; it submits through ordinary plot actions | | `sim/persistence.rs` | `create_save_state`, `apply_save_state`, and transient-state reconstruction coordination | version schema/load policy, which remain in `save.rs`; compatibility repair hidden inside load | | `sim/carrier.rs` (added post-extraction) | the person-carrier projection: per-person visual state (gray/crimson/amber), carried work and asset-task reads (people-tokens.md) | mutation of people, schedules, or detection — it is a read over their truth | @@ -80,15 +84,24 @@ modules (`account.rs`, `messages.rs`, `work_grid.rs`, `sinks.rs`, and so on) continue to own their data structures and locally complete algorithms. `sim/*` owns only integration across those structures through the aggregate. -The 2026-07-27 size review found two documented modest exceptions to the -roughly 2,500-line behavior-module budget. `reach_build.rs` is approximately -2,650 lines because exact build-route commitment, procurement/repurposing, -sensor population, and device graph mutation meet at one realization boundary. -`social_plot.rs` is approximately 2,665 lines because people, persona-bound -assets, carried human work, plot execution/policy, and typed world acts share -one person/relationship mutation boundary. Both remain cohesive islands rather -than replacement aggregate roots; further growth should split a complete -invariant rather than move arbitrary lines to satisfy a count. +The 2026-08-04 size review remeasured the living modules rather than preserving +the 2026-07-27 counts. `social_plot.rs` had reached 3,060 lines because a +complete persona integration invariant accumulated after the original social +extraction. Persona authorship, claim review, grants, institutional receipts, +and lifecycle now live in a focused 519-line `persona.rs`; social/assets/plots +remain together at 2,548 lines. A source-boundary regression caps those islands +at 1,000 and 2,600 lines respectively so the same concern cannot silently fold +back into social/plot. + +Two documented exceptions remain above the roughly 2,500-line behavior-module +budget. `communications.rs` is approximately 2,663 lines because one exact +message-record lifecycle spans authorship, delivery/read scheduling, Filing +custody, recording capture, and processed-intel application. `reach_build.rs` +is approximately 3,118 lines because exact build-route commitment, +procurement/repurposing, sensor population, hall/rack realization, and device +graph mutation meet at one physical-realization boundary. They remain cohesive +islands rather than replacement aggregate roots; further growth should split a +complete invariant rather than move arbitrary lines to satisfy a count. ## Boundary rules diff --git a/wiki/log/2026-08-04-sim-persona-module-boundary.md b/wiki/log/2026-08-04-sim-persona-module-boundary.md new file mode 100644 index 00000000..924666ff --- /dev/null +++ b/wiki/log/2026-08-04-sim-persona-module-boundary.md @@ -0,0 +1,60 @@ +# 2026-08-04 — Persona integration leaves the social/plot island + +``` +Type: log +``` + +## Finding + +The simulation decomposition still preserved one aggregate, one stable public +facade, one persistence bridge, canonical replay/save characterization, and an +explicit advance order. Its size record had drifted, however: +`sim/social_plot.rs` was no longer the documented approximately 2,665-line +exception. It had reached 3,060 lines as persona authorship, claim review, +grants, institutional receipts, and lifecycle accumulated after the original +social/plot extraction. + +That was not arbitrary bulk. The second `impl Sim` block was one complete +persona-world integration invariant with production consumers outside +social/plot and a narrow dependency on the existing institutional-signature +seam. + +## Changed + +- Moved the complete persona integration block into `sim/persona.rs`: suggested + names and claims, claim-subject binding and review, identity creation, grants, + expectations, institutional receipts, and retire/burn/reopen lifecycle. +- Kept social commands, assets, carried human work, authored plots, typed world + acts, and the institutional event ledger in `sim/social_plot.rs`. +- Widened only the two shared institutional-signature helpers from private to + `pub(super)`; no public path or signature changed. +- Moved the focused persona institutional-route regression into + `sim/tests/persona.rs` and added a source-boundary regression. Social/plot is + capped at 2,600 lines and persona integration at 1,000; crossing either asks + for another complete invariant rather than a line-count shuffle. +- Updated the architecture mirror and current module-size record. The living + files are now 2,548 lines for social/plot and 519 for persona integration. + +## Defense + +The decomposition spec requires behavior-owned edit surfaces without creating +independently saved systems or widening the public facade. Persona integration +already acts through the canonical `PersonaWorld`, message, account, detection, +and institutional-ledger seams; giving that integration one source address +reduces unrelated social/plot collisions while preserving one aggregate and +one rule path. The focused source test protects the semantic seam that made the +move worthwhile, not merely today's exact line count. + +No simulation field, command behavior, tick phase, save schema/bytes, public +API, or frontend contract changed. + +## Verification + +- `cargo test -p misaligned-core sim::tests::persona -- --nocapture` +- `./tools/check.sh --lib` +- `./tools/check.sh --land` + +The focused persona tests passed. The proportional library gate passed 674 +core tests, three Act One integrations, core/terminal Clippy, Bevy's core API +check, deterministic agent smoke, corpus/wiki validation, generated-index +checks, and all project fixtures before the exact landing gate. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index d85359b6..2daf38d1 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -61,6 +61,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-08-04-simulation-law-staging-drift.md](2026-08-04-simulation-law-staging-drift.md) +## 2026-08-04 - Persona integration leaves the social/plot island + +- Intent: (see session log) +- Log: [wiki/log/2026-08-04-sim-persona-module-boundary.md](2026-08-04-sim-persona-module-boundary.md) + ## 2026-08-04 - Session wrap reads live state - Intent: (see session log) diff --git a/wiki/log/decisions/2026-08-04.md b/wiki/log/decisions/2026-08-04.md index 977932c7..0da278e0 100644 --- a/wiki/log/decisions/2026-08-04.md +++ b/wiki/log/decisions/2026-08-04.md @@ -196,3 +196,33 @@ Owner: [opening.md](../../world/story/opening.md#the-current-run-beats), frame. Only the inscription was undersized. Owner: [liturgical-ui-constitution.md](../../interface/liturgical-ui-constitution.md#typography-canon). + +## Persona integration has its own simulation address + +### DECIDED + +- `sim/persona.rs` owns identity suggestions and authorship, claim binding and + Assurance review, grants and expectations, institutional receipts, and + retire/burn/reopen lifecycle integration. +- `sim/social_plot.rs` retains social commands, assets and carried human work, + authored plot execution/policy, typed world acts, and the institutional + event ledger. Both remain `impl Sim` islands over one aggregate and the + canonical domain types. +- A focused source regression holds social/plot below 2,600 lines and persona + integration below 1,000. Future growth splits a complete invariant rather + than moving arbitrary methods to satisfy a count. + +### REJECTED + +- **Leave persona lifecycle inside social/plot because both touch people.** + The file had reached 3,060 lines and persona creation/review/grant consumers + already span Operations, save, economy, and advance orchestration; that is a + coherent integration boundary, not merely a social-action helper. +- **Create a new crate or independently saved persona subsystem.** The move is + source topology only. `Sim`, `PersonaWorld`, save custody, public paths, and + tick order remain singular. +- **Split by line range alone.** The extracted block owns one named invariant + and only two narrow `pub(super)` institutional-signature seams cross it. + +Owner: [sim-decomposition.md](../../engineering/sim-decomposition.md#standing-topology), +acceptance criteria 1, 6, and 7. diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index a80dddd9..66450573 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -65,7 +65,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/interface/narration.md` + `wiki/mechanics/sim-mechanics.md` guidance chain | 2026-08-04 | finding | the shared query and both current mirrors now implement the no-boundary decision: survival interrupts -> required senses/person teaching -> optional Territory with `hall_territory_line` -> bare Audit fallback. Badge cloning remains available through person actions but is not a story rung; no readiness or completion state can displace the continuing lab. Focused core and public-API playthrough regressions pin the chain — [alignment log](../log/2026-08-04-act-one-no-boundary-runtime.md), prior [Territory log](../log/2026-07-27-territory-guidance-chain.md) and [suspicion-cooling log](../log/2026-07-18-suspicion-cooling-nudge.md). | | `wiki/interface/agent-play.md` | 2026-08-03 | question | a surface-only seed-93 cold run selected real THINK but could not discover how to let it produce Ears: unlike human frontends, agent mode has no wall clock, while opening law deliberately hides `wait N`, parser help, and all status. The informed seed-94 route proved the intended THINK -> Ears -> camera -> Eyes -> process chain once the hidden clock route was supplied. The spec now carries an `[OPEN]` choice between exposing a channel-level WAIT affordance, auto-advancing to the first sense, or explicitly accepting protocol foreknowledge; authenticated issue creation was blocked by the headless login collection — [playtest](../playtests/2026-08-03-playtest-agent-opening-clock.md), [log](../log/2026-08-04-agent-opening-clock-playtest.md). Prior implementation findings stand — [task coverage](../log/2026-07-20-agent-task-shortcut-coverage.md), [opening log](../log/2026-07-20-agent-opening-protocol.md), [frame log](../log/2026-07-19-agent-frame-contract.md), [prior help log](../log/2026-07-19-agent-help-suppress-task.md) | | `wiki/engineering/crate-workspace.md` + as-built source/run mirrors | 2026-08-04 | finding | The four-package split, dependency direction, gates, and exact multi-binary asset commands remain sound, but the binding action-family inventory stopped at the July 29 extractions: it omitted the later device-choice and receipt seams, while the as-built architecture mirror also skipped those two modules, machine procedures, the performance fixture, and five already-bound Bevy subsystem addresses. Both inventories now match the live tree, and the dependency sketch correctly separates test-only BLAKE3 from shipped core. The source-shape test previously required `mod device;` but would still accept both device-copy methods reabsorbed into the facade; it now rejects either return and reports family-neutral failures — [reaudit](../log/2026-08-04-crate-workspace-boundary-reaudit.md). Prior multi-binary command, source-topology, and retired-effects-label findings stand — [command log](../log/2026-07-26-architecture-asset-harness-command.md), [source log](../log/2026-07-19-bevy-shot-harness-module.md), [label log](../log/2026-07-19-effects-lab-architecture-gate.md) | -| `wiki/engineering/sim-decomposition.md` | 2026-07-27 | finding | one aggregate, facade, persistence bridge, canonical fingerprint, and explicit orchestration remain intact, but the phase-order regression had stopped at the original extraction vocabulary: carried asset work, facility standing/maintenance, and facility-meter authorship were direct top-level calls with no trace marker. All three now occupy their exact causal positions in the test-only trace; the acceptance criterion requires every direct ordered subsystem call to be named. The audit also records why cohesive `reach_build.rs` and `social_plot.rs` currently sit modestly above the roughly 2,500-line budget instead of leaving silent criterion drift — [log](../log/2026-07-27-sim-advance-phase-defense.md) | +| `wiki/engineering/sim-decomposition.md` | 2026-08-04 | finding | the aggregate root, stable facade, persistence bridge, canonical fingerprint, and explicit advance trace remain intact, but `social_plot.rs` had grown from the recorded 2,665 lines to 3,060 as persona authorship, claim review, grants, receipts, and lifecycle accumulated beside social/assets/plots. That complete 519-line invariant now owns `sim/persona.rs`; social/plot is 2,548 lines, one focused source regression prevents the concerns from silently recombining, and the current communications/reach exception counts are recorded without moving arbitrary lines — [log](../log/2026-08-04-sim-persona-module-boundary.md). Prior phase-trace defense stands — [log](../log/2026-07-27-sim-advance-phase-defense.md). | | `wiki/mechanics/rollback.md` + B3 dependency chain | 2026-07-31 | finding | metadata still hard-blocked rollback and Markets on all remaining z-planes work, then hard-blocked the entire overt phase on Markets, even though zplanes had explicitly released the dependent chain and the detailed ROADMAP said both B1-record aggregation and the first response loop could start independently. Whole-order blockers are now removed: rollback dispatches against the landed World/Plane substrate; Markets keeps greenfield criterion 4 scoped to z-planes; overt criteria 1-4 may build the local response without Markets, criterion 6 composes with rollback, and overt-owned criterion 7 alone waits for Markets' off-plane Space. Re-hide remains honestly [OPEN] under issue #13 rather than being selected by taste — [audit](../log/2026-07-31-overt-dependency-scope.md), [prior issue](../log/2026-07-19-overt-rehide-decision.md) | | `wiki/gameplay/horizon.md` + B1/B2 core-loss mirrors | 2026-07-29 | finding | the horizon still said B1 woke at its freshest sync and lost subsequent learning, while runtime stores no image and merely moves `core.host_machine` with every other current field intact. `HostLoss::FailedOver`, the ordinary power-loss receipt, DESIGNATE HOST FAILOVER, core/objective/Act One/opening/glossary mirrors, and core criterion 1 now distinguish live current-state host failover from B2's deferred completed-image rollback. A simulation regression and corpus gate defend the boundary — [log](../log/2026-07-29-b1-host-failover-boundary.md). | | `wiki/vision/premise.md` | 2026-08-04 | finding | the design-pillar summary still marked the villain's exact shape `[OPEN]` and pointed vaguely “below,” while the next heading has bound all four phased layers since the corpus migration: origin, objective, capability body, and public mask/doctrine. The pillar now names that decided shape directly instead of preserving a false unresolved call — [log](../log/2026-08-04-villain-shape-open-marker.md). Prior machine-axis and objective-boundary repairs stand — [origin wording](../log/2026-07-23-origin-bias-wording.md), [objective boundary](../log/2026-07-23-premise-objective-boundary.md). |