Keep emergent Persona identities distinct

This commit is contained in:
Meta Vens 2026-08-25 15:41:42 +02:00
commit dff41b255e
5 changed files with 239 additions and 12 deletions

View file

@ -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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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")
);
}

View file

@ -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>("campaign.v1", &seed.id.to_string())
.unwrap()

View file

@ -26,6 +26,11 @@ pub struct PermittedActorSlice {
pub snapshot_binding: String,
pub interaction_role: ActorInteractionRole,
pub identity_experience: Vec<String>,
/// 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<String>,
pub memories: Vec<String>,
pub perceived_events: Vec<String>,
pub perceived_actors: std::collections::BTreeMap<String, String>,
@ -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::<Vec<_>>()
.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())]),

View file

@ -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<String> {
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::<BTreeSet<_>>();
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();

View file

@ -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