Preserve adopted identities across gestalt materialization

This commit is contained in:
Meta Vens 2026-08-25 00:11:03 +02:00
commit d1f2a88419
5 changed files with 276 additions and 6 deletions

View file

@ -642,6 +642,11 @@ pub struct GestaltFissionPreview {
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
pub struct ActorStateDelta {
pub memories_add: Vec<String>,
/// One public self-identifier explicitly adopted in the actor's own
/// speech. WorldKernel binds the subject and derives the identity handle;
/// the model never supplies either authority value.
#[serde(default)]
pub identity_adoption: Option<String>,
pub conditions_add: BTreeSet<String>,
pub conditions_remove: BTreeSet<String>,
pub goals_add: Vec<String>,

View file

@ -103,7 +103,7 @@ impl GestaltPresencePlanner {
individuation_admitted,
)?;
let individuation_instruction = if individuation_admitted {
"The immediately committed event is direct player speech. If that speech makes one anonymous population member individually relevant and no supplied member fits, you may individuate exactly one durable member delta from the gestalt baseline. Use a new stable lowercase id, version 0, the exact gestalt id/version, no materialized actor id, and record only personal departures from the shared baseline."
"The immediately committed event is direct player speech. If that speech makes one anonymous population member individually relevant and no supplied member fits, you may individuate exactly one durable member delta from the gestalt baseline. Give that person a stable personal self-identifier suitable to the setting, never a role, condition, manifest category, or descriptive placeholder. Use a new stable lowercase id, version 0, the exact gestalt id/version, no materialized actor id, and record only personal departures from the shared baseline."
} else {
"The immediately committed event does not admit a new canonical person. The individuations array must be empty. Cast only supplied existing members."
};

View file

@ -1313,6 +1313,19 @@ fn execute(
"reaction Interpreter cannot write actor memory".into(),
));
}
if let Some(identity) = reaction.private_delta.identity_adoption.as_deref() {
validate_bounded_text("reaction identity adoption", identity, 160)?;
let speech = reaction.speech.as_deref().ok_or_else(|| {
KernelError::Invalid(
"reaction identity adoption requires public speech".into(),
)
})?;
if !speech.to_lowercase().contains(&identity.to_lowercase()) {
return Err(KernelError::Invalid(
"reaction identity adoption must copy an exact spoken handle".into(),
));
}
}
if let Some(speech) = &reaction.speech {
validate_bounded_text("reaction speech", speech, 1_000)?;
}
@ -6054,6 +6067,25 @@ mod tests {
assert!(
matches!(result, Err(KernelError::Invalid(message)) if message.contains("cannot write actor memory"))
);
let 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 Anna.".into()),
deliberate_silence: false,
private_delta: ActorStateDelta {
identity_adoption: Some("Taren".into()),
..Default::default()
},
action_proposals: vec![],
}],
})
.await;
assert!(
matches!(identity_result, Err(KernelError::Invalid(message)) if message.contains("exact spoken handle"))
);
let persisted = store
.load::<Campaign>("campaign.v1", &seed.id.to_string())
.unwrap()

View file

