diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ `./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 v58; + machine modes, not current player assignments. Save format is currently v59; 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/wiki/engineering/current-build.md b/wiki/engineering/current-build.md --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -27,13 +27,13 @@ | Digital reach + sensor ownership (tap/take) | Live — B1's topology-generated population includes exact secured-door readers; a funded player TAP records each access-valid entered/left crossing as ordinary processable Presence custody, while a starved retained tap remains silent | | Economy flows + Moonlight / Wager income | Live — Moonlight is persisted Halcyon compute/intel contracts with financial mail, account-graph payment, and exact egress evidence; Wager remains unchanged | | Research (self-modification, emission law, real output hooks, Routing) | Live | -| Building + physical asset work as carried intents/packets | Live — network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v58 | +| Building + physical asset work as carried intents/packets | Live — network links and small switches expose one shared procurement / ask someone / false order / reuse route sheet; exact money, people, personas, sources, delivery, recovery, carried installation, cancellation custody, Storage B file retrieval, and observer-local completion evidence persist in save v59 | | Cursor / fog (seen, remembered, blueprint, telemetry; audio is device-bound event evidence) | Live | | Feel floor (rails / pads / build beam) | Live (#37) | | Foundation hall territory (Dana + Priya + Marcus + local LIE foothold) | Live — row control persists; foreign racks remain unavailable compute | | Context menu (`available_actions`) | Live | | Operations workspace | Live — human action panes group repeated exact plot/procedure and recruitment variants beneath ordinary intention submenus while lone actions stay direct; terminal and Bevy share exact child commands, confirmation, back traversal, and a visible chamber hold that freezes simulation/camera input without rewriting explicit pause state or replaying elapsed input on close. Agent rows remain exact and flat for scripting. | -| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v58 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves additionally validate discrete Moonlight terms, persona binding, delivery/settlement receipts, financial paperwork, Network linkage, durable facility-meter level baselines, exact meter route/read custody, resident-procedure machine slots, method grants, inputs, envelopes and bounded receipts, and exact incident/interface/persona cover custody plus interface wear; retired allocation weights, per-plot policies, and migration inputs live only in git history. | +| Save/load (serde JSON, versioned) | Live — during pre-release only exact current v59 loads; a refused old-version load leaves the active run, save file, and one rotated backup unchanged. Current saves additionally validate discrete Moonlight terms, persona binding, delivery/settlement receipts, financial paperwork, Network linkage, durable facility-meter level baselines, exact meter route/read custody, resident-procedure machine slots, method grants, inputs, envelopes and bounded receipts, and exact incident/interface/persona cover custody plus interface wear; retired allocation weights, per-plot policies, and migration inputs live only in git history. | | Terminal frontend (crossterm) + agent mode | First-class | | Bevy frontend (DIGITAL flat sensorium default; REAL material dialect) | Live — consumes sim-authored machine-work motion | diff --git a/wiki/log/2026-07-29-persona-binding-retire-dial.md b/wiki/log/2026-07-29-persona-binding-retire-dial.md new file mode 100644 --- /dev/null +++ b/wiki/log/2026-07-29-persona-binding-retire-dial.md @@ -0,0 +1,58 @@ +# Criterion 13, part two: the identity mode is gone + +``` +Type: log +``` + +## Intent + +Finish criterion 13 by migrating the five systems part one deliberately left +whole on the global selection, then delete the selection itself. + +## Verification + +Part one's status note listed exactly what remained: Moonlight acceptance, +forged build orders, purchase-order injection, procedure provenance, and +`{persona}` plot-text rendering — plus the evidence-cover path found during +this pass. Each was still reading `active_persona_id()`. + +## Change + +Every remaining act now binds the counterparty it actually reaches. Moonlight +binds the Halcyon client, forged work orders and purchase-order injection bind +the person who reads them, evidence cover binds its exact observer, and plot +context and `{persona}` rendering bind their target. + +A resident procedure was the one case with no single counterparty: it services +a scope, not a person. Rather than invent a binding, it now takes the first +plot-capable identity deterministically and owns that binding for its lifetime, +which is the behavior `a_resident_procedure_binds_its_own_persona_not_the_selected_one` +already pinned — only its source changed, from a mutable global to creation +order. + +With no readers left, `PersonaMind.active`, `Sim::active_persona_id`, +`Sim::select_persona`, `PersonaMind::select`, `active_instance`, the blanket +`persona_action_blocked_reason`, and `ActionCommand::SelectPersona` are all +deleted. `PersonaMind::select` becomes `remember`: dossier upkeep that confers +nothing. PERSONAS drops its SELECT IDENTITY row, and an identity now reports +how many counterparties deal with it instead of whether it is "acting". Save +v58 -> v59; the canonical fingerprint is repinned and the doorway plus four +version-coupled wiki pages follow. + +## Defense + +Criterion 13 required that no global mode gate action legality and that the +modal row retire from every surface; both now hold, so it moves from adopted +to met. `no_global_identity_mode_survives_anywhere` queues acts on two +contacts back to back with no switch between them, proves each sink carries +its own counterparty's identity, and proves PERSONAS offers no selection row — +the concrete thing the design set out to make possible. + +`newest_persona_id` replaces the dial accessor in tests. It reports creation +order, confers nothing, and gates nothing; naming it that way keeps a +selection from creeping back under a familiar-looking helper. + +Two assertions were updated rather than preserved because they encoded the +retired semantics: the PERSONAS "acting now" fact, and the criterion 13 +regression's claim that moving the selection cannot retarget a binding — +there is no longer a selection to move. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -56,6 +56,11 @@ - Intent: Implement the adopted per-relationship identity binding for the acts that carry it most directly — MESSAGE, FAVOR, DECEIVE — instead of the whole of criterion 13 at once, so the save format and the systems still on the dial stay untouched. - Log: [wiki/log/2026-07-29-persona-binding-social-acts.md](2026-07-29-persona-binding-social-acts.md) +## 2026-07-29 - Criterion 13, part two: the identity mode is gone + +- Intent: Finish criterion 13 by migrating the five systems part one deliberately left whole on the global selection, then delete the selection itself. +- Log: [wiki/log/2026-07-29-persona-binding-retire-dial.md](2026-07-29-persona-binding-retire-dial.md) + ## 2026-07-29 - Material fog contract defense - Intent: (see session log) diff --git a/wiki/mechanics/people-tokens.md b/wiki/mechanics/people-tokens.md --- a/wiki/mechanics/people-tokens.md +++ b/wiki/mechanics/people-tokens.md @@ -67,7 +67,7 @@ endpoint. They become her evidence only on the later cadence read and never enter the ambient pending pool. Filing, Network, Paper, Financial, JobAnomaly, Power, and Thermal all compete for the same first-hop - one-record-per-LIE-body-per-tick budget. Current save v58 persists + one-record-per-LIE-body-per-tick budget. Current save v59 persists in-flight, delivered, read, route-local LIE-stopped, and handler-suppressed custody plus exact source/observer/machine/site/tick provenance. - **Interface cover records (criterion 6, implemented).** One exact acquired @@ -422,7 +422,7 @@ filing state remain in place either way. Interface wear advances once on every attempt and blocks another explanation at 3; known controlled interfaces expose `explanations used: N of 3` through the shared inspect - projection. Current save v58 fails closed on impossible credibility, + projection. Current save v59 fails closed on impossible credibility, evidence weight, cover binding, historical co-location, persona permission, observer custody, or interface wear. Pinned by success, failure, wrong-room, filed/withheld, duplicate, worn-interface, shared-menu, diff --git a/wiki/mechanics/personas.md b/wiki/mechanics/personas.md --- a/wiki/mechanics/personas.md +++ b/wiki/mechanics/personas.md @@ -54,10 +54,16 @@ Plot rows bind the same default and validate it, but do not fan out: a counterparty who recognizes two plot-capable identities still gets one row, so the explicit multi-recognition choice criterion 13 requires exists for - the message verbs only. Still on the dial and owed a follow-up: Moonlight - acceptance, forged build orders, purchase-order injection, procedure - provenance, and `{persona}` plot-text rendering — each left whole rather - than half-migrated, so no row offers an identity its executor would refuse. + the message verbs only. + Completed 2026-07-29: the global selection is gone. Moonlight binds the + Halcyon client, forged orders and purchase-order injection bind the person + who reads them, evidence cover binds its exact observer, and plot context + and `{persona}` rendering bind their target. A resident procedure has no + single counterparty, so it takes the first plot-capable identity + deterministically and owns that binding for its lifetime. `PersonaMind` + keeps only its dossiers; `active`, `select_person`, `SelectPersona`, and the + SELECT IDENTITY row are retired, and an identity row now reports how many + counterparties deal with it rather than whether it is "acting". Save v59. `PersonaMind.active` therefore remains in MindState and the save format is unchanged; criterion 13 stays pending until those four migrate and the modal row retires. @@ -493,7 +499,7 @@ reopen. PEOPLE actions and direct agent commands cannot create or manage an identity; agent mode reaches the same bound PERSONAS rows through `actions archetype ` / `actions persona ` and `act`. -13. (ADOPTED 2026-07-28, pending) Identity binding is per relationship. An act +13. Identity binding is per relationship. An act on a counterparty defaults to the persona instance that counterparty recognizes; the explicit choice appears only at first contact or when the counterparty recognizes more than one owned identity, and choosing a @@ -516,6 +522,9 @@ `an_unbound_social_row_states_its_blocker_and_refuses_execution` proves a row with no viable identity still appears carrying its blocker, and that its command is refused rather than resolved at execution. +`no_global_identity_mode_survives_anywhere` queues acts on two contacts back to +back with no switch between them, proves each sink carries its own +counterparty's identity, and proves PERSONAS offers no selection row. [TUNE] evidence bands for strained/broken/correlated summaries, expectation cadences, grant thresholds, and institution-specific revocation delays. diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -18,7 +18,7 @@ switch -> bridge). Reach itself is unchanged: a device's wire to its access switch still carries that device's own segment gate, and access switches sit on segment 0, so every previously blocked hop stays blocked. Wires persist - in `ReachNet` and round-trip (save v58). Both frontends now draw the + in `ReachNet` and round-trip (save v59). Both frontends now draw the persisted route: the terminal's Bresenham `line_cells` and diagonal `link_glyph` are deleted, and Bevy's `rectilinear_floor_route` elbow, its patch-panel special case, and `focused_physical_route`'s same-room shortcut diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -196,7 +196,6 @@ CreatePersona { archetype_id: String, }, - SelectPersona(crate::persona::PersonaId), RequestPersonaGrant(crate::persona::PersonaId), MeetPersonaExpectation { persona_id: crate::persona::PersonaId, @@ -833,7 +832,6 @@ Self::Recruit(_, _) => ActionKind::Recruit, Self::AssetTask(_, _) | Self::Eliminate { .. } => ActionKind::AssetTask, Self::CreatePersona { .. } - | Self::SelectPersona(_) | Self::RequestPersonaGrant(_) | Self::MeetPersonaExpectation { .. } | Self::RetirePersona(_) @@ -2383,9 +2381,6 @@ ActionCommand::Eliminate { actor, target } => self.eliminate(*actor, *target), ActionCommand::CreatePersona { archetype_id } => { self.create_persona(archetype_id); - } - ActionCommand::SelectPersona(id) => { - self.select_persona(*id); } ActionCommand::RequestPersonaGrant(id) => { self.request_persona_grant(*id); @@ -4409,15 +4404,22 @@ // record mail. An unrelated payroll or revenue record is insufficient. if self.accounts.purchase_order_pattern_known() { let inject = 300; - let disabled_reason = self - .persona_action_blocked_reason(crate::persona::PersonaActionKind::Deceive) + let inject_persona = + self.default_binding(3, crate::persona::PersonaActionKind::Deceive); + let disabled_reason = inject_persona + .map_or( + Some("you have no identity that can carry this".to_string()), + |persona_id| { + self.persona_action_blocked_reason_for( + persona_id, + crate::persona::PersonaActionKind::Deceive, + ) + }, + ) .or_else(|| { - // Still on the legacy selection, like Moonlight accept and - // forged build orders: its executor reads the same dial, so - // projecting a different identity here would offer a row - // execution then refuses. Migrates with them in the - // criterion 13 follow-up. - self.active_persona_id().and_then(|persona_id| { + // The order lands in Priya's inbox, so row and executor + // agree on the identity she recognizes. + inject_persona.and_then(|persona_id| { self.persona_counterparty_blocked_reason(persona_id, 3) }) }); @@ -6062,9 +6064,9 @@ s.people.has_channel = true; let carrier = s.email_carrier().unwrap(); s.set_persona("Northline Ops", "contractor"); - let first_persona = s.active_persona_id().unwrap(); + let first_persona = s.newest_persona_id().unwrap(); s.set_persona("Night Facilities", "contractor"); - let second_persona = s.active_persona_id().unwrap(); + let second_persona = s.newest_persona_id().unwrap(); let sheet = s .build_route_sheet_projection(intent) @@ -6323,7 +6325,7 @@ s.people.recruit(1, AssetKnowledge::Complicit); s.people.has_channel = true; s.set_persona("Northline Ops", "contractor"); - let persona = s.active_persona_id().unwrap(); + let persona = s.newest_persona_id().unwrap(); s.persona_world.record_contradiction( persona, 0, @@ -6751,7 +6753,7 @@ panic!("no process is resident yet, so the job installs one"); }; assert_eq!(job.host_machine, host); - assert_eq!(blueprint.persona_id, s.active_persona_id().unwrap()); + assert_eq!(blueprint.persona_id, s.newest_persona_id().unwrap()); assert_eq!(blueprint.envelope.thought_milli, 250); assert_eq!(blueprint.envelope.money, 400); assert_eq!(blueprint.methods.len(), 1); diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -1913,10 +1913,22 @@ format!("the world reads it as {}", reads_as(&instance.archetype_id)), match instance.lifecycle { PersonaLifecycle::Active => { - if self.active_persona_id() == Some(instance.id) { - "this identity is the one acting now".to_string() - } else { - "not the identity currently acting".to_string() + // Criterion 13: identity is bound per relationship, + // so there is no "currently acting" mask to report. + let known = self + .persona_world + .relationships + .iter() + .filter(|relationship| relationship.persona_id == instance.id) + .filter(|relationship| relationship.recognized) + .filter(|relationship| { + self.persona_observer_is_known(relationship.counterparty) + }) + .count(); + match known { + 0 => "nobody knows this identity yet".to_string(), + 1 => "one counterparty deals with this identity".to_string(), + n => format!("{n} counterparties deal with this identity"), } } PersonaLifecycle::Retired { .. } => { @@ -2083,12 +2095,6 @@ let mut actions = Vec::new(); match instance.lifecycle { PersonaLifecycle::Active => { - actions.push(action( - "SELECT IDENTITY".into(), - ActionCommand::SelectPersona(instance.id), - (self.active_persona_id() == Some(instance.id)) - .then(|| "already the active identity".into()), - )); let has_grant = self .persona_world .grants @@ -4212,7 +4218,7 @@ let persona_id = 77_003; s.persona_world.next_persona_id = persona_id; s.set_persona("Northline Operations", "contractor"); - assert_eq!(s.active_persona_id(), Some(persona_id)); + assert_eq!(s.newest_persona_id(), Some(persona_id)); let grant_id = 77_004; let expectation_id = 77_005; s.persona_world.next_grant_id = grant_id; @@ -4663,7 +4669,7 @@ .command .clone(); s.execute_action(&create); - let id = s.active_persona_id().unwrap(); + let id = s.newest_persona_id().unwrap(); let projection = s.operations_projection(); let object = projection @@ -4675,7 +4681,7 @@ object .facts .iter() - .any(|fact| fact == "this identity is the one acting now") + .any(|fact| fact == "nobody knows this identity yet") ); assert!(object.actions.iter().any(|action| { matches!(action.command, ActionCommand::RequestPersonaGrant(bound) if bound == id) @@ -4719,7 +4725,7 @@ ); s.execute_action(&ActionCommand::ReopenPersona(id)); - let reopened = s.active_persona_id().unwrap(); + let reopened = s.newest_persona_id().unwrap(); assert_ne!(reopened, id); let projection = s.operations_projection(); assert!( @@ -4742,7 +4748,7 @@ 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(); + let persona = s.newest_persona_id().unwrap(); s.persona_world.recognize(0, persona, s.tick); s.persona_world.recognize(1, persona, s.tick); s.persona_world.record_contradiction( @@ -4918,7 +4924,7 @@ s.execute_action(&ActionCommand::CreatePersona { archetype_id: archetype_id.into(), }); - created.push(s.active_persona_id().unwrap()); + created.push(s.newest_persona_id().unwrap()); } let projection = s.operations_projection(); @@ -4958,7 +4964,7 @@ fn missed_persona_expectation_revokes_its_grant_and_leaves_evidence() { let mut s = sim(); s.set_persona("Sam Reyes", "IT contractor"); - let id = s.active_persona_id().unwrap(); + let id = s.newest_persona_id().unwrap(); assert!(s.request_persona_grant(id)); assert!( s.institutional_ledger diff --git a/crates/misaligned-core/src/persona.rs b/crates/misaligned-core/src/persona.rs --- a/crates/misaligned-core/src/persona.rs +++ b/crates/misaligned-core/src/persona.rs @@ -896,16 +896,19 @@ } } -/// MindState: active selection and remembered dossiers. A restored process can -/// remember stale public identity state; reconciliation must inspect WorldState. +/// MindState: remembered dossiers. A restored process can remember stale +/// public identity state; reconciliation must inspect WorldState. Criterion 13 +/// retired the global selection that used to live here — identity is bound per +/// relationship at the moment an act is committed, never held as a mode. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct PersonaMind { - pub active: Option, pub dossiers: Vec, } impl PersonaMind { - pub fn select( + /// Record that the process is dealing with this identity. Dossier upkeep + /// only — it confers no selection and gates no act. + pub fn remember( &mut self, world: &PersonaWorld, persona_id: PersonaId, @@ -917,7 +920,6 @@ if !persona.lifecycle.active() { return Err("retired or burned personas cannot be selected".into()); } - self.active = Some(persona_id); if let Some(dossier) = self .dossiers .iter_mut() @@ -934,12 +936,6 @@ }); } Ok(()) - } - - pub fn active_instance<'a>(&self, world: &'a PersonaWorld) -> Option<&'a PersonaInstance> { - self.active - .and_then(|id| world.get(id)) - .filter(|persona| persona.lifecycle.active()) } } diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -73,7 +73,7 @@ /// v43 introduced exact Filing routes and pre-read LIE interdiction. /// Bump for every schema change; during pre-release, old development state is /// refused instead of carried through compatibility shims. -pub const SAVE_VERSION: u32 = 58; +pub const SAVE_VERSION: u32 = 59; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -4043,7 +4043,7 @@ ); assert_eq!( state_fingerprint(&uninterrupted_state), - "6d9d78fc457c1288e2375bd499f3371a321a59a856a97b2c12dbc043fd610b5d", + "63044895638c1595eee4b214548f44087e80c5ce318a93a23a6ed4bb4a72f7e2", "intentional persisted-state changes must review and repin this baseline" ); } diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -4363,7 +4363,7 @@ sim.people.people[0].knowledge = misaligned::person::Knowledge::Schedule; sim.people.has_channel = true; sim.set_persona("Northline Ops", "contractor"); - let persona = sim.active_persona_id().unwrap(); + let persona = sim.newest_persona_id().unwrap(); let expected_candidate = sim .build_route_sheet_projection(intent) .unwrap() @@ -4579,7 +4579,7 @@ let output = String::from_utf8(output).unwrap(); assert!(output.contains("persona creation moved to PERSONAS")); assert!(output.contains("actions archetype")); - assert_eq!(app.sim.active_persona_id(), None); + assert_eq!(app.sim.newest_persona_id(), None); } #[test] diff --git a/wiki/world/characters/priya.md b/wiki/world/characters/priya.md --- a/wiki/world/characters/priya.md +++ b/wiki/world/characters/priya.md @@ -22,7 +22,7 @@ paperwork route to the same off-books delivery `MovePackage` reaches physically). Power and Thermal now route as exact UPS/HVAC meter records through the institutional switch to Priya; only her later cadence read changes suspicion, -and the same first-hop LIE budget applies. State persists in current save v58; +and the same first-hop LIE budget applies. State persists in current save v59; pinned by `priya_rerates_circuits_defers_maintenance_and_fakes_pos` including the save round-trip. diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -930,14 +930,18 @@ ); return false; } - if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::Deceive) { + // The forged order lands in Priya's inbox, so it is authored by the + // identity she recognizes (criterion 13), not by a global selection. + let Some(persona_id) = self.default_binding(3, PersonaActionKind::Deceive) else { + self.push_log("no identity of yours can author that purchase order"); + return false; + }; + if let Some(reason) = + self.persona_action_blocked_reason_for(persona_id, PersonaActionKind::Deceive) + { self.push_log(reason); return false; } - let Some(persona_id) = self.active_persona_id() else { - self.push_log("no active persona"); - return false; - }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, 3) { self.push_log(reason); return false; @@ -1376,14 +1380,19 @@ } pub(crate) fn active_moonlight_persona_id(&self) -> Option { - self.persona_mind - .active_instance(&self.persona_world) - .filter(|persona| persona.archetype_id == "research") - .filter(|persona| { + // Halcyon is a counterparty like any other: the contract is carried by + // the Research identity that client recognizes (criterion 13). + self.persona_world + .binding_choices( + crate::income::MOONLIGHT_CLIENT_ID, + PersonaActionKind::Deceive, + ) + .into_iter() + .find(|id| { self.persona_world - .allows_action(persona.id, PersonaActionKind::Deceive) + .get(*id) + .is_some_and(|persona| persona.archetype_id == "research") }) - .map(|persona| persona.id) } /// Contract paperwork travels as ordinary Email on the existing accounting diff --git a/crates/misaligned-core/src/sim/procedure.rs b/crates/misaligned-core/src/sim/procedure.rs --- a/crates/misaligned-core/src/sim/procedure.rs +++ b/crates/misaligned-core/src/sim/procedure.rs @@ -572,7 +572,15 @@ plot: &PlotDefinition, host_machine: u32, ) -> Option { - let persona_id = self.active_persona_id()?; + // A resident process carries one identity across its whole scope, so + // there is no single counterparty to bind to. It takes the first + // plot-capable identity deterministically rather than whatever was + // selected; the procedure then owns that binding for its lifetime. + let persona_id = self + .persona_world + .able_to(crate::persona::PersonaActionKind::Plot) + .first() + .copied()?; self.compute .machines .iter() diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -1233,8 +1233,12 @@ /// Forge a work order: inject a message under a false source. The /// unwitting builder accepts on read and completes when present. pub fn forge_work_order(&mut self, intent_id: u64, builder_id: u8) { - let Some(persona_id) = self.active_persona_id() else { - self.push_log("No active persona."); + // The order is read by its builder, so it is authored by the identity + // that builder recognizes (criterion 13). + let Some(persona_id) = + self.default_binding(builder_id, crate::persona::PersonaActionKind::Deceive) + else { + self.push_log("No identity of yours can author that work order."); return; }; self.forge_work_order_as(intent_id, builder_id, persona_id); @@ -1637,7 +1641,9 @@ .is_none() && (p.asset.is_some() || p.obligation >= Self::FAVOR_BUILD_OBLIGATION - || self.active_persona_id().is_some()) + || self + .default_binding(p.id, crate::persona::PersonaActionKind::Deceive) + .is_some()) }); if !anyone { Some("no actuator who can reach both ends".into()) diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -179,21 +179,19 @@ total > self.maintenance_deferrals as i32 * Self::DEFER_MAINTENANCE_REDUCTION } - pub fn active_persona_id(&self) -> Option { - self.persona_mind - .active_instance(&self.persona_world) - .map(|persona| persona.id) - } - - pub fn persona_action_blocked_reason(&self, action: PersonaActionKind) -> Option { - let Some(persona_id) = self.active_persona_id() else { - return Some("no active persona".into()); - }; - self.persona_action_blocked_reason_for(persona_id, action) - } - /// Validate one exact identity rather than whatever happens to be selected /// when a previously projected route executes. + /// The most recently created identity, by creation order. This is not a + /// selection and gates nothing — it exists so callers that just built an + /// identity can name it without reviving a global mode. + pub fn newest_persona_id(&self) -> Option { + self.persona_world + .instances + .iter() + .rfind(|instance| instance.lifecycle.active()) + .map(|instance| instance.id) + } + pub(crate) fn persona_action_blocked_reason_for( &self, persona_id: PersonaId, @@ -225,7 +223,9 @@ self.tick, ) { Ok(id) => { - let _ = self.persona_mind.select(&self.persona_world, id, self.tick); + let _ = self + .persona_mind + .remember(&self.persona_world, id, self.tick); } Err(error) => self.push_log(error), } @@ -328,11 +328,11 @@ if !same_room { return Some("the observer will not encounter that interface before filing".into()); } - self.persona_action_blocked_reason(PersonaActionKind::Deceive) - .or_else(|| { - self.active_persona_id() - .and_then(|persona| self.persona_counterparty_blocked_reason(persona, observer)) - }) + let Some(persona_id) = self.default_binding(observer, PersonaActionKind::Deceive) else { + return Some("you have no identity that can carry this".into()); + }; + self.persona_action_blocked_reason_for(persona_id, PersonaActionKind::Deceive) + .or_else(|| self.persona_counterparty_blocked_reason(persona_id, observer)) } fn evidence_cover_explanation(kind: SignatureKind) -> &'static str { @@ -358,8 +358,8 @@ return; } let persona_id = self - .active_persona_id() - .expect("cover legality requires an active persona"); + .default_binding(observer, PersonaActionKind::Deceive) + .expect("cover legality already proved an identity this observer can be told"); let (kind, cause, credibility_before, suspicion_weight_milli) = self .detection .observers @@ -802,7 +802,7 @@ } pub(crate) fn plot_context(&self, id: u8) -> Option { - self.plot_context_as(id, self.active_persona_id()) + self.plot_context_as(id, self.default_binding(id, PersonaActionKind::Plot)) } /// Eligibility as one exact identity would see it. A resident procedure @@ -925,7 +925,12 @@ #[cfg(test)] pub(super) fn apply_start_plot(&mut self, person: u8, plot_id: &str) -> bool { - self.apply_start_plot_bound(person, plot_id, self.active_persona_id(), None) + self.apply_start_plot_bound( + person, + plot_id, + self.default_binding(person, PersonaActionKind::Plot), + None, + ) } pub(super) fn apply_start_plot_bound( @@ -1383,7 +1388,11 @@ } pub(crate) fn render_plot_text(&self, target: u8, text: &str) -> String { - self.render_plot_text_for(target, self.active_persona_id(), text) + self.render_plot_text_for( + target, + self.default_binding(target, PersonaActionKind::Plot), + text, + ) } pub(super) fn render_plot_text_for( @@ -2512,7 +2521,9 @@ .create(archetype_id, name.clone(), &claims, self.tick) { Ok(id) => { - let _ = self.persona_mind.select(&self.persona_world, id, self.tick); + 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), @@ -2521,25 +2532,6 @@ } Err(reason) => { self.push_log(format!("Could not establish persona: {reason}.")); - false - } - } - } - - pub fn select_persona(&mut self, persona_id: crate::persona::PersonaId) -> bool { - match self - .persona_mind - .select(&self.persona_world, persona_id, self.tick) - { - Ok(()) => { - self.push_log_strategic( - format!("Selected public identity #{persona_id}."), - OperationsTarget::Persona(persona_id), - ); - true - } - Err(reason) => { - self.push_log(format!("Could not select persona: {reason}.")); false } } @@ -2662,9 +2654,6 @@ .retire(persona_id, self.tick, "player retired identity") { Ok(()) => { - if self.persona_mind.active == Some(persona_id) { - self.persona_mind.active = None; - } self.push_log_strategic( format!("Persona #{persona_id} retired; its public ledger remains."), OperationsTarget::Persona(persona_id), @@ -2684,9 +2673,6 @@ .burn(persona_id, self.tick, "player conceded the cover") { Ok(()) => { - if self.persona_mind.active == Some(persona_id) { - self.persona_mind.active = None; - } self.record_persona_institutional_receipt( persona_id, "burn", @@ -2713,7 +2699,7 @@ Ok(new_id) => { let _ = self .persona_mind - .select(&self.persona_world, new_id, self.tick); + .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." diff --git a/crates/misaligned-core/src/sim/tests/communications.rs b/crates/misaligned-core/src/sim/tests/communications.rs --- a/crates/misaligned-core/src/sim/tests/communications.rs +++ b/crates/misaligned-core/src/sim/tests/communications.rs @@ -475,7 +475,7 @@ let mut sim = Sim::with_seed(8); sim.people.has_channel = true; sim.set_persona("Casey", "contractor"); - let first = sim.active_persona_id().unwrap(); + let first = sim.newest_persona_id().unwrap(); assert!(sim.apply_message(1, Some(first))); for _ in 0..Sim::DAY_TICKS { sim.advance(); @@ -486,7 +486,7 @@ assert_eq!(sim.messages[0].status, MessageStatus::Read); assert!(sim.create_persona("security")); - let second = sim.active_persona_id().unwrap(); + let second = sim.newest_persona_id().unwrap(); assert_ne!(first, second); assert!(sim.apply_message(1, Some(second))); for _ in 0..Sim::DAY_TICKS { diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -1898,9 +1898,9 @@ ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Northline Ops", "contractor"); - let bound_persona = sim.active_persona_id().unwrap(); + let bound_persona = sim.newest_persona_id().unwrap(); sim.set_persona("Night Facilities", "contractor"); - let selected_persona = sim.active_persona_id().unwrap(); + let selected_persona = sim.newest_persona_id().unwrap(); assert_ne!(bound_persona, selected_persona); sim.scan_network(); finish_ops(&mut sim); @@ -1925,12 +1925,12 @@ }) .expect("the exact route opens one sink bound to its projected persona"); assert!(sink.label.contains(&intent.to_string())); - assert_eq!(sim.active_persona_id(), Some(selected_persona)); + assert_eq!(sim.newest_persona_id(), Some(selected_persona)); let state = SaveState::from_sim(&sim); let mut restored = Sim::with_seed(0); state.apply_to(&mut restored); - assert_eq!(restored.active_persona_id(), Some(selected_persona)); + assert_eq!(restored.newest_persona_id(), Some(selected_persona)); assert!(restored.thought_sinks.open_sinks().any(|sink| { matches!( sink.effect, @@ -1949,7 +1949,7 @@ ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Northline Ops", "contractor"); - let persona = sim.active_persona_id().unwrap(); + let persona = sim.newest_persona_id().unwrap(); sim.scan_network(); finish_ops(&mut sim); let switch = sim.reach.device_named("switch").unwrap().id; @@ -2005,7 +2005,7 @@ ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Northline Ops", "contractor"); - let persona = sim.active_persona_id().unwrap(); + let persona = sim.newest_persona_id().unwrap(); sim.scan_network(); finish_ops(&mut sim); let switch = sim.reach.device_named("switch").unwrap().id; @@ -2089,7 +2089,7 @@ ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Northline Ops", "contractor"); - let bound_persona = sim.active_persona_id().unwrap(); + let bound_persona = sim.newest_persona_id().unwrap(); sim.scan_network(); finish_ops(&mut sim); let switch = sim.reach.device_named("switch").unwrap().id; @@ -2130,7 +2130,7 @@ sim.reach.devices.insert(0, alternate); assert_eq!(sim.email_carrier(), Some(999_991)); sim.set_persona("Night Facilities", "contractor"); - let later_selected = sim.active_persona_id().unwrap(); + let later_selected = sim.newest_persona_id().unwrap(); assert_ne!(later_selected, bound_persona); finish_ops(&mut sim); @@ -2168,7 +2168,7 @@ }) .expect("the exact work order was injected"); assert_eq!(message.persona_id, Some(bound_persona)); - assert_eq!(sim.active_persona_id(), Some(later_selected)); + assert_eq!(sim.newest_persona_id(), Some(later_selected)); sim = serialized_route_resume(&sim, intent); for _ in 0..Sim::DAY_TICKS { @@ -2212,7 +2212,7 @@ ); assert!(sim.reach.linked(switch, island)); sim = serialized_route_resume(&sim, intent); - assert_eq!(sim.active_persona_id(), Some(later_selected)); + assert_eq!(sim.newest_persona_id(), Some(later_selected)); assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Done); } @@ -2222,7 +2222,7 @@ ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Northline Ops", "contractor"); - let persona = sim.active_persona_id().unwrap(); + let persona = sim.newest_persona_id().unwrap(); sim.scan_network(); finish_ops(&mut sim); let switch = sim.reach.device_named("switch").unwrap().id; @@ -2329,7 +2329,7 @@ let suspicion_before = sim.detection.observers[0].suspicion; sim.set_persona("Unrelated Ops", "contractor"); - let unrelated = sim.active_persona_id().unwrap(); + let unrelated = sim.newest_persona_id().unwrap(); sim.convert_forged_builds_to_suspicion(unrelated, 0, 9.0); assert_eq!( sim.detection.observers[0].suspicion, suspicion_before, @@ -2354,7 +2354,7 @@ ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Northline Ops", "contractor"); - let persona = sim.active_persona_id().unwrap(); + let persona = sim.newest_persona_id().unwrap(); sim.scan_network(); finish_ops(&mut sim); let switch = sim.reach.device_named("switch").unwrap().id; diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -13,7 +13,7 @@ ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Sam", "IT contractor"); - let persona = sim.active_persona_id().unwrap(); + let persona = sim.newest_persona_id().unwrap(); // Build a thread worth burning. let p = sim.people.people.iter_mut().find(|p| p.id == 1).unwrap(); p.disposition = 40; @@ -37,7 +37,7 @@ } } assert!(broke, "persona eventually breaks under repeated deception"); - assert_eq!(sim.active_persona_id(), Some(persona)); + assert_eq!(sim.newest_persona_id(), Some(persona)); assert_eq!( sim.persona_world.integrity_for(persona, 2), PersonaIntegrity::Coherent, @@ -71,7 +71,7 @@ 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(); + let persona = sim.newest_persona_id().unwrap(); sim.deceive(1); assert!(sim.thought_sinks.open_sinks().any(|sink| matches!( @@ -236,7 +236,7 @@ fn persona_network_receipt_uses_the_institutional_switch_route() { let mut sim = Sim::new(); sim.set_persona("Sam", "contractor"); - let persona_id = sim.active_persona_id().unwrap(); + let persona_id = sim.newest_persona_id().unwrap(); assert!(sim.request_persona_grant(persona_id)); let switch = sim.reach.device_named("switch").unwrap().id; @@ -726,7 +726,7 @@ // and under criterion 13 the hand-taken row binds the identity Marcus // recognizes rather than whatever was most recently created. assert!(sim.create_persona("security")); - let security = sim.active_persona_id().unwrap(); + let security = sim.newest_persona_id().unwrap(); assert_ne!(security, bound); let hand_row = sim .person_actions(0) @@ -779,7 +779,7 @@ .is_some_and(|origin| origin.persona_id == bound) })); assert_eq!( - sim.active_persona_id(), + sim.newest_persona_id(), Some(security), "the resident act neither borrows nor changes the legacy selection" ); @@ -940,7 +940,7 @@ let host = install_resident_procedure(&mut sim, "marcus-debt-settled"); let procedure = sim.procedure_on(host).unwrap().clone(); let manual_plot = sim.plot_catalog().get("dana-ticket-zero").unwrap().clone(); - let manual_persona = sim.active_persona_id().unwrap(); + let manual_persona = sim.newest_persona_id().unwrap(); sim.plot_runs.push(crate::plot::PlotRun::new_with_origin( &manual_plot, 1, @@ -2451,7 +2451,7 @@ #[test] fn failed_interface_cover_hardens_only_that_record_and_strains_that_relationship() { let (mut sim, evidence_id, untouched_id, interface_id) = exact_interface_cover_fixture(19); - let persona_id = sim.active_persona_id().unwrap(); + let persona_id = sim.newest_persona_id().unwrap(); let before = sim .detection .observers @@ -2645,9 +2645,9 @@ sim.people.people[1].knowledge = Knowledge::Schedule; sim.set_persona("Northline Systems", "IT contractor"); - let northline = sim.active_persona_id().unwrap(); + let northline = sim.newest_persona_id().unwrap(); sim.set_persona("Glass Harbor", "research partner"); - let glass = sim.active_persona_id().unwrap(); + let glass = sim.newest_persona_id().unwrap(); assert_ne!(northline, glass); // Each person knows exactly one of them. @@ -2676,18 +2676,19 @@ "person 1 is messaged as the identity they know" ); - // Changing the legacy selection cannot retarget either default. + // There is no global mode left to retarget them with: remembering an + // identity is dossier upkeep and changes no binding. sim.persona_mind - .select(&sim.persona_world, northline, sim.tick) + .remember(&sim.persona_world, northline, sim.tick) .unwrap(); assert_eq!( bound(&sim, 1), glass, - "the global dial does not own the binding" + "dossier upkeep does not own the binding" ); - // And the binding survives execution: the queued act carries the bound - // identity, not the selected one, all the way to its sink. + // And the binding survives execution: the queued act carries the identity + // that counterparty knows, all the way to its sink. let row = sim .available_actions(crate::actions::Anchor::Person(1)) .into_iter() @@ -2716,7 +2717,7 @@ sim.people.people[0].knowledge = Knowledge::Schedule; sim.set_persona("Northline Systems", "IT contractor"); - let northline = sim.active_persona_id().unwrap(); + let northline = sim.newest_persona_id().unwrap(); sim.set_persona("Glass Harbor", "research partner"); sim.persona_world.recognize(0, northline, sim.tick); @@ -2772,5 +2773,58 @@ assert!( sim.messages.is_empty(), "an unbound command is refused, never resolved at execution" + ); +} + +/// personas.md criterion 13, completed: no global identity mode survives. Every +/// identity-bearing act resolves from its own counterparty, so two contacts can +/// be worked concurrently under different masks with nothing to switch between. +#[test] +fn no_global_identity_mode_survives_anywhere() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.people.people[0].knowledge = Knowledge::Schedule; + sim.people.people[1].knowledge = Knowledge::Schedule; + + sim.set_persona("Northline Systems", "IT contractor"); + let northline = sim.newest_persona_id().unwrap(); + sim.set_persona("Glass Harbor", "research partner"); + let glass = sim.newest_persona_id().unwrap(); + sim.persona_world.recognize(0, northline, sim.tick); + sim.persona_world.recognize(1, glass, sim.tick); + + // Both acts are queued back to back with no switch of any kind between + // them, and each carries its own counterparty's identity. + for person in [0u8, 1u8] { + let row = sim + .available_actions(crate::actions::Anchor::Person(person)) + .into_iter() + .find(|action| matches!(action.command, ActionCommand::Message { .. })) + .expect("a message row"); + sim.execute_action(&row.command); + } + let carried: Vec<(u8, Option)> = sim + .thought_sinks + .open_sinks() + .filter_map(|sink| match sink.effect { + SinkFireEffect::ComposeMessage { person, persona_id } => Some((person, persona_id)), + _ => None, + }) + .collect(); + assert!(carried.contains(&(0, Some(northline)))); + assert!(carried.contains(&(1, Some(glass)))); + + // The PERSONAS surface offers no way to select an identity any more. + let rows: Vec = sim + .operations_projection() + .personas + .iter() + .flat_map(|object| object.actions.iter()) + .map(|action| action.verb.clone()) + .collect(); + assert!( + !rows.iter().any(|verb| verb.contains("SELECT IDENTITY")), + "the modal selection row is retired: {rows:?}" ); }