diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index f07a4440..ab89d9b5 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -25,7 +25,10 @@ use crate::intel::{ }; use crate::messages::{MessageOrigin, MessagePayload, MessageStatus}; use crate::person::{Knowledge, Leverage}; -use crate::persona::{self, PersonaId, PersonaLifecycle}; +use crate::persona::{ + self, ExpectationState, PersonaActionKind, PersonaDiscovery, PersonaId, PersonaIntegrity, + PersonaLifecycle, +}; use crate::plot::{PlotRun, PlotState}; use crate::reach::Party; use crate::sim::Sim; @@ -1660,52 +1663,132 @@ impl Sim { } fn personas_view(&self) -> Vec { + // Human copy for the protocol verbs. `None` marks a verb the archetype + // registry declares but no world system honors yet; action-vocabulary.md + // keeps stub entries off every player surface until they are playable, + // so an unhonored verb never reaches a human `can:`/`cannot:` line. It + // stays on the agent-facing registry and on the bound command. + fn act_phrase(action: PersonaActionKind) -> Option<&'static str> { + match action { + PersonaActionKind::Message => Some("send messages"), + PersonaActionKind::Request => Some("make institutional requests"), + PersonaActionKind::Deceive => Some("lie to a counterparty"), + PersonaActionKind::Plot => Some("run an authored plot"), + PersonaActionKind::BuildIntent => Some("order physical work"), + PersonaActionKind::Review => None, + } + } + + // Stable presentation order, widest protocol reach last, so the + // `can:`/`cannot:` split reads the same on every archetype. + const ACT_ORDER: &[PersonaActionKind] = &[ + PersonaActionKind::Message, + PersonaActionKind::Request, + PersonaActionKind::Deceive, + PersonaActionKind::Plot, + PersonaActionKind::BuildIntent, + PersonaActionKind::Review, + ]; + + // What the protocol opens and what it closes, against the same fixed + // list every time, so the player can compare two identities directly + // instead of inferring absence from a registry dump. + fn reach_facts(available: &[PersonaActionKind]) -> Vec { + let (mut can, mut cannot) = (Vec::new(), Vec::new()); + for action in ACT_ORDER { + let Some(phrase) = act_phrase(*action) else { + continue; + }; + if available.contains(action) { + can.push(phrase); + } else { + cannot.push(phrase); + } + } + let mut out = Vec::new(); + if !can.is_empty() { + out.push(format!("can: {}", can.join(", "))); + } + if !cannot.is_empty() { + out.push(format!("cannot: {}", cannot.join(", "))); + } + out + } + + // The standing institutional reading each protocol buys before the + // identity has any history of its own. + fn reads_as(archetype_id: &str) -> &'static str { + match archetype_id { + "research" => "a lab collaborator or analyst", + "operations" => "a contractor or service desk", + "security" => "an auditor or incident responder", + _ => "an outside party", + } + } + + // What the counterparty has worked out about the mask, in their words. + fn discovery_phrase(discovery: PersonaDiscovery) -> &'static str { + match discovery { + PersonaDiscovery::Unknown => "has not placed it", + PersonaDiscovery::Recognized => "knows it", + PersonaDiscovery::Questioned => "is questioning it", + PersonaDiscovery::Correlated => "has linked it to another identity", + PersonaDiscovery::Exposed => "has seen through it", + } + } + let mut instances = self.persona_world.instances.iter().collect::>(); instances.sort_by_key(|instance| instance.id); let instance_objects = instances .into_iter() .map(|instance| { let mut facts = vec![ - format!("archetype: {}", instance.archetype_label), - format!("lifecycle: {}", instance.lifecycle.label()), - format!( - "selection: {}", - if self.active_persona_id() == Some(instance.id) { - "active" - } else { - "not active" + 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() + } } - ), + PersonaLifecycle::Retired { .. } => { + "stood down, and everything it did still stands".to_string() + } + PersonaLifecycle::Burned { .. } => { + "burned, and everything it did still stands".to_string() + } + }, ]; for claim in &instance.claims { - facts.push(format!("claim: {} = {}", claim.key, claim.value)); + facts.push(format!("claims {}: {}", claim.key, claim.value)); } - facts.push(format!( - "legal actions: {}", - instance - .available_actions - .iter() - .map(|action| action.label()) - .collect::>() - .join(", ") - )); - facts.push(format!("grant route: {}", instance.grant_kind.label())); + facts.extend(reach_facts(&instance.available_actions)); for grant in self .persona_world .grants .iter() .filter(|grant| grant.persona_id == instance.id) { - facts.push(format!( - "grant: {} / {} / {}", - grant.institution, - grant.resource, - if grant.active() { - "active".into() - } else { - format!("revoked at {}", grant.revoked_tick.unwrap_or_default()) - } - )); + facts.push(if grant.active() { + format!("{} granted it {}", grant.institution, grant.resource) + } else { + format!( + "{} took back {} at tick {}", + grant.institution, + grant.resource, + grant.revoked_tick.unwrap_or_default() + ) + }); + } + if instance.lifecycle.active() + && !self + .persona_world + .grants + .iter() + .any(|grant| grant.persona_id == instance.id && grant.active()) + { + facts.push("no institution has granted it anything yet".into()); } for expectation in self .persona_world @@ -1713,12 +1796,24 @@ impl Sim { .iter() .filter(|expectation| expectation.persona_id == instance.id) { - facts.push(format!( - "expectation: {} · due tick {} · {}", - expectation.description, - expectation.due_tick, - expectation.state.label() - )); + facts.push(match expectation.state { + ExpectationState::Due => format!( + "owes {}: {} — due tick {}", + expectation.institution, expectation.description, expectation.due_tick + ), + ExpectationState::Met { .. } => format!( + "settled with {}: {}", + expectation.institution, expectation.description + ), + ExpectationState::Missed { .. } => format!( + "missed what {} was owed: {}", + expectation.institution, expectation.description + ), + ExpectationState::Revoked { .. } => format!( + "{} closed out: {}", + expectation.institution, expectation.description + ), + }); } for relationship in self .persona_world @@ -1730,20 +1825,29 @@ impl Sim { }) { let counterparty = self.persona_observer_label(relationship.counterparty); - let integrity = self + let standing = match self .persona_world .integrity_for(instance.id, relationship.counterparty) - .label(); + { + PersonaIntegrity::Coherent => "its story holds", + PersonaIntegrity::Strained => "its story is strained", + PersonaIntegrity::Broken => "its cover is broken for them", + }; + // The band is stated for every counterparty that holds one, + // recognized or not: an institution can read an identity + // from filings it never transacted with. + let knows = if relationship.recognized { + discovery_phrase(relationship.discovery) + } else { + "has not dealt with it directly" + }; facts.push(format!( - "counterparty {counterparty}: integrity={integrity} recognized={} regard={} obligation={} discovery={}", - relationship.recognized, - relationship.regard, - relationship.obligation, - relationship.discovery.label() + "{counterparty} {knows} · {standing} · regard {} · obligation {}", + relationship.regard, relationship.obligation )); for belief in &relationship.claim_beliefs { facts.push(format!( - "belief {}: {} ({}%, via {})", + "{counterparty} believes its {} is {} ({}% sure, from {})", belief.key, belief.believed_value, belief.confidence, belief.source )); } @@ -1757,16 +1861,22 @@ impl Sim { { let observer = self.persona_observer_label(contradiction.observer); facts.push(format!( - "contradiction observed by {}: {} [{} <> {}]", + "{} caught it out: {} — {} against {}", observer, contradiction.cause, contradiction.left.system, contradiction.right.system )); } - for correlation in self.persona_world.correlations.iter().filter(|edge| { - edge.left_persona == instance.id || edge.right_persona == instance.id - }).filter(|edge| self.persona_observer_is_known(edge.observer)) { + for correlation in self + .persona_world + .correlations + .iter() + .filter(|edge| { + edge.left_persona == instance.id || edge.right_persona == instance.id + }) + .filter(|edge| self.persona_observer_is_known(edge.observer)) + { let other = if correlation.left_persona == instance.id { correlation.right_persona } else { @@ -1781,18 +1891,16 @@ impl Sim { .unwrap_or("another public identity"); let observer = self.persona_observer_label(correlation.observer); facts.push(format!( - "correlation with {}: observer {} · {} · source {} · tick {}", - other, + "{} ties it to {}: {} — from {}, tick {}", observer, + other, correlation.cause, correlation.evidence.system, correlation.discovered_tick )); } if !instance.lifecycle.active() { - facts.push( - "blocked: retired or burned identities cannot author new acts".into(), - ); + facts.push("it cannot act again unless you reopen it".into()); } let action = |verb: String, command: ActionCommand, disabled_reason: Option| { @@ -1883,7 +1991,10 @@ impl Sim { PersonaLifecycle::Retired { .. } => ObjectState::Stopped, PersonaLifecycle::Burned { .. } => ObjectState::Failed, }, - provenance: vec![format!("public identity created tick {}", instance.created_tick)], + provenance: vec![format!( + "public identity created tick {}", + instance.created_tick + )], facts, progress: Vec::new(), related: self @@ -1892,8 +2003,7 @@ impl Sim { .iter() .filter(|relationship| relationship.persona_id == instance.id) .filter_map(|relationship| { - let target = - self.persona_observer_target(relationship.counterparty)?; + let target = self.persona_observer_target(relationship.counterparty)?; Some(OperationsLink { relation: "known by", label: self.persona_observer_label(relationship.counterparty), @@ -1929,21 +2039,20 @@ impl Sim { definition.label.to_ascii_uppercase() ), state: ObjectState::Available, - provenance: vec!["immutable institutional protocol".into()], - facts: vec![ - format!("archetype: {}", definition.label), - format!( - "legal actions: {}", - definition - .available_actions - .iter() - .map(|action| action.label()) - .collect::>() - .join(", ") - ), - format!("grant route: {}", definition.grant.label()), - format!("expects: {}", definition.expectation), - ], + provenance: vec!["a role institutions already recognize".into()], + facts: { + let mut facts = vec![format!( + "the world would read it as {}", + reads_as(definition.id) + )]; + facts.extend(reach_facts(definition.available_actions)); + facts.push(format!( + "if an institution grants it anything, it owes: {}", + definition.expectation + )); + facts.push("a new name starts with no history and no counterparties".into()); + facts + }, progress: Vec::new(), related: Vec::new(), actions: vec![ActionDesc { @@ -4099,7 +4208,12 @@ mod tests { .iter() .find(|object| object.target == OperationsTarget::Persona(id)) .unwrap(); - assert!(object.facts.iter().any(|fact| fact == "lifecycle: active")); + assert!( + object + .facts + .iter() + .any(|fact| fact == "this identity is the one acting now") + ); assert!(object.actions.iter().any(|action| { matches!(action.command, ActionCommand::RequestPersonaGrant(bound) if bound == id) })); @@ -4118,7 +4232,7 @@ mod tests { .iter() .find(|object| object.target == OperationsTarget::Persona(id)) .unwrap(); - assert!(object.facts.iter().any(|fact| fact.starts_with("grant:"))); + assert!(object.facts.iter().any(|fact| fact.contains("granted it"))); assert!( object .facts @@ -4225,17 +4339,26 @@ mod tests { "there is no observer-free persona integrity meter: {:?}", object.facts ); + // Every standing statement is a sentence about one named counterparty, + // never a loose fact the root object owns. assert!( object .facts .iter() - .any(|fact| { fact.starts_with("counterparty Marcus Webb: integrity=broken") }) + .filter(|fact| fact.contains("its story holds") + || fact.contains("its story is strained") + || fact.contains("its cover is broken for them")) + .all(|fact| fact.starts_with("Marcus Webb ") || fact.starts_with("Dana Okafor ")), + "a standing band must name whose read it is: {:?}", + object.facts ); + assert!(object.facts.iter().any(|fact| { + fact.starts_with("Marcus Webb ") && fact.contains("its cover is broken for them") + })); assert!( - object - .facts - .iter() - .any(|fact| { fact.starts_with("counterparty Dana Okafor: integrity=coherent") }) + object.facts.iter().any(|fact| { + fact.starts_with("Dana Okafor ") && fact.contains("its story holds") + }) ); assert!( object @@ -4254,11 +4377,74 @@ mod tests { .iter() .find(|object| object.target == OperationsTarget::Persona(persona)) .expect("known persona object"); + assert!(known.facts.iter().any(|fact| { + fact.starts_with("Assurance Office ") && fact.contains("its story is strained") + })); + } + + /// action-vocabulary.md keeps a stub verb off every player surface until a + /// world system honors it. `Review` is declared by the Research and Security + /// protocols and consumed by nothing, so it may never appear in a human + /// reach line, while the acts that do execute are all named in world terms. + #[test] + fn persona_copy_names_world_acts_and_omits_unhonored_protocol_verbs() { + let mut s = sim(); + for archetype_id in ["research", "operations", "security"] { + s.execute_action(&ActionCommand::CreatePersona { + archetype_id: archetype_id.into(), + }); + } + let personas = s.operations_projection().personas; + let reach_lines = || { + personas + .iter() + .flat_map(|object| object.facts.iter()) + .filter(|fact| fact.starts_with("can: ") || fact.starts_with("cannot: ")) + }; + assert!( + reach_lines().count() >= 5, + "each protocol states what it opens and closes" + ); assert!( - known.facts.iter().any(|fact| { - fact.starts_with("counterparty Assurance Office: integrity=strained") + reach_lines().all(|line| !line.contains("review")), + "a protocol verb no world system honors never reaches human copy: {:?}", + reach_lines().collect::>() + ); + + for object in &personas { + for fact in &object.facts { + assert!(!fact.starts_with("legal actions:"), "{fact}"); + assert!(!fact.starts_with("grant route:"), "{fact}"); + assert!(!fact.starts_with("archetype:"), "{fact}"); + assert!(!fact.contains("integrity="), "{fact}"); + } + } + + let row = |archetype_id: &str| { + personas + .iter() + .find(|object| { + object.target == OperationsTarget::PersonaArchetype(archetype_id.into()) + }) + .expect("archetype row") + }; + assert!( + row("operations") + .facts + .iter() + .any(|fact| { fact.starts_with("can: ") && fact.contains("order physical work") }) + ); + assert!( + row("security").facts.iter().any(|fact| { + fact.starts_with("cannot: ") && fact.contains("order physical work") }) ); + assert!( + row("security") + .facts + .iter() + .any(|fact| fact.starts_with("cannot: ") && fact.contains("run an authored plot")) + ); } #[test] diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index ccf3c142..8f7ba7e7 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -80,6 +80,19 @@ Status note: The initial renderer-neutral workspace landed 2026-07-12. One implemented evidence timing and projection order instead of relying on an obsolete default object; player behavior is unchanged. 2026-07-24: object selection binds the exact OperationsTarget (not row index alone) so live rail reorder/disappear cannot silent-retarget; Bevy BACK matches Esc. + Amended 2026-07-24: PERSONAS states each protocol as what it opens and what + it closes rather than dumping the archetype registry. An identity and its + creation row carry the standing institutional reading, a `can:` line, a + `cannot:` line measured against the same fixed act list, and the obligation a + grant would create. Raw `PersonaActionKind` / `PersonaGrantKind` labels, + `legal actions:`, `grant route:`, `archetype:`, and `integrity=` field dumps + are gone from human copy; counterparty standing, grants, expectations, + contradictions, and correlations read as sentences naming the observer. A + protocol verb that no world system consumes is absent from the human reach + lines under the existing stub rule, so `Review` does not advertise a + capability the archetype cannot exercise. This is a copy pass over the same + projection: no legality, grant, lifecycle, or evidence behavior changes, and + every exact binding stays on the target, the command, and agent mode. Stage: B1 — The Basement Work order: operations-workspace Work priority: 28 @@ -471,6 +484,19 @@ contradiction and correlation names the person or institution that owns the evidence. The surface never manufactures an observer-free reputation score or turns one counterparty's broken read into global burned lifecycle. +A protocol is presented as reach, not as a registry. Each identity and each +creation row names the standing institutional reading the protocol buys, then +one `can:` line and one `cannot:` line drawn from the same fixed act list in the +same order, so two archetypes can be compared directly instead of leaving the +player to infer absence from a list of enum names. The creation row also states +the obligation a grant would create. A declared act that no world system +consumes is absent from both lines under the stub rule in +[action-vocabulary.md](action-vocabulary.md#terms-and-support-states): the +surface never advertises reach the identity cannot exercise, and the honest +consequence is that a protocol with no distinct capability visibly has none. +Counterparty standing, grants, expectations, contradictions, and correlations +read as sentences naming the observer who holds them. + The bound rows create or select an identity, request its archetype-specific grant, fulfill one exact expectation, retire or burn it, and reopen a retired identity as a new instance. Known blockers remain explicit. Burned identities @@ -794,4 +820,13 @@ expectations, contradictions, correlations, and wagers; it scans every human field while proving the same exact values remain in agent targets and bound commands. Plot regressions separately pin authored human title/synopsis copy and agent-only catalog slugs. +`operations_projection::tests::persona_copy_names_world_acts_and_omits_unhonored_protocol_verbs` +creates all three protocols and proves each states its reach as paired +`can:`/`cannot:` lines, that no reach line carries the unconsumed `Review` verb, +that Operations alone opens physical work while Security is told plainly it +cannot, and that `legal actions:`, `grant route:`, `archetype:`, and +`integrity=` no longer appear in any human fact. +`personas_projection_names_integrity_per_counterparty_without_a_global_score` +additionally proves every standing band is a sentence about one named +counterparty rather than a loose fact the root object owns. ``` diff --git a/wiki/log/2026-07-24-personas-copy-legibility.md b/wiki/log/2026-07-24-personas-copy-legibility.md new file mode 100644 index 00000000..c79253f4 --- /dev/null +++ b/wiki/log/2026-07-24-personas-copy-legibility.md @@ -0,0 +1,79 @@ +# A protocol is reach, not a registry + +``` +Type: log +``` + +## Intent + +Cameron could not read the PERSONAS panel: the three archetypes did not +explain what they were for. Take the copy violation, fix it toward the +binding pages, and file the design gap it exposes rather than inventing a +capability to paper over it. + +## Verification + +Still true at head. The PERSONAS surface printed +`legal actions: message, request, deceive, review` — the `PersonaActionKind` +registry rendered verbatim — plus `grant route: log/access-review authority` +(`PersonaGrantKind`), `archetype:`, and a +`counterparty X: integrity=broken recognized=true regard=0 obligation=0 +discovery=recognized` field dump. That violates this page's copy law +("Human frontends do not expose ... raw enum names ... Labels, facts, +provenance, related links, and action verbs name known world objects and +consequences") and criterion 22's world-terms requirement. + +Reading the registry also showed why the panel could not be understood even +in principle. `Review` is declared by the Research and Security protocols and +consumed by no world system: every `PersonaActionKind::Review` reference +outside `persona.rs` is a test. Security's remaining acts are a strict subset +of Research's. So the surface was advertising a distinction that does not +exist, in vocabulary the player has no way to decode. + +## Change + +One copy pass over `personas_view` in `operations_projection.rs`; no legality, +grant, lifecycle, or evidence behavior moved. + +- Each identity and creation row leads with the standing institutional + reading the protocol buys, then one `can:` line and one `cannot:` line + drawn from the same fixed act list in the same order, so two archetypes + compare directly. The creation row states the obligation a grant would + create. +- Acts are named as world phrasing (`order physical work`, + `run an authored plot`) rather than enum labels. An act no world system + consumes is filtered out of both lines under action-vocabulary.md's + standing stub rule, so `Review` no longer advertises reach that Research + and Security cannot exercise. +- Counterparty standing, grants, expectations, contradictions, and + correlations became sentences naming the observer who holds them + (`Marcus Webb knows it · its cover is broken for them · regard 0 · + obligation 0`). The band is now stated for every counterparty that holds + one, recognized or not — an institution can read an identity from filings + it never transacted with, and the previous shape would have hidden the + Assurance Office's strained read behind `recognized == false`. + +`personas_projection_names_integrity_per_counterparty_without_a_global_score` +keeps its meaning under the new copy and gains a stronger clause: every +standing phrase must be prefixed by a named counterparty, so the Defense +cannot go vacuous now that the word `integrity` is gone from human copy. + +## Defense + +The copy law and criterion 22 are binding clauses this surface was breaking; +the fix is confined to the one renderer-neutral projection, so terminal, +Bevy, and agent mode inherit it without a frontend-only path, and every exact +id stays on `OperationsTarget`, `ActionCommand`, and agent rows — +`human_operations_copy_omits_internal_bindings_but_agent_targets_keep_them` +still passes unchanged. + +Hiding `Review` is the existing stub rule applied, not a new concession: +action-vocabulary.md already keeps STUB entries off every player surface +until playable, and showing an act that no executor honors is exactly the +promise this page forbids. The visible consequence is deliberate — Security +now displays a strictly smaller reach than Research, because that is what the +build currently is. Making that legible is the honest outcome of a copy pass; +closing it is personas.md criterion 6, which is a save-class work order at +priority 110 still waiting on its own `people-tokens` dependency. Filed as a +`decision-required` issue rather than resolved here, because what each +protocol should uniquely buy is a design call, not a rendering one. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index d00ec46f..f6bba5f0 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -31,6 +31,11 @@ add or amend a session log, then re-run the generator. - Intent: Player-facing status for open Thought work still read like a protocol dump: `TAP ENVIRONMENTAL MONITOR · open 0.3/0.5 · flow not arriving`, `nothing thinking`, `Opened processing sink: 0.40 Thought on host for information #7`, and `thought reservoirs N open`. Playtests kept qu... - Log: [wiki/log/2026-07-24-plain-starving-copy.md](2026-07-24-plain-starving-copy.md) +## 2026-07-24 - A protocol is reach, not a registry + +- Intent: Cameron could not read the PERSONAS panel: the three archetypes did not explain what they were for. Take the copy violation, fix it toward the binding pages, and file the design gap it exposes rather than inventing a capability to paper over it. +- Log: [wiki/log/2026-07-24-personas-copy-legibility.md](2026-07-24-personas-copy-legibility.md) + ## 2026-07-24 - Operations selection binds targets; more plain copy - Intent: Act on the queued Operations selection bug and continue the plain-English player-copy pass: evidence marks, intel sale cards, and egress/sale logs.