@ -700,6 +700,39 @@ pub fn lower_reaction_wave(
summary: Some(witnessed_memory.into()),
});
}
if let Some(identity) = reaction.private_delta.identity_adoption.as_deref()
&& identity != actor.name
{
let handle_id = format!(
"identity:adopted:{}:{}",
reaction.actor_id,
short_digest(identity)
);
let audience = campaign
.actors
.values()
.filter(|observer| {
observer.id != reaction.actor_id && observer.location_id == actor.location_id
})
.map(|observer| actor_subject(&observer.id))
.collect::<Vec<_>>();
mutations.push(WorldMutation::ChangeIdentity {
subject: subject.clone(),
operation: IdentityMutationOperation::Adopt,
handle_id: handle_id.clone(),
handle_value: Some(identity.into()),
audience: Vec::new(),
});
if !audience.is_empty() {
mutations.push(WorldMutation::ChangeIdentity {
subject: subject.clone(),
operation: IdentityMutationOperation::Disclose,
handle_id,
handle_value: Some(identity.into()),
audience,
});
}
}
for condition in &reaction.private_delta.conditions_add {
mutations.push(WorldMutation::ChangeCondition {
subject: subject.clone(),
@ -763,7 +796,7 @@ pub fn lower_reaction_wave(
None,
MutationOutcomeBinding::Deterministic,
None,
"Each exact reacting actor may update only their private memory, conditions, goals, and directed relationships.",
"Each exact reacting actor may update only their own public self-identity, private memory, conditions, goals, and directed relationships.",
source_receipt_id,
Some(digest_serializable(reactions)?),
None,
@ -1923,6 +1956,39 @@ fn project_mutated_components(
profile.location_ids = BTreeSet::from([destination.id.clone()]);
profile.profile_version = profile.profile_version.saturating_add(1);
}
WorldMutation::ChangeIdentity {
subject,
operation: IdentityMutationOperation::Adopt,
handle_id,
..
} if subject.kind == SubjectKind::Actor => {
let identity = next
.identities
.get(handle_id)
.filter(|identity| identity.active && identity.subject == *subject)
.ok_or_else(|| anyhow!("accepted identity adoption vanished"))?;
if let Some(member_id) = subject.id.strip_prefix("member:") {
campaign
.gestalt_members
.get_mut(member_id)
.ok_or_else(|| anyhow!("accepted identity member vanished"))?
.name = identity.value.clone();
touched_members.insert(member_id.to_string());
}
campaign
.actors
.get_mut(&subject.id)
.ok_or_else(|| anyhow!("accepted identity actor vanished"))?
.name = identity.value.clone();
}
WorldMutation::ChangeIdentity {
subject,
operation: IdentityMutationOperation::Disclose,
..
} if subject.kind == SubjectKind::Actor => {
// Exact audience knowledge lives in the component world. The
// aggregate actor/member projection has no rival audience map.
}
WorldMutation::ChangeCondition {
subject,
condition_id,
@ -2634,8 +2700,9 @@ fn short_digest(value: &str) -> String {
mod tests {
use super::*;
use crate::domain::{
ActorState, BranchOrigin, CommitmentDelta, ConditionDelta, InstitutionState, Location,
ResolutionPolicy, Route, StrategicOutcomeBand, WorldClock, WorldFact,
ActorState, BranchOrigin, CommitmentDelta, ConditionDelta, GestaltMemberDelta,
InstitutionState, Location, ResolutionPolicy, Route, StrategicOutcomeBand, WorldClock,
WorldFact,
};
use chrono::Duration;
use uuid::Uuid;
@ -2767,6 +2834,105 @@ mod tests {
}
}
#[test]
fn reaction_identity_adoption_updates_materialized_member_and_delta() {
let mut campaign = campaign();
campaign.gestalts.insert(
"refugees".into(),
GestaltPersonaState {
schema: "ghostlight.gestalt_persona_state.v1".into(),
id: "refugees".into(),
name: "Refugees".into(),
version: 0,
home_location_id: "room".into(),
shared_capabilities: BTreeSet::new(),
shared_knowledge: BTreeSet::new(),
resources: BTreeSet::new(),
goals: vec![],
pressures: vec![],
},
);
campaign.gestalt_members.insert(
"oxygen_patient".into(),
GestaltMemberDelta {
schema: "ghostlight.gestalt_member_delta.v1".into(),
id: "oxygen_patient".into(),
gestalt_id: "refugees".into(),
version: 0,
name: "Refugee Convoy Oxygen Patient".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("room".into()),
materialized_actor_id: Some("member:oxygen_patient".into()),
last_relevant_revision: 4,
relevance_lease_until_revision: 6,
},
);
campaign.actors.insert(
"member:oxygen_patient".into(),
ActorState {
id: "member:oxygen_patient".into(),
name: "Refugee Convoy Oxygen Patient".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![],
},
);
let reactions = vec![ActorReaction {
actor_id: "member:oxygen_patient".into(),
speech: Some("My name is Taren.".into()),
deliberate_silence: false,
private_delta: crate::domain::ActorStateDelta {
identity_adoption: Some("Taren".into()),
..Default::default()
},
action_proposals: vec![],
}];
let transition = lower_reaction_wave(
&campaign,
"Witnessed: the player asks your name.",
&reactions,
"reaction:test",
Utc::now() + Duration::minutes(5),
)
.unwrap();
assert!(transition.batch.mutations.iter().any(|permitted| matches!(
&permitted.mutation,
WorldMutation::ChangeIdentity {
operation: IdentityMutationOperation::Adopt,
handle_value: Some(value),
..
} if value == "Taren"
)));
assert!(transition.batch.mutations.iter().any(|permitted| matches!(
&permitted.mutation,
WorldMutation::ChangeIdentity {
operation: IdentityMutationOperation::Disclose,
handle_value: Some(value),
audience,
..
} if value == "Taren"
&& audience == &vec![actor_subject("player"), actor_subject("witness")]
)));
apply_lowered_transition(&mut campaign, &transition, Utc::now()).unwrap();
assert_eq!(campaign.actors["member:oxygen_patient"].name, "Taren");
assert_eq!(campaign.gestalt_members["oxygen_patient"].name, "Taren");
}
#[test]
fn foreground_legacy_record_can_only_change_state_through_exact_mutations() {
let mut campaign = campaign();

View file

@ -443,7 +443,7 @@ fn actor_interpreter_guidance(slice: &PermittedActorSlice) -> String {
}
};
format!(
"{response_guidance} Record only private changes supported by the lived stream and typed context. World actions are attempts, not completed effects. Speech is extracted separately and is already complete. Do not emit a world action merely to make another actor answer, choose, consent, believe, disclose, feel, or obey; the other actor retains agency and any requested response remains unresolved. actor_id must be {:?}. Exact allowed state references are {:?}. Relationship update keys may only be {:?}.",
"{response_guidance} Record only private changes supported by the lived stream and typed context. identity_adoption is null unless the Persona explicitly adopts or presents one public self-identifier in its own speech; when set, copy the exact spoken handle and nothing else. World actions are attempts, not completed effects. Speech is extracted separately and is already complete. Do not emit a world action merely to make another actor answer, choose, consent, believe, disclose, feel, or obey; the other actor retains agency and any requested response remains unresolved. actor_id must be {:?}. Exact allowed state references are {:?}. Relationship update keys may only be {:?}.",
slice.actor_id,
allowed_actor_references(slice),
slice.perceived_actors.keys().collect::<Vec<_>>()
@ -451,6 +451,11 @@ fn actor_interpreter_guidance(slice: &PermittedActorSlice) -> String {
}
fn ground_actor_lived_stream(slice: &PermittedActorSlice, projection: &str) -> String {
let identity = if slice.identity_experience.is_empty() {
"no more specific self-identity than your exact actor identity".to_owned()
} else {
slice.identity_experience.join("; ")
};
let reliable_knowledge = if slice.knowledge.is_empty() {
"no additional external fact beyond what is happening in front of you".to_owned()
} else {
@ -486,7 +491,7 @@ fn ground_actor_lived_stream(slice: &PermittedActorSlice, projection: &str) -> S
}
};
format!(
"{projection}\n\n{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}. {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."
)
}
@ -514,6 +519,23 @@ fn validate_actor_proposals(
{
return Err(anyhow!("Persona speech must contain 1 to 1000 characters"));
}
if let Some(identity) = proposals.private_delta.identity_adoption.as_deref() {
let identity = identity.trim();
if identity.is_empty() || identity.chars().count() > 160 {
return Err(anyhow!(
"Persona identity adoption must contain 1 to 160 characters"
));
}
let speech = proposals
.speech
.as_deref()
.ok_or_else(|| anyhow!("Persona identity adoption requires public speech"))?;
if !speech.to_lowercase().contains(&identity.to_lowercase()) {
return Err(anyhow!(
"Persona identity adoption must copy an exact spoken handle"
));
}
}
Ok(())
}
@ -2725,6 +2747,11 @@ fn constrain_interpreter_schema(
.and_then(|value| value.as_object_mut())
.ok_or_else(|| anyhow!("Persona proposal schema has no memory additions"))?;
memories_add.insert("maxItems".into(), serde_json::json!(0));
let identity_adoption = schema
.pointer_mut("/$defs/ActorStateDelta/properties/identity_adoption")
.ok_or_else(|| anyhow!("Persona proposal schema has no identity adoption"))?;
*identity_adoption =
serde_json::json!({"type":["string","null"],"minLength":1,"maxLength":160});
let root = schema
.as_object_mut()
.ok_or_else(|| anyhow!("Persona proposal schema is not an object"))?;
@ -4128,6 +4155,7 @@ mod tests {
assert!(guidance.contains("other actor retains agency"));
assert!(guidance.contains("requested response remains unresolved"));
let grounded = ground_actor_lived_stream(&slice, "The crowd turns toward you.");
assert!(grounded.contains("Your active self-identity: A tired navigator"));
assert!(grounded.contains("What you remember experiencing or being told"));
assert!(grounded.contains("Proposed the eastern trail evacuation at dusk."));
assert!(grounded.contains("not omniscient proof"));
@ -4136,6 +4164,45 @@ mod tests {
assert_eq!(result.proposals.reaction_priority, 0);
}
#[test]
fn persona_identity_adoption_must_be_spoken_exactly() {
let 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()],
memories: vec![],
perceived_events: vec!["The player asks your name.".into()],
perceived_actors: BTreeMap::from([("player".into(), "Player".into())]),
relationships: vec![],
goals: vec![],
knowledge: vec![],
capabilities: vec![],
pressures: vec![],
affordances: vec![],
source_receipt_ids: vec![],
};
let mut proposals = PersonaProposalBundle {
private_delta: crate::domain::ActorStateDelta {
identity_adoption: Some("Taren".into()),
..Default::default()
},
speech: Some("Call me Rook.".into()),
deliberate_silence: false,
reaction_priority: 0,
world_actions: vec![],
};
assert!(
validate_actor_proposals(&slice, &proposals)
.unwrap_err()
.to_string()
.contains("exact spoken handle")
);
proposals.speech = Some("My name is Taren.".into());
validate_actor_proposals(&slice, &proposals).unwrap();
}
#[test]
fn semantic_correction_restates_non_empty_effect_contract() {
let mut request = ModelStageRequest {