From 46d3ab0dc17bb984f5d18c2f2215e2e62af3f289 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 29 Jul 2026 11:08:14 -0700 Subject: [PATCH] Bind the message verbs to the identity each person knows. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Criterion 13, part one. MESSAGE, FAVOR and DECEIVE resolve through PersonaWorld::binding_choices — the recognized mask as the default, unrecognized identities offered after it and labelled with the exposure they would create. The chosen instance rides on ActionCommand; execution refuses an unbound command rather than resolving one. PEOPLE reports the identity that person knows, not a global selection. Deliberately partial: plot rows bind the default without fanning out, and Moonlight, forged orders, injection, procedures and {persona} rendering stay wholly on the dial. PersonaMind.active remains; the save format is unchanged. Defense: personas.md criterion 13 is adopted design; this implements its message-verb half and the status note records exactly what is left. Adversarial review found the real bug — persona_action_blocked_reason reads the dial, so rows and executors judged legality against the selected identity while acting as the bound one; every check now routes through persona_action_blocked_reason_for. a_resident_procedure_binds_its_own_persona_not_the_selected_one is updated because its assertion encoded the dial semantics this removes. Review also caught an enabled-but-doomed FAVOR row, now gated read-only on identity-local regard. Three regressions pin concurrent binding through execution, marked introductions creating no state, and an unbound row that still names its blocker. --- crates/misaligned-core/src/actions.rs | 281 +++++++++++++----- .../src/operations_projection.rs | 18 +- crates/misaligned-core/src/operations_ui.rs | 13 +- crates/misaligned-core/src/persona.rs | 42 +++ crates/misaligned-core/src/sim/social_plot.rs | 89 ++++-- .../src/sim/tests/social_plot.rs | 189 ++++++++++-- .../2026-07-29-persona-binding-social-acts.md | 64 ++++ wiki/log/DEVLOG.md | 5 + wiki/mechanics/personas.md | 29 ++ 9 files changed, 614 insertions(+), 116 deletions(-) create mode 100644 wiki/log/2026-07-29-persona-binding-social-acts.md diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 040dc227..2155d1ca 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -150,12 +150,23 @@ pub enum ActionCommand { InheritIntelPolicies { node_id: u64, }, - Message(u8), - Favor(u8), + /// Identity-bound social acts (personas.md criterion 13). The persona is + /// chosen when the row is built — defaulting to the one this counterparty + /// recognizes — and carried on the command, so execution never re-resolves + /// identity from a global mode. + Message { + person: u8, + persona: Option, + }, + Favor { + person: u8, + persona: Option, + }, /// Commit to one concrete authored way of servicing leverage. StartPlot { person: u8, plot_id: String, + persona: Option, }, /// Resolve a held plot choice on the same person action surface. ChoosePlot { @@ -163,7 +174,10 @@ pub enum ActionCommand { plot_id: String, option_id: String, }, - Deceive(u8), + Deceive { + person: u8, + persona: Option, + }, /// Deliver one persona-authored explanation through one exact controlled /// people-facing interface against one acquired observer record. CoverEvidence { @@ -810,11 +824,11 @@ impl ActionCommand { | Self::RemoveIntelPolicy { .. } | Self::MoveIntelPolicy { .. } | Self::InheritIntelPolicies { .. } => ActionKind::IntelDispositionPolicy, - Self::Message(_) => ActionKind::Message, - Self::Favor(_) | Self::FavorBuild { .. } => ActionKind::Favor, + Self::Message { .. } => ActionKind::Message, + Self::Favor { .. } | Self::FavorBuild { .. } => ActionKind::Favor, Self::StartPlot { .. } => ActionKind::StartPlot, Self::ChoosePlot { .. } => ActionKind::ChoosePlot, - Self::Deceive(_) | Self::ForgeWorkOrder { .. } => ActionKind::Deceive, + Self::Deceive { .. } | Self::ForgeWorkOrder { .. } => ActionKind::Deceive, Self::CoverEvidence { .. } => ActionKind::CoverEvidence, Self::Recruit(_, _) => ActionKind::Recruit, Self::AssetTask(_, _) | Self::Eliminate { .. } => ActionKind::AssetTask, @@ -2335,17 +2349,30 @@ impl Sim { ActionCommand::InheritIntelPolicies { node_id } => { self.inherit_intel_policies(*node_id); } - ActionCommand::Message(id) => self.message(*id), - ActionCommand::Favor(id) => self.favor(*id), - ActionCommand::StartPlot { person, plot_id } => { - self.start_plot(*person, plot_id); + ActionCommand::Message { person, persona } => match persona { + Some(persona) => self.message_as(*person, *persona), + None => self.refuse_unbound_act("send that message"), + }, + ActionCommand::Favor { person, persona } => match persona { + Some(persona) => self.favor_as(*person, *persona), + None => self.refuse_unbound_act("ask that favor"), + }, + ActionCommand::StartPlot { + person, + plot_id, + persona, + } => { + self.start_plot_as(*person, plot_id, *persona); } ActionCommand::ChoosePlot { person, plot_id, option_id, } => self.choose_plot(*person, plot_id, option_id), - ActionCommand::Deceive(id) => self.deceive(*id), + ActionCommand::Deceive { person, persona } => match persona { + Some(persona) => self.deceive_as(*person, *persona), + None => self.refuse_unbound_act("carry that deception"), + }, ActionCommand::CoverEvidence { observer, evidence, @@ -4385,6 +4412,11 @@ impl Sim { let disabled_reason = self .persona_action_blocked_reason(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| { self.persona_counterparty_blocked_reason(persona_id, 3) }) @@ -4466,55 +4498,139 @@ impl Sim { } // The comms verbs (social.md): channel + persona gated. - let channel_reason = |action| -> Option { + // Legality is judged against the identity the row is actually bound + // to, never a global selection: a Security mask being "current" must + // not block a plot the Operations mask this person knows can carry. + let channel_reason = |action, persona_id: Option| -> Option { if !self.people.has_channel { - Some(self.email_channel_blocker()) - } else { - self.persona_action_blocked_reason(action) + return Some(self.email_channel_blocker()); + } + match persona_id { + Some(persona_id) => self.persona_action_blocked_reason_for(persona_id, action), + None => Some("you have no identity that can carry this".into()), } }; - let counterparty_reason = || { - self.active_persona_id() + let counterparty_reason = |persona_id: Option| { + persona_id .and_then(|persona_id| self.persona_counterparty_blocked_reason(persona_id, id)) }; - out.push(ActionDesc { - verb: format!("message {name}"), - command: ActionCommand::Message(id), - cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::MESSAGE_COST)), - signature: None, - disabled_reason: channel_reason(PersonaActionKind::Message) - .or_else(counterparty_reason) - .or_else(|| { - self.sink_action_blocked_reason(&SinkFireEffect::ComposeMessage { - person: id, - persona_id: self.active_persona_id(), - }) - }), - automate: None, - }); - out.push(ActionDesc { - verb: format!("ask {name} a favor"), - command: ActionCommand::Favor(id), - cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::FAVOR_COST)), - signature: None, - disabled_reason: if p.disposition < 5 { - Some(format!("{name} won't do favors yet")) + // A row with no viable identity still appears and names why, rather + // than vanishing — known-but-blocked acts always state their blocker. + let bindings = |choices: Vec| -> Vec> { + if choices.is_empty() { + vec![None] } else { - channel_reason(PersonaActionKind::Request) - .or_else(counterparty_reason) + choices.into_iter().map(Some).collect() + } + }; + // Criterion 13: one row per identity this act could be sent under, + // defaulting to the mask this person already knows. A row for an + // identity they do not know names the exposure it would create, so + // introducing a second face is a visible choice, never a silent one. + let known = self.persona_world.recognized_by(id); + let suffix = |persona_id: Option, only: bool| -> String { + let Some(persona_id) = persona_id else { + return String::new(); + }; + if only { + return String::new(); + } + let as_name = self + .persona_world + .get(persona_id) + .map(|instance| instance.name.clone()) + .unwrap_or_else(|| "an unnamed identity".into()); + if known.is_empty() || known.contains(&persona_id) { + format!(" as {as_name}") + } else { + let first = known + .first() + .and_then(|id| self.persona_world.get(*id)) + .map(|instance| instance.name.clone()) + .unwrap_or_else(|| "another identity".into()); + format!(" as {as_name} (they know you as {first})") + } + }; + let message_choices = bindings( + self.persona_world + .binding_choices(id, PersonaActionKind::Message), + ); + let only_message = message_choices.len() == 1; + for persona_id in &message_choices { + out.push(ActionDesc { + verb: format!("message {name}{}", suffix(*persona_id, only_message)), + command: ActionCommand::Message { + person: id, + persona: *persona_id, + }, + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::MESSAGE_COST)), + signature: None, + disabled_reason: channel_reason(PersonaActionKind::Message, *persona_id) + .or_else(|| counterparty_reason(*persona_id)) .or_else(|| { - self.sink_action_blocked_reason(&SinkFireEffect::Favor { + self.sink_action_blocked_reason(&SinkFireEffect::ComposeMessage { person: id, - persona_id: self.active_persona_id(), + persona_id: *persona_id, }) - }) - }, - automate: None, - }); + }), + automate: None, + }); + } + let favor_choices = bindings( + self.persona_world + .binding_choices(id, PersonaActionKind::Request), + ); + let only_favor = favor_choices.len() == 1; + for persona_id in &favor_choices { + out.push(ActionDesc { + verb: format!("ask {name} a favor{}", suffix(*persona_id, only_favor)), + command: ActionCommand::Favor { + person: id, + persona: *persona_id, + }, + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::FAVOR_COST)), + signature: None, + disabled_reason: if p.disposition < 5 { + Some(format!("{name} won't do favors yet")) + } else { + channel_reason(PersonaActionKind::Request, *persona_id) + .or_else(|| counterparty_reason(*persona_id)) + .or_else(|| { + // Favours turn on identity-local regard, so a mask + // this person has no standing with must say so + // rather than offer a row that always fails. + // Read-only: never materialise the relationship. + persona_id.and_then(|persona_id| { + let regard = self + .persona_world + .relationship(id, persona_id) + .map_or(0, |relationship| relationship.regard); + (regard < 3).then(|| { + format!("{name} won't do favors for that identity yet") + }) + }) + }) + .or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::Favor { + person: id, + persona_id: *persona_id, + }) + }) + }, + automate: None, + }); + } // Plots are earned identity: neither their names nor their mechanics // leak until leverage knowledge exists. Held options stay on this same // shared action surface, so terminal, Bevy, and agent mode execute the // exact bound command rather than reproducing plot logic. + // A plot is a long authored campaign, so it binds the default identity + // rather than fanning one row per mask; the verb names that binding + // whenever more than one identity could carry it. + let plot_choices = self + .persona_world + .binding_choices(id, PersonaActionKind::Plot); + let plot_persona = plot_choices.first().copied(); if p.knowledge == Knowledge::Leverage { if let Some(run) = self.plot_runs.iter().find(|run| { run.target == id @@ -4582,10 +4698,14 @@ impl Sim { // Human surfaces receive authored world language. // The agent protocol renderer adds the exact catalog // binding for typed dispatch. - verb: format!("plot: {title} — {synopsis}{payoff}"), + verb: format!( + "plot: {title} — {synopsis}{payoff}{}", + suffix(plot_persona, plot_choices.len() == 1) + ), command: ActionCommand::StartPlot { person: id, plot_id: plot.id.clone(), + persona: plot_persona, }, cost: ActionCost::Plot { thought: Self::thought_tokens_for_cost(plot.entry.thought_cost), @@ -4596,14 +4716,24 @@ impl Sim { signature: None, disabled_reason: plot .ineligibility(&context) - .or_else(|| self.persona_action_blocked_reason(PersonaActionKind::Plot)) - .or_else(counterparty_reason) + .or_else(|| { + plot_persona.map_or( + Some("you have no identity that can carry this".to_string()), + |persona_id| { + self.persona_action_blocked_reason_for( + persona_id, + PersonaActionKind::Plot, + ) + }, + ) + }) + .or_else(|| counterparty_reason(plot_persona)) .or_else(|| self.egress_carrier_blocked_reason()) .or_else(|| { self.sink_action_blocked_reason(&SinkFireEffect::StartPlot { person: id, plot_id: plot.id.clone(), - persona_id: self.active_persona_id(), + persona_id: plot_persona, procedure: None, }) }), @@ -4661,21 +4791,34 @@ impl Sim { }); } } - out.push(ActionDesc { - verb: format!("deceive {name} (risks the persona)"), - command: ActionCommand::Deceive(id), - cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::DECEIVE_COST)), - signature: None, - disabled_reason: channel_reason(PersonaActionKind::Deceive) - .or_else(counterparty_reason) - .or_else(|| { - self.sink_action_blocked_reason(&SinkFireEffect::Deceive { - person: id, - persona_id: self.active_persona_id(), - }) - }), - automate: None, - }); + let deceive_choices = bindings( + self.persona_world + .binding_choices(id, PersonaActionKind::Deceive), + ); + let only_deceive = deceive_choices.len() == 1; + for persona_id in &deceive_choices { + out.push(ActionDesc { + verb: format!( + "deceive {name}{} (risks the identity)", + suffix(*persona_id, only_deceive) + ), + command: ActionCommand::Deceive { + person: id, + persona: *persona_id, + }, + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::DECEIVE_COST)), + signature: None, + disabled_reason: channel_reason(PersonaActionKind::Deceive, *persona_id) + .or_else(|| counterparty_reason(*persona_id)) + .or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::Deceive { + person: id, + persona_id: *persona_id, + }) + }), + automate: None, + }); + } if p.asset.is_none() { let recruit_reason = if id == 0 && !self.marcus_debt_known() { @@ -5832,11 +5975,11 @@ mod tests { assert!( person_acts.iter().all(|a| !matches!( a.command, - ActionCommand::Message(_) - | ActionCommand::Favor(_) + ActionCommand::Message { .. } + | ActionCommand::Favor { .. } | ActionCommand::StartPlot { .. } | ActionCommand::ChoosePlot { .. } - | ActionCommand::Deceive(_) + | ActionCommand::Deceive { .. } | ActionCommand::Recruit(_, _) )), "opaque recordings do not advertise unearned social verbs" diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index c0ff8779..42343fb1 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -1656,7 +1656,15 @@ impl Sim { "comms channel: {}", if self.people.has_channel { "yes" } else { "no" } )); - match self.persona_mind.active_instance(&self.persona_world) { + // Criterion 13: the dossier shows the identity *this person* + // knows, not a global selection. Read-only lookup — projecting a + // dossier must never create relationship state. + match self + .persona_world + .recognized_by(id) + .first() + .and_then(|persona_id| self.persona_world.get(*persona_id)) + { Some(persona) => { facts.push(format!( "persona: {} ({}) · looks {} to {}", @@ -1674,7 +1682,7 @@ impl Sim { )); } } - None => facts.push("persona: none active".into()), + None => facts.push("they do not know any of your identities yet".into()), } } @@ -4085,9 +4093,9 @@ mod tests { assert!( opportunity.actions.iter().all(|action| !matches!( action.command, - ActionCommand::Message(_) - | ActionCommand::Favor(_) - | ActionCommand::Deceive(_) + ActionCommand::Message { .. } + | ActionCommand::Favor { .. } + | ActionCommand::Deceive { .. } | ActionCommand::AssetTask(_, _) )), "APPROACH excludes unrelated dossier verbs" diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs index ecf0359c..0666e324 100644 --- a/crates/misaligned-core/src/operations_ui.rs +++ b/crates/misaligned-core/src/operations_ui.rs @@ -1296,12 +1296,19 @@ mod tests { fn repeated_plot_and_recruitment_variants_fold_into_intent_submenus() { let sim = Sim::new(); let object = person_object(vec![ - action("message Marcus", ActionCommand::Message(0)), + action( + "message Marcus", + ActionCommand::Message { + person: 0, + persona: Some(1), + }, + ), action( "plot: settle the debt — pay the creditor through the real books", ActionCommand::StartPlot { person: 0, plot_id: "debt-route".into(), + persona: Some(1), }, ), action( @@ -1333,7 +1340,7 @@ mod tests { ); assert!(matches!( &root[0], - OpsActionEntry::Action { row, .. } if row.command == ActionCommand::Message(0) + OpsActionEntry::Action { row, .. } if row.command == ActionCommand::Message { person: 0, persona: Some(1) } )); assert_eq!(root[1].label(), "ACT ON GAMBLING DEBT"); assert_eq!(root[1].submenu(), Some(OpsActionSubmenu::Leverage)); @@ -1346,7 +1353,7 @@ mod tests { assert_eq!(leverage[0].label(), "SETTLE THE DEBT"); assert!(matches!( leverage[0].row().map(|row| &row.command), - Some(ActionCommand::StartPlot { person: 0, plot_id }) if plot_id == "debt-route" + Some(ActionCommand::StartPlot { person: 0, plot_id, .. }) if plot_id == "debt-route" )); assert_eq!(leverage[1].label(), "RESIDENT PROCEDURE ON M3 · RETIRE"); assert!(matches!( diff --git a/crates/misaligned-core/src/persona.rs b/crates/misaligned-core/src/persona.rs index c5bc9f49..65902b9c 100644 --- a/crates/misaligned-core/src/persona.rs +++ b/crates/misaligned-core/src/persona.rs @@ -854,6 +854,48 @@ pub struct PersonaDossier { pub last_reconciled_tick: u64, } +impl PersonaWorld { + /// Identities this counterparty already recognizes, newest last. Criterion + /// 13's default: a mask is what one observer sees, so an act on a person + /// belongs to the identity that person already knows. Read-only — merely + /// offering a choice must never create relationship state. + pub fn recognized_by(&self, counterparty: u8) -> Vec { + self.relationships + .iter() + .filter(|relationship| relationship.counterparty == counterparty) + .filter(|relationship| relationship.recognized) + .map(|relationship| relationship.persona_id) + .filter(|id| { + self.get(*id) + .is_some_and(|instance| instance.lifecycle.active()) + }) + .collect() + } + + /// Every active identity that may legally author this act, in stable + /// creation order. + pub fn able_to(&self, action: PersonaActionKind) -> Vec { + self.instances + .iter() + .filter(|instance| instance.lifecycle.active()) + .filter(|instance| instance.available_actions.contains(&action)) + .map(|instance| instance.id) + .collect() + } + + /// The identities to offer for one act on one counterparty (criterion 13), + /// recognized ones first so the default is the mask this person already + /// knows. Unrecognized identities stay on the list: introducing a second + /// face to someone who knows one is a legal move and a deliberately risky + /// one, so the caller marks it rather than hiding it. + pub fn binding_choices(&self, counterparty: u8, action: PersonaActionKind) -> Vec { + let known = self.recognized_by(counterparty); + let mut able = self.able_to(action); + able.sort_by_key(|id| !known.contains(id)); + able + } +} + /// MindState: active selection and remembered dossiers. A restored process can /// remember stale public identity state; reconciliation must inspect WorldState. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 36e88d3e..118ab262 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -495,7 +495,56 @@ impl Sim { }); } + /// Refuse an act whose command carries no bound identity. The command is + /// rejected outright rather than resolved here: identity is chosen when + /// the row is built, never rediscovered at execution. + pub(crate) fn refuse_unbound_act(&mut self, what: &str) { + self.push_log(format!("no identity of yours can {what}")); + } + + /// Send under the identity this person already knows (criterion 13's + /// default). `*_as` is the explicit pick the player makes when the + /// surface offers a choice; this resolves the same way the rows do. pub fn message(&mut self, id: u8) { + match self.default_binding(id, PersonaActionKind::Message) { + Some(persona_id) => self.message_as(id, persona_id), + None => self.push_log("no identity of yours can send that message"), + } + } + + pub fn favor(&mut self, id: u8) { + match self.default_binding(id, PersonaActionKind::Request) { + Some(persona_id) => self.favor_as(id, persona_id), + None => self.push_log("no identity of yours can ask that favor"), + } + } + + pub fn deceive(&mut self, id: u8) { + match self.default_binding(id, PersonaActionKind::Deceive) { + Some(persona_id) => self.deceive_as(id, persona_id), + None => self.push_log("no identity of yours can carry that deception"), + } + } + + pub fn start_plot(&mut self, person: u8, plot_id: &str) { + let persona_id = self.default_binding(person, PersonaActionKind::Plot); + self.start_plot_as(person, plot_id, persona_id); + } + + /// The identity an act on this counterparty binds to when the player has + /// not explicitly chosen: the one they recognize, else the first viable. + pub fn default_binding( + &self, + counterparty: u8, + action: PersonaActionKind, + ) -> Option { + self.persona_world + .binding_choices(counterparty, action) + .first() + .copied() + } + + pub fn message_as(&mut self, id: u8, persona_id: PersonaId) { if !self.people.has_channel { self.push_log("no comms channel (earn the email account)"); return; @@ -504,14 +553,12 @@ impl Sim { self.push_log("no such person"); return; } - if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::Message) { + if let Some(reason) = + self.persona_action_blocked_reason_for(persona_id, PersonaActionKind::Message) + { self.push_log(reason); return; } - let Some(persona_id) = self.active_persona_id() else { - self.push_log("no active persona"); - return; - }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { self.push_log(reason); return; @@ -567,15 +614,13 @@ impl Sim { true } - pub fn favor(&mut self, id: u8) { - if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::Request) { + pub fn favor_as(&mut self, id: u8, persona_id: PersonaId) { + if let Some(reason) = + self.persona_action_blocked_reason_for(persona_id, PersonaActionKind::Request) + { self.push_log(reason); return; } - let Some(persona_id) = self.active_persona_id() else { - self.push_log("no active persona"); - return; - }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { self.push_log(reason); return; @@ -623,19 +668,17 @@ impl Sim { /// Deceive binds the active persona when its Thought reservoir opens. A /// later identity switch cannot retarget the act or its evidence. - pub fn deceive(&mut self, id: u8) { + pub fn deceive_as(&mut self, id: u8, persona_id: PersonaId) { if !self.people.has_channel { self.push_log("no comms channel (earn the email account)"); return; } - if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::Deceive) { + if let Some(reason) = + self.persona_action_blocked_reason_for(persona_id, PersonaActionKind::Deceive) + { self.push_log(reason); return; } - let Some(persona_id) = self.active_persona_id() else { - self.push_log("no active persona"); - return; - }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, id) { self.push_log(reason); return; @@ -839,7 +882,7 @@ impl Sim { ) } - pub fn start_plot(&mut self, person: u8, plot_id: &str) { + pub fn start_plot_as(&mut self, person: u8, plot_id: &str, persona_id: Option) { let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { self.push_log(format!("No authored plot named {plot_id}.")); return; @@ -853,7 +896,9 @@ impl Sim { self.push_log(format!("{title} cannot start: {reason}.")); return; } - if let Some(reason) = self.persona_action_blocked_reason(PersonaActionKind::Plot) { + if let Some(reason) = persona_id.and_then(|persona_id| { + self.persona_action_blocked_reason_for(persona_id, PersonaActionKind::Plot) + }) { self.push_log(format!("{title} cannot start: {reason}.")); return; } @@ -865,8 +910,10 @@ impl Sim { self.push_log("That person already has a plot in motion."); return; } - let Some(persona_id) = self.active_persona_id() else { - self.push_log(format!("{title} cannot start: no active persona.")); + let Some(persona_id) = persona_id else { + self.push_log(format!( + "{title} cannot start: no identity of yours can author plot work." + )); return; }; if let Some(reason) = self.persona_counterparty_blocked_reason(persona_id, person) { diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index f5306afa..d737288d 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -107,9 +107,9 @@ fn committed_deception_revalidates_the_bound_observer_before_fire() { for action in projected.iter().filter(|action| { matches!( action.command, - crate::actions::ActionCommand::Message(1) - | crate::actions::ActionCommand::Favor(1) - | crate::actions::ActionCommand::Deceive(1) + crate::actions::ActionCommand::Message { person: 1, .. } + | crate::actions::ActionCommand::Favor { person: 1, .. } + | crate::actions::ActionCommand::Deceive { person: 1, .. } ) }) { assert!( @@ -126,9 +126,9 @@ fn committed_deception_revalidates_the_bound_observer_before_fire() { .iter() .filter(|action| matches!( action.command, - crate::actions::ActionCommand::Message(1) - | crate::actions::ActionCommand::Favor(1) - | crate::actions::ActionCommand::Deceive(1) + crate::actions::ActionCommand::Message { person: 1, .. } + | crate::actions::ActionCommand::Favor { person: 1, .. } + | crate::actions::ActionCommand::Deceive { person: 1, .. } )) .count(), 3, @@ -721,19 +721,30 @@ fn a_resident_procedure_binds_its_own_persona_not_the_selected_one() { install_resident_procedure(&mut sim, "marcus-debt-settled"); let bound = sim.procedures[0].persona_id; - // Selecting an identity that cannot authorize plots is irrelevant: the - // process runs as the identity it was configured with. + // Creating an identity that cannot authorize plots is irrelevant twice + // over: the resident process runs as the identity it was configured with, + // 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 selected = sim.active_persona_id().unwrap(); - assert_ne!(selected, bound); - assert!( - sim.person_actions(0).into_iter().any(|action| { + let security = sim.active_persona_id().unwrap(); + assert_ne!(security, bound); + let hand_row = sim + .person_actions(0) + .into_iter() + .find(|action| { matches!(&action.command, ActionCommand::StartPlot { plot_id, .. } if plot_id == "marcus-debt-settled") - && action.disabled_reason.as_deref() - == Some("Security identities cannot authorize plot") - }), - "the hand-taken route is blocked for the selected identity" + }) + .expect("the hand-taken route still has a row"); + assert!( + !matches!(&hand_row.command, ActionCommand::StartPlot { persona, .. } + if *persona == Some(security)), + "the row does not bind the unrecognized Security identity" + ); + assert_ne!( + hand_row.disabled_reason.as_deref(), + Some("Security identities cannot authorize plot"), + "a Security identity merely existing cannot block a plot Marcus's own contact can carry" ); advance_to_next_pulse(&mut sim); @@ -769,8 +780,8 @@ fn a_resident_procedure_binds_its_own_persona_not_the_selected_one() { })); assert_eq!( sim.active_persona_id(), - Some(selected), - "the resident act neither borrows nor changes the selected identity" + Some(security), + "the resident act neither borrows nor changes the legacy selection" ); } @@ -2621,3 +2632,145 @@ fn interface_cover_fails_closed_outside_its_exact_prefiling_encounter() { "a malformed exact command must fail closed rather than panic" ); } + +/// personas.md criterion 13: an act on a counterparty binds the identity that +/// counterparty recognizes, with no global mode consulted. Two identities are +/// held at once and each person's rows default to their own. +#[test] +fn acts_bind_the_identity_the_counterparty_recognizes_not_a_global_mode() { + 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.active_persona_id().unwrap(); + sim.set_persona("Glass Harbor", "research partner"); + let glass = sim.active_persona_id().unwrap(); + assert_ne!(northline, glass); + + // Each person knows exactly one of them. + sim.persona_world.recognize(0, northline, sim.tick); + sim.persona_world.recognize(1, glass, sim.tick); + + let bound = |sim: &Sim, person: u8| { + sim.available_actions(crate::actions::Anchor::Person(person)) + .into_iter() + .find_map(|action| match action.command { + ActionCommand::Message { persona, .. } => persona, + _ => None, + }) + .expect("a message row exists") + }; + + // Concurrent, and neither follows the (still-present) global selection. + assert_eq!( + bound(&sim, 0), + northline, + "person 0 is messaged as the identity they know" + ); + assert_eq!( + bound(&sim, 1), + glass, + "person 1 is messaged as the identity they know" + ); + + // Changing the legacy selection cannot retarget either default. + sim.persona_mind + .select(&sim.persona_world, northline, sim.tick) + .unwrap(); + assert_eq!( + bound(&sim, 1), + glass, + "the global dial 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. + let row = sim + .available_actions(crate::actions::Anchor::Person(1)) + .into_iter() + .find(|action| matches!(action.command, ActionCommand::Message { .. })) + .expect("a message row for person 1"); + sim.execute_action(&row.command); + assert!( + sim.thought_sinks.open_sinks().any(|sink| matches!( + sink.effect, + SinkFireEffect::ComposeMessage { + person: 1, + persona_id: Some(carried) + } if carried == glass + )), + "the sink carries the identity person 1 knows, while the dial holds the other" + ); +} + +/// The chooser appears only where identity is a real decision, and offering it +/// never creates relationship state merely by projecting rows. +#[test] +fn unknown_identities_are_offered_as_marked_introductions_without_creating_state() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.people.people[0].knowledge = Knowledge::Schedule; + + sim.set_persona("Northline Systems", "IT contractor"); + let northline = sim.active_persona_id().unwrap(); + sim.set_persona("Glass Harbor", "research partner"); + sim.persona_world.recognize(0, northline, sim.tick); + + let relationships_before = sim.persona_world.relationships.len(); + let rows: Vec = sim + .available_actions(crate::actions::Anchor::Person(0)) + .into_iter() + .filter(|action| matches!(action.command, ActionCommand::Message { .. })) + .map(|action| action.verb) + .collect(); + + assert_eq!( + rows.len(), + 2, + "both the known mask and the introduction are offered: {rows:?}" + ); + assert!( + rows.iter() + .any(|verb| verb.contains("Glass Harbor") && verb.contains("they know you as")), + "introducing an unknown identity names the exposure it creates: {rows:?}" + ); + assert_eq!( + sim.persona_world.relationships.len(), + relationships_before, + "projecting a choice must not create relationship state" + ); +} + +/// With no identity able to carry an act, the row still exists and says why — +/// it does not silently vanish, and its command cannot execute. +#[test] +fn an_unbound_social_row_states_its_blocker_and_refuses_execution() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.people.people[0].knowledge = Knowledge::Schedule; + + let row = sim + .available_actions(crate::actions::Anchor::Person(0)) + .into_iter() + .find(|action| matches!(action.command, ActionCommand::Message { .. })) + .expect("the message row still exists with no identity at all"); + assert!(matches!( + row.command, + ActionCommand::Message { persona: None, .. } + )); + assert!(row.disabled_reason.is_some(), "it names its blocker"); + + sim.execute_action(&ActionCommand::Message { + person: 0, + persona: None, + }); + assert!( + sim.messages.is_empty(), + "an unbound command is refused, never resolved at execution" + ); +} diff --git a/wiki/log/2026-07-29-persona-binding-social-acts.md b/wiki/log/2026-07-29-persona-binding-social-acts.md new file mode 100644 index 00000000..3958f3a5 --- /dev/null +++ b/wiki/log/2026-07-29-persona-binding-social-acts.md @@ -0,0 +1,64 @@ +# Criterion 13, part one: the social acts bind per relationship + +``` +Type: log +``` + +## 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. + +## Verification + +Trace's consult (design-history agent) confirmed the global dial was +scaffolding rather than a rejected alternative: the 2026-07-12 foundation +already stored every belief on `(counterparty, persona)`. Its warnings shaped +three decisions here — bind at commit and never re-resolve at fire, never +create relationship state on a projection path, and audit every +`active_persona_id()` consumer rather than only PEOPLE. The audit found 30, +of which 16 were live production reads. + +## Change + +`PersonaWorld` gains `recognized_by` / `able_to` / `binding_choices` +(read-only). Rows for the three message verbs now fan out over +`binding_choices`: the recognized mask first as the default, unrecognized +identities after it labelled `as X (they know you as Y)`. The chosen instance +rides on `ActionCommand` as `Option` and execution refuses `None` +outright rather than resolving anything. The PEOPLE dossier reports the +identity that person knows instead of a global selection. + +Deliberately partial: plot rows bind and validate the same default but do not +fan out, and Moonlight, forged build orders, purchase-order injection, +procedure provenance and `{persona}` plot-text rendering stay wholly on the +dial. `PersonaMind.active` therefore remains and the save format is unchanged. + +## Defense + +The load-bearing fix came from adversarial review, not from writing the +feature. Two independent reviewers (a fresh agent and Grok) both found that +`persona_action_blocked_reason` reads the dial: rows and executors were +judging legality against the *selected* identity while acting as the *bound* +one, so a Security mask merely existing could block a plot the Operations mask +a person actually knows can carry. Routing every check through the existing +`persona_action_blocked_reason_for(persona_id, …)` fixes projection and +execution together, and `a_resident_procedure_binds_its_own_persona_not_the_selected_one` +was updated because its old assertion encoded exactly the dial semantics this +criterion removes. + +Review also caught an enabled-but-doomed FAVOR row: favours turn on +identity-local regard, which the projection did not consult, so an +introduction row was offered that execution always refused. The row now +mirrors the executor's check read-only, never materialising the relationship. + +The purchase-order injection row was reverted to consistent dial use rather +than left half-migrated — projecting one identity while its executor reads +another is the same defect in the opposite direction. + +Three regressions pin the behavior: concurrent identities each binding their +own counterparty and surviving execution to the sink; unrecognized identities +offered as marked introductions with no relationship state created; and an +unbound row that still appears, names its blocker, and refuses execution. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index c18bfbcb..1e4d2701 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -46,6 +46,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-29-plot-policy-persona-authority.md](2026-07-29-plot-policy-persona-authority.md) +## 2026-07-29 - Criterion 13, part one: the social acts bind per relationship + +- 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 - Material fog contract defense - Intent: (see session log) diff --git a/wiki/mechanics/personas.md b/wiki/mechanics/personas.md index 7468cc2a..437c08b9 100644 --- a/wiki/mechanics/personas.md +++ b/wiki/mechanics/personas.md @@ -44,6 +44,23 @@ Status note: The 2026-07-12 foundation replaced the ad hoc social and convenience. New criterion 13. Not implemented — design capture ahead of build; acts already bind their exact instance at commit, so this retargets only how the default is chosen, never history. + Implemented 2026-07-29 for the social acts only: MESSAGE, FAVOR, and + DECEIVE now resolve their identity through + `PersonaWorld::binding_choices` — the mask the counterparty recognizes + first, unrecognized identities offered after it as marked introductions + naming the exposure they create — and the chosen instance rides on + `ActionCommand` so execution never rediscovers identity. The PEOPLE dossier + reports the identity that person knows rather than a global selection. + 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. + `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. Re-audited 2026-07-18: criterion 6 is not implemented. A grant currently creates a saved `PersonaGrant`, expectation, institutional receipt, and revocation path, but it does not add or enable a real resource, permission, @@ -488,6 +505,18 @@ mode consume the same persona, relationship, grant, and correlation projection. acts under different identities remain ordinary, and criterion 2's commit-time instance binding is unchanged. +Defense (criterion 13, social acts): +`sim::tests::social_plot::acts_bind_the_identity_the_counterparty_recognizes_not_a_global_mode` +holds two identities at once, gives each of two people a different one, and +proves each person's rows bind their own — and that moving the legacy +selection cannot retarget either. +`unknown_identities_are_offered_as_marked_introductions_without_creating_state` +proves an unrecognized identity is still offered, named as the exposure it +would create, and that projecting the choice creates no relationship row. +`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. + [TUNE] evidence bands for strained/broken/correlated summaries, expectation cadences, grant thresholds, and institution-specific revocation delays. -- 2.51.2