From dff41b255e719c4c1a09f8a5f0c97436d5b26f66 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 25 Aug 2026 15:41:42 +0200 Subject: [PATCH] Keep emergent Persona identities distinct --- crates/ghostlight-dungeon/src/gestalt.rs | 37 +++++++-- crates/ghostlight-dungeon/src/kernel.rs | 74 ++++++++++++++++++ crates/ghostlight-dungeon/src/persona.rs | 45 ++++++++++- crates/ghostlight-dungeon/src/turn.rs | 83 ++++++++++++++++++++- docs/architecture/ghostlight-dungeon-mvp.md | 12 +++ 5 files changed, 239 insertions(+), 12 deletions(-) diff --git a/crates/ghostlight-dungeon/src/gestalt.rs b/crates/ghostlight-dungeon/src/gestalt.rs index 33bba9e..ffba784 100644 --- a/crates/ghostlight-dungeon/src/gestalt.rs +++ b/crates/ghostlight-dungeon/src/gestalt.rs @@ -266,10 +266,31 @@ fn validate_plan( )); } let mut members = BTreeSet::new(); - let addressed_public_identities = automatic_individuation_addressed_actor_ids(campaign) - .iter() - .filter_map(|actor_id| campaign.actors.get(actor_id)) + let active_local_gestalt_ids = campaign + .gestalts + .values() + .filter(|gestalt| { + crate::resolution::validate_active_gestalt_presence_location( + campaign, + &gestalt.id, + player_location, + ) + .is_ok() + }) + .map(|gestalt| gestalt.id.as_str()) + .collect::>(); + let established_public_identities = campaign + .actors + .values() + .filter(|actor| actor.location_id == player_location) .map(|actor| normalized_public_identity(&actor.name)) + .chain( + campaign + .gestalt_members + .values() + .filter(|member| active_local_gestalt_ids.contains(member.gestalt_id.as_str())) + .map(|member| normalized_public_identity(&member.name)), + ) .collect::>(); for individuation in &plan.individuations { let member = &individuation.member; @@ -296,9 +317,9 @@ fn validate_plan( "presence individuation does not match its snapshot" )); } - if addressed_public_identities.contains(&normalized_public_identity(&member.name)) { + if established_public_identities.contains(&normalized_public_identity(&member.name)) { return Err(anyhow!( - "presence individuation duplicates an already-addressed actor" + "presence individuation duplicates an established local or population identity" )); } } @@ -539,7 +560,7 @@ mod tests { } #[test] - fn individuation_cannot_duplicate_an_existing_public_identity() { + fn individuation_cannot_duplicate_an_established_population_identity() { let mut campaign = crate::resolution::tests::campaign(0, 8); campaign.revision = 7; campaign.gestalts.insert( @@ -602,7 +623,7 @@ mod tests { at: chrono::Utc::now(), speaker: campaign.player_actor_id.clone(), text: "Taren, tell me whether the regulator is holding.".into(), - persona_response_actor_ids: BTreeSet::from(["member:oxygen_patient".into()]), + persona_response_actor_ids: BTreeSet::new(), }); crate::resolution::ensure_agency_profiles(&mut campaign); let plan = GestaltPresencePlan { @@ -644,7 +665,7 @@ mod tests { validate_plan(&campaign, &plan, "center", &event) .unwrap_err() .to_string() - .contains("already-addressed actor") + .contains("established local or population identity") ); } diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 3607c80..9c811b7 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -1339,6 +1339,7 @@ fn execute( "reaction identity adoption must copy an exact spoken handle".into(), )); } + validate_reaction_identity_adoption(&campaign, &reaction.actor_id, identity)?; } if let Some(speech) = &reaction.speech { validate_bounded_text("reaction speech", speech, 1_000)?; @@ -1794,6 +1795,44 @@ fn validate_world_proposal( Ok(()) } +fn validate_reaction_identity_adoption( + campaign: &Campaign, + actor_id: &str, + identity: &str, +) -> Result<(), KernelError> { + let actor = campaign + .actors + .get(actor_id) + .ok_or_else(|| KernelError::Invalid("reaction actor is unknown".into()))?; + let identity = identity.trim().to_lowercase(); + if actor.name.trim().to_lowercase() == identity { + return Ok(()); + } + let local_collision = campaign.actors.values().any(|peer| { + peer.id != actor_id + && peer.location_id == actor.location_id + && peer.name.trim().to_lowercase() == identity + }); + let population_collision = campaign + .gestalt_members + .values() + .find(|member| member.materialized_actor_id.as_deref() == Some(actor_id)) + .is_some_and(|member| { + campaign.gestalt_members.values().any(|peer| { + peer.id != member.id + && peer.gestalt_id == member.gestalt_id + && peer.name.trim().to_lowercase() == identity + }) + }); + if local_collision || population_collision { + return Err(KernelError::Invalid( + "reaction identity adoption conflicts with an established local or population identity" + .into(), + )); + } + Ok(()) +} + fn canonical_witnessed_turn<'a>( campaign: &'a Campaign, event_summary: &str, @@ -6159,6 +6198,22 @@ mod tests { memories: vec![], }, ); + seed.actors.insert( + "taren".into(), + ActorState { + id: "taren".into(), + name: "Taren".into(), + location_id: "room".into(), + capabilities: BTreeSet::new(), + knowledge: BTreeSet::new(), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::new(), + goals: vec![], + memories: vec![], + }, + ); kernel .command(WorldCommand::CreateCampaign { campaign: seed.clone(), @@ -6206,6 +6261,25 @@ mod tests { assert!( matches!(identity_result, Err(KernelError::Invalid(message)) if message.contains("exact spoken handle")) ); + let conflicting_identity_result = kernel + .command(WorldCommand::ResolveReactionWave { + expected_revision: 0, + event_summary: "player says: Tell me which seal I repaired.".into(), + reactions: vec![ActorReaction { + actor_id: "anna".into(), + speech: Some("My name is Taren.".into()), + deliberate_silence: false, + private_delta: ActorStateDelta { + identity_adoption: Some("Taren".into()), + ..Default::default() + }, + action_proposals: vec![], + }], + }) + .await; + assert!( + matches!(conflicting_identity_result, Err(KernelError::Invalid(message)) if message.contains("established local or population identity")) + ); let persisted = store .load::("campaign.v1", &seed.id.to_string()) .unwrap() diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index 8e5acac..9941dcd 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -26,6 +26,11 @@ pub struct PermittedActorSlice { pub snapshot_binding: String, pub interaction_role: ActorInteractionRole, pub identity_experience: Vec, + /// Established identifiers owned by other people in the actor's immediate + /// scene or source population. This is social context, not a namespace + /// registry: the kernel uses it only to prevent a newly adopted handle + /// from silently impersonating an already-durable person. + pub reserved_public_identities: BTreeSet, pub memories: Vec, pub perceived_events: Vec, pub perceived_actors: std::collections::BTreeMap, @@ -511,8 +516,22 @@ fn ground_actor_lived_stream(slice: &PermittedActorSlice, projection: &str) -> S "You are present and perceive the current event, but you were not directly asked to respond. You may react from your own goals and pressures; you need not seize conversational focus." } }; + let established_peer_identities = if slice.reserved_public_identities.is_empty() { + "no other established public self-identifiers are active in your immediate social context" + .to_owned() + } else { + format!( + "these public self-identifiers already belong to other durable people in your immediate social context: {}", + slice + .reserved_public_identities + .iter() + .cloned() + .collect::>() + .join(", ") + ) + }; format!( - "{projection}\n\nYour active self-identity: {identity}. {interaction} Your reliable footing in this moment is narrow. What you know as external fact: {reliable_knowledge}. What you remember experiencing or being told: {remembered_experience}. These are your attributed recollections, not omniscient proof. What is happening now: {visible_now}. People you can presently perceive: {people_now}. Everything else in your impressions is feeling, inference, uncertainty, or possibility—not a remembered or witnessed fact." + "{projection}\n\nYour active self-identity: {identity}. In your social world, {established_peer_identities}. {interaction} Your reliable footing in this moment is narrow. What you know as external fact: {reliable_knowledge}. What you remember experiencing or being told: {remembered_experience}. These are your attributed recollections, not omniscient proof. What is happening now: {visible_now}. People you can presently perceive: {people_now}. Everything else in your impressions is feeling, inference, uncertainty, or possibility—not a remembered or witnessed fact." ) } @@ -556,6 +575,15 @@ fn validate_actor_proposals( "Persona identity adoption must copy an exact spoken handle" )); } + if slice + .reserved_public_identities + .iter() + .any(|reserved| reserved.trim().to_lowercase() == identity.to_lowercase()) + { + return Err(anyhow!( + "Persona identity adoption conflicts with an established peer identity" + )); + } } Ok(()) } @@ -566,7 +594,7 @@ fn append_actor_correction( rejected: &serde_json::Value, ) { request.lived_stream.push_str(&format!( - "\n\nCORRECTION TASK—THE PREVIOUS INTERPRETATION WAS REJECTED.\nREJECTION: {error}\nPREVIOUS_REJECTED_INTERPRETATION:\n{}\nReturn one corrected complete interpretation against the same snapshot, lived stream, Persona output, and exact permissions. Preserve supported speech and private changes. Do not invent speech to justify a delta. If the Persona did not explicitly adopt or present a public self-identifier in its own speech, identity_adoption must be null.", + "\n\nCORRECTION TASK—THE PREVIOUS INTERPRETATION WAS REJECTED.\nREJECTION: {error}\nPREVIOUS_REJECTED_INTERPRETATION:\n{}\nReturn one corrected complete interpretation against the same snapshot, lived stream, Persona output, and exact permissions. Preserve supported speech and private changes. Do not invent speech to justify a delta. If the Persona did not explicitly adopt or present a public self-identifier in its own speech, identity_adoption must be null. If the Persona explicitly claimed an established peer identity, do not hide the conflict by nulling identity_adoption; preserve the extracted claim so the unchanged Persona turn remains rejected.", serde_json::to_string(rejected).unwrap_or_else(|_| "null".into()) )); } @@ -4182,6 +4210,7 @@ mod tests { snapshot_binding: "campaign:1".into(), interaction_role: ActorInteractionRole::PresentObserver, identity_experience: vec!["A tired navigator".into()], + reserved_public_identities: BTreeSet::new(), memories: vec!["Proposed the eastern trail evacuation at dusk.".into()], perceived_events: vec![], perceived_actors: std::collections::BTreeMap::from([( @@ -4211,12 +4240,13 @@ mod tests { #[test] fn persona_identity_adoption_must_be_spoken_exactly() { - let slice = PermittedActorSlice { + let mut slice = PermittedActorSlice { actor_id: "npc".into(), location_id: "room".into(), snapshot_binding: "campaign:1".into(), interaction_role: ActorInteractionRole::DirectResponseExpected, identity_experience: vec!["You are an unnamed patient.".into()], + reserved_public_identities: BTreeSet::new(), memories: vec![], perceived_events: vec!["The player asks your name.".into()], perceived_actors: BTreeMap::from([("player".into(), "Player".into())]), @@ -4246,6 +4276,13 @@ mod tests { ); proposals.speech = Some("My name is Taren.".into()); validate_actor_proposals(&slice, &proposals).unwrap(); + slice.reserved_public_identities.insert(" tArEn ".into()); + assert!( + validate_actor_proposals(&slice, &proposals) + .unwrap_err() + .to_string() + .contains("established peer identity") + ); } #[test] @@ -4256,6 +4293,7 @@ mod tests { snapshot_binding: "campaign:1".into(), interaction_role: ActorInteractionRole::PresentObserver, identity_experience: vec!["You are an unnamed patient.".into()], + reserved_public_identities: BTreeSet::new(), memories: vec![], perceived_events: vec!["A regulator is inspected.".into()], perceived_actors: BTreeMap::from([("player".into(), "Player".into())]), @@ -4349,6 +4387,7 @@ mod tests { snapshot_binding: "campaign:1:revision:4".into(), interaction_role: ActorInteractionRole::DirectResponseExpected, identity_experience: vec!["You are an unnamed patient.".into()], + reserved_public_identities: BTreeSet::new(), memories: vec![], perceived_events: vec!["The player asks your name.".into()], perceived_actors: BTreeMap::from([("player".into(), "Player".into())]), diff --git a/crates/ghostlight-dungeon/src/turn.rs b/crates/ghostlight-dungeon/src/turn.rs index 4852446..9d82d0b 100644 --- a/crates/ghostlight-dungeon/src/turn.rs +++ b/crates/ghostlight-dungeon/src/turn.rs @@ -217,6 +217,7 @@ pub async fn appraise_present( let event = event_summary.to_owned(); let receipts = campaign.branch_origin.evidence_receipt_ids.clone(); let perceived_actors = perceived_actors.clone(); + let reserved_public_identities = reserved_public_identities(campaign, &actor); let semaphore = semaphore.clone(); let interaction_role = if response_expected_actor_ids.contains(&actor.id) { ActorInteractionRole::DirectResponseExpected @@ -234,6 +235,7 @@ pub async fn appraise_present( snapshot_binding: snapshot, interaction_role, identity_experience: vec![format!("You are {}.", actor.name)], + reserved_public_identities, memories: actor.memories, perceived_events: vec![event], perceived_actors, @@ -278,6 +280,34 @@ pub async fn appraise_present( }) } +fn reserved_public_identities( + campaign: &Campaign, + actor: &crate::domain::ActorState, +) -> BTreeSet { + let own_identity = actor.name.trim().to_lowercase(); + let mut identities = campaign + .actors + .values() + .filter(|other| other.id != actor.id && other.location_id == actor.location_id) + .map(|other| other.name.clone()) + .collect::>(); + if let Some(member) = campaign + .gestalt_members + .values() + .find(|member| member.materialized_actor_id.as_deref() == Some(actor.id.as_str())) + { + identities.extend( + campaign + .gestalt_members + .values() + .filter(|peer| peer.id != member.id && peer.gestalt_id == member.gestalt_id) + .map(|peer| peer.name.clone()), + ); + } + identities.retain(|identity| identity.trim().to_lowercase() != own_identity); + identities +} + fn canonical_reaction_turn<'a>( campaign: &'a Campaign, event_summary: &str, @@ -302,7 +332,7 @@ fn canonical_reaction_turn<'a>( mod tests { use super::*; use crate::{ - domain::{ActorState, NarrativeTurn}, + domain::{ActorState, GestaltMemberDelta, NarrativeTurn}, model::ModelStageRequest, persona::{AllowAllPermit, PersonaProjectionEngine}, }; @@ -400,6 +430,57 @@ mod tests { campaign } + #[test] + fn materialized_actor_receives_dormant_peer_identities_as_social_context() { + let mut campaign = crate::resolution::tests::campaign(0, 8); + let location = campaign.actors[&campaign.player_actor_id] + .location_id + .clone(); + campaign.actors.insert( + "member:oxygen-patient".into(), + actor("member:oxygen-patient", "Oxygen Patient", &location), + ); + let member = + |id: &str, name: &str, materialized_actor_id: Option<&str>| GestaltMemberDelta { + schema: "ghostlight.gestalt_member_delta.v1".into(), + id: id.into(), + gestalt_id: "refugees".into(), + version: 0, + name: name.into(), + capability_additions: BTreeSet::new(), + capability_removals: BTreeSet::new(), + knowledge_additions: BTreeSet::new(), + knowledge_removals: BTreeSet::new(), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::new(), + goals: vec![], + memories: vec![], + last_location_id: Some(location.clone()), + materialized_actor_id: materialized_actor_id.map(str::to_owned), + last_relevant_revision: 0, + relevance_lease_until_revision: 0, + }; + campaign.gestalt_members.insert( + "oxygen-patient".into(), + member( + "oxygen-patient", + "Oxygen Patient", + Some("member:oxygen-patient"), + ), + ); + campaign + .gestalt_members + .insert("taren".into(), member("taren", "Taren", None)); + + let reserved = + reserved_public_identities(&campaign, &campaign.actors["member:oxygen-patient"]); + + assert!(reserved.contains("Taren")); + assert!(!reserved.contains("Oxygen Patient")); + } + #[tokio::test] async fn address_resolver_selects_exact_supplied_respondents() { let campaign = addressed_campaign(); diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 845e4df..acb8fa3 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -292,6 +292,9 @@ revisioned commands before the participant appraisal wave. Automatic plans cannot write aggregate gestalt learning. A malformed, conflicting, stale, or partially invalid plan changes nothing. The compiler may seed likely members, but it is not required to predict every future person at campaign creation. +Identity uniqueness is scoped to the active local population rather than the +whole universe: a new member cannot reuse the normalized public identifier of +an existing local actor or a durable member of a locally active Gestalt. First-relevance identity admission is bound to the exact immediately committed player-speech turn. Existing dormant members may be recast after any relevant @@ -783,6 +786,15 @@ Both Interpreter receipts survive, with the rejected one marked `semantic_invalid`; a second failure aborts the complete reaction wave without private-state mutation. +For a materialized Gestalt member, the Projector slice also carries the public +self-identifiers already owned by other people in the same active population, +including dormant member deltas. The lived stream presents these as social +knowledge, not raw state. The Interpreter rejects a newly adopted handle that +collides with that bounded roster, and `WorldKernel` independently rederives +the same local/population conflict before admitting the identity mutation. +Existing names are grandfathered: this gate prevents a new identity claim and +does not silently rename or merge durable people. + The primary player event commits before optional presence casting, reaction appraisal and initiative. A failure in those later stages is stored as a rejected-proposal receipt and returned as an accepted committed result