Preserve canonical Gestalt member identity

This commit is contained in:
Meta Vens 2026-08-26 07:16:14 +02:00
commit f1fee54bed
11 changed files with 482 additions and 42 deletions

View file

@ -778,7 +778,7 @@ fn member_outcome_kind(
) -> Option<&'static str> {
use ghostlight_dungeon::domain::StrategicOutcomeEffect;
let member_subject_id = format!("member:{member_id}");
let member_subject_id = ghostlight_dungeon::domain::gestalt_member_subject_id(member_id);
match effect {
StrategicOutcomeEffect::MemberMemory {
member_id: owner, ..

View file

@ -183,6 +183,9 @@ struct CompiledLocation {
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
struct CompiledGestaltMemberDelta {
schema: String,
#[schemars(
description = "Local stable member ID without the world-subject `member:` namespace prefix."
)]
id: String,
gestalt_id: String,
version: u64,
@ -2505,7 +2508,7 @@ fn validate_shared_seed_excludes_locally_owned_subjects(
seed.gestalt_members
.iter()
.filter(|member| locally_owned_names.contains(&normalized_identity(&member.name)))
.map(|member| format!("member:{}", member.id)),
.map(|member| crate::domain::gestalt_member_subject_id(&member.id)),
)
.collect::<Vec<_>>();
if collisions.is_empty() {
@ -2723,7 +2726,7 @@ fn canonical_relationship_subject_ids(campaign: &Campaign) -> BTreeSet<String> {
campaign
.gestalt_members
.keys()
.map(|member_id| format!("member:{member_id}")),
.map(|member_id| crate::domain::gestalt_member_subject_id(member_id)),
);
targets
}
@ -3845,7 +3848,13 @@ fn ensure_distinct_fields<const N: usize>(
}
}
fn seed_to_campaign(seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Result<Campaign> {
fn seed_to_campaign(mut seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Result<Campaign> {
for member in &mut seed.gestalt_members {
member.id = crate::domain::canonical_gestalt_member_local_id(&member.id);
if member.id.is_empty() {
return Err(anyhow!("compiled gestalt member ID is empty"));
}
}
require_unique_ids(
"location",
seed.locations.iter().map(|item| item.id.as_str()),
@ -4340,7 +4349,16 @@ pub fn validate_campaign_seed(c: &Campaign) -> Result<()> {
));
}
}
for member in c.gestalt_members.values() {
for (member_key, member) in &c.gestalt_members {
if member_key != &member.id
|| member.id.is_empty()
|| crate::domain::canonical_gestalt_member_local_id(&member.id) != member.id
{
return Err(anyhow!(
"gestalt member {} must use one canonical local ID without a member: prefix",
member.id
));
}
if !c.gestalts.contains_key(&member.gestalt_id) {
return Err(anyhow!(
"gestalt member {} references unknown gestalt {}",
@ -5190,7 +5208,7 @@ mod tests {
"locations":[{"id":"yard","name":"Yard","container_id":null,"routes":[{"route_id":"out","destination_id":destination,"distance":"near","travel_minutes":5}],"persistent_features":["same yard"]}],
"actors":[],
"gestalts":[{"schema":"ghostlight.gestalt_persona_state.v1","id":"yard-workers","name":"Yard workers","version":0,"home_location_id":"yard","shared_capabilities":["maintain machinery"],"shared_knowledge":["yard routines"],"resources":["tool shed"],"goals":["finish the shift"],"pressures":["the gate is failing"]}],
"gestalt_members":[{"schema":"ghostlight.gestalt_member_delta.v1","id":"john","gestalt_id":"yard-workers","version":0,"name":"John the smith","capability_additions":["forge hinges"],"capability_removals":[],"knowledge_additions":[],"knowledge_removals":[],"equipment":["hammer"],"conditions":[],"obligations":[],"relationships":[],"goals":[],"memories":[],"last_location_id":"yard","materialized_actor_id":null}],
"gestalt_members":[{"schema":"ghostlight.gestalt_member_delta.v1","id":"member:john","gestalt_id":"yard-workers","version":0,"name":"John the smith","capability_additions":["forge hinges"],"capability_removals":[],"knowledge_additions":[],"knowledge_removals":[],"equipment":["hammer"],"conditions":[],"obligations":[],"relationships":[],"goals":[],"memories":[],"last_location_id":"yard","materialized_actor_id":null}],
"institutions":[],"clocks":[{"id":"shift","label":"Shift ends","progress":0,"threshold":4,"consequence":"night"}],
"facts":[
{"id":"f","statement":"A witnessed fact","scope":"canon_baseline","evidence_receipt_ids":["fixture"]},

View file

@ -158,6 +158,22 @@ pub struct GestaltMemberDelta {
pub relevance_lease_until_revision: u64,
}
/// Gestalt member records own a local identity. The `member:` namespace is a
/// world-subject projection and must never become part of that stored local ID.
/// Repeated prefixes are accepted only at model and legacy-state boundaries so
/// every downstream organ converges on one canonical person.
pub fn canonical_gestalt_member_local_id(value: &str) -> String {
let mut value = value.trim();
while let Some(unprefixed) = value.strip_prefix("member:") {
value = unprefixed;
}
value.to_owned()
}
pub fn gestalt_member_subject_id(member_id: &str) -> String {
format!("member:{}", canonical_gestalt_member_local_id(member_id))
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct GestaltPromotion {
pub gestalt_id: String,

View file

@ -49,7 +49,7 @@ impl GestaltPresencePlanner {
"member": member,
"player_relationship_to_member": player
.relationships
.get(&format!("member:{}", member.id)),
.get(&crate::domain::gestalt_member_subject_id(&member.id)),
})
})
.collect::<Vec<_>>();

View file

@ -1688,7 +1688,8 @@ fn apply_individuation(
campaign: &mut Campaign,
individuation: &GestaltIndividuation,
) -> Result<(), KernelError> {
let member = &individuation.member;
let mut member = individuation.member.clone();
member.id = crate::domain::canonical_gestalt_member_local_id(&member.id);
let gestalt = campaign
.gestalts
.get(&individuation.gestalt_id)
@ -1773,7 +1774,7 @@ fn apply_promotion(
"gestalt member is already materialized".into(),
));
}
let actor_id = format!("member:{}", member.id);
let actor_id = crate::domain::gestalt_member_subject_id(&member.id);
if campaign.actors.contains_key(&actor_id) {
return Err(KernelError::Invalid(
"materialized actor id collides".into(),
@ -2267,7 +2268,7 @@ fn apply_strategic_tick_plan(
merge_strategic_outcome_event_context(
&mut outcome_event_context,
&activity.action_digest,
&format!("member:{}", activity.member_id),
&crate::domain::gestalt_member_subject_id(&activity.member_id),
&activity.location_ids,
&activity.public_channels,
)?;
@ -2742,7 +2743,7 @@ fn apply_strategic_tick_plan(
.filter(|target| campaign.institutions.contains_key(*target))
.cloned()
.collect();
let mut actor_ids = vec![format!("member:{}", action.member_id)];
let mut actor_ids = vec![crate::domain::gestalt_member_subject_id(&action.member_id)];
actor_ids.extend(
action
.target_subject_ids
@ -2772,7 +2773,7 @@ fn apply_strategic_tick_plan(
&prospective_actor_locations,
&prospective_gestalt_locations,
&prospective_member_locations,
&format!("member:{}", action.member_id),
&crate::domain::gestalt_member_subject_id(&action.member_id),
&action.location_ids,
)?;
let event = Event {
@ -2828,7 +2829,7 @@ fn apply_strategic_tick_plan(
"{member_name} moves from {origin} to {} and joins {}.",
action.destination_location_id, action.destination_gestalt_id
),
actor_ids: vec![format!("member:{}", action.member_id)],
actor_ids: vec![crate::domain::gestalt_member_subject_id(&action.member_id)],
institution_ids: vec![],
gestalt_ids: vec![action.source_gestalt_id, action.destination_gestalt_id],
location_ids: vec![origin, action.destination_location_id],
@ -2949,7 +2950,7 @@ fn collect_outcome_subject_ids(
StrategicOutcomeEffect::MemberMemory { member_id, .. }
| StrategicOutcomeEffect::MemberObligation { member_id, .. }
| StrategicOutcomeEffect::MemberRelationship { member_id, .. } => {
subjects.insert(format!("member:{member_id}"));
subjects.insert(crate::domain::gestalt_member_subject_id(member_id));
if let StrategicOutcomeEffect::MemberRelationship {
other_subject_id, ..
} = effect
@ -3662,7 +3663,7 @@ mod tests {
.chain(plan.member_activities.iter().map(|activity| {
(
activity.action_digest.clone(),
format!("member:{}", activity.member_id),
crate::domain::gestalt_member_subject_id(&activity.member_id),
)
}))
.map(
@ -4724,7 +4725,7 @@ mod tests {
let actor = materialize_actor(
&value.gestalts[destination],
member,
&format!("member:{id}"),
&crate::domain::gestalt_member_subject_id(id),
location,
);
assert_eq!(actor.name, old.name);

View file

@ -213,7 +213,7 @@ pub fn lower_fission(
.get(&member.id)
.unwrap_or(&preview.residual_child_id);
mutations.push(WorldMutation::ChangePopulationMembership {
actor: actor_subject(&format!("member:{}", member.id)),
actor: actor_subject(&crate::domain::gestalt_member_subject_id(&member.id)),
operation: PopulationMembershipOperation::Transfer,
source_population: Some(parent.clone()),
destination_population: Some(population_subject(destination)),
@ -657,7 +657,7 @@ fn project_accepted_fission(
.member_child_assignments
.get(&member_id)
.unwrap_or(&preview.residual_child_id);
let actor = actor_subject(&format!("member:{member_id}"));
let actor = actor_subject(&crate::domain::gestalt_member_subject_id(&member_id));
let active_destinations = next
.memberships
.iter()
@ -1043,7 +1043,7 @@ pub fn lower_strategic_wave(
.last_location_id
.as_deref()
.unwrap_or(&source.home_location_id);
let subject = actor_subject(&format!("member:{}", action.member_id));
let subject = actor_subject(&crate::domain::gestalt_member_subject_id(&action.member_id));
if origin != action.destination_location_id {
mutations.push(WorldMutation::Relocate {
subject: subject.clone(),
@ -1196,7 +1196,7 @@ fn push_strategic_outcome_mutations(
StrategicOutcomeEffect::MemberMemory { member_id, memory } => {
ensure_member(campaign, member_id)?;
mutations.push(WorldMutation::ChangeMemory {
subject: actor_subject(&format!("member:{member_id}")),
subject: actor_subject(&crate::domain::gestalt_member_subject_id(member_id)),
operation: MemoryMutationOperation::Record,
memory_id: format!(
"memory:strategic:{}:{}",
@ -1213,7 +1213,7 @@ fn push_strategic_outcome_mutations(
} => {
ensure_member(campaign, member_id)?;
mutations.push(WorldMutation::ChangeCommitment {
subject: actor_subject(&format!("member:{member_id}")),
subject: actor_subject(&crate::domain::gestalt_member_subject_id(member_id)),
operation: CommitmentMutationOperation::Create,
kind: CommitmentKind::Obligation,
commitment_id: format!(
@ -1233,7 +1233,7 @@ fn push_strategic_outcome_mutations(
let member = ensure_member(campaign, member_id)?;
let target = resolve_subject(campaign, other_subject_id)
.ok_or_else(|| anyhow!("strategic member relationship target is unknown"))?;
let source = actor_subject(&format!("member:{member_id}"));
let source = actor_subject(&crate::domain::gestalt_member_subject_id(member_id));
mutations.push(WorldMutation::ChangeRelationship {
source: source.clone(),
target,
@ -1781,7 +1781,7 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
}
}
for member in campaign.gestalt_members.values() {
let subject = actor_subject(&format!("member:{}", member.id));
let subject = actor_subject(&crate::domain::gestalt_member_subject_id(&member.id));
let gestalt = campaign
.gestalts
.get(&member.gestalt_id)
@ -1811,12 +1811,13 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
.unwrap_or(&gestalt.home_location_id),
),
);
let identity_id = format!("identity:canonical:{}", subject.id);
state
.identities
.entry(format!("identity:canonical:member:{}", member.id))
.entry(identity_id.clone())
.or_insert_with(|| IdentityHandleState {
schema: "ghostlight.identity_handle.v1".into(),
id: format!("identity:canonical:member:{}", member.id),
id: identity_id,
subject: subject.clone(),
value: member.name.clone(),
active: true,
@ -2833,7 +2834,7 @@ fn legacy_resources(campaign: &Campaign) -> BTreeSet<(SubjectRef, String)> {
}
}
for member in campaign.gestalt_members.values() {
let owner = actor_subject(&format!("member:{}", member.id));
let owner = actor_subject(&crate::domain::gestalt_member_subject_id(&member.id));
for resource in &member.equipment {
resources.insert((owner.clone(), resource.clone()));
}

View file

@ -508,7 +508,7 @@ pub fn validate_plan_activity_outcomes(
(
activity.action_digest.clone(),
CellActionProposal {
subject_id: format!("member:{}", activity.member_id),
subject_id: crate::domain::gestalt_member_subject_id(&activity.member_id),
intent: "committed strategic activity".into(),
intended_effect: "resolve the committed strategic activity".into(),
priority: 0,
@ -820,14 +820,20 @@ fn resolved_outcome_summary(
}
StrategicOutcomeEffect::MemberMemory { member_id, memory } => format!(
"{} retains a new memory: {memory}.",
subject_name(campaign, &format!("member:{member_id}"))?
subject_name(
campaign,
&crate::domain::gestalt_member_subject_id(member_id)
)?
),
StrategicOutcomeEffect::MemberObligation {
member_id,
obligation,
} => format!(
"{} accepts an obligation: {obligation}.",
subject_name(campaign, &format!("member:{member_id}"))?
subject_name(
campaign,
&crate::domain::gestalt_member_subject_id(member_id)
)?
),
StrategicOutcomeEffect::MemberRelationship {
member_id,
@ -835,7 +841,10 @@ fn resolved_outcome_summary(
description,
} => format!(
"{}'s relationship with {} becomes: {description}.",
subject_name(campaign, &format!("member:{member_id}"))?,
subject_name(
campaign,
&crate::domain::gestalt_member_subject_id(member_id)
)?,
subject_name(campaign, other_subject_id)?
),
StrategicOutcomeEffect::KnowledgeLearned {

View file

@ -61,6 +61,8 @@ impl CampaignRegistry {
.load::<Campaign>("campaign.v1", &keys[0])?
.ok_or_else(|| anyhow!("campaign row vanished during migration"))?;
let before = campaign.clone();
let normalized_member_identities =
crate::resolution::normalize_legacy_gestalt_member_identities(&mut campaign)?;
crate::resolution::ensure_agency_profiles(&mut campaign);
if campaign != before {
let previous_resolution_epoch = campaign.resolution_policy.resolution_epoch;
@ -79,7 +81,11 @@ impl CampaignRegistry {
provider_configuration_epoch: campaign
.resolution_policy
.provider_configuration_epoch,
operation: "migrate_flat_agency_state".into(),
operation: if normalized_member_identities {
"normalize_gestalt_member_identity".into()
} else {
"migrate_flat_agency_state".into()
},
active_cell_budget: campaign.resolution_policy.active_cell_budget,
pin_ids: campaign.resolution_pins.keys().cloned().collect(),
committed_at: Utc::now(),

View file

@ -116,6 +116,248 @@ pub fn ensure_agency_profiles(campaign: &mut Campaign) {
ensure_structural_relations(campaign);
}
/// Normalize the one historical compiler shape that stored the world-subject
/// `member:` namespace inside a Gestalt member's local ID. This migrates only
/// current canonical references. Event history and persisted receipts remain
/// immutable witnesses of the version that produced them.
pub(crate) fn normalize_legacy_gestalt_member_identities(campaign: &mut Campaign) -> Result<bool> {
let mut next = campaign.clone();
let changed = normalize_legacy_gestalt_member_identities_inner(&mut next)?;
if changed {
*campaign = next;
}
Ok(changed)
}
fn normalize_legacy_gestalt_member_identities_inner(campaign: &mut Campaign) -> Result<bool> {
let mut changed = false;
let mut subject_aliases = BTreeMap::new();
let mut normalized_members = BTreeMap::new();
for (stored_key, mut member) in std::mem::take(&mut campaign.gestalt_members) {
if stored_key != member.id {
return Err(anyhow!(
"gestalt member map key {:?} disagrees with record ID {:?}",
stored_key,
member.id
));
}
let old_local_id = member.id.clone();
let local_id = crate::domain::canonical_gestalt_member_local_id(&old_local_id);
if local_id.is_empty() {
return Err(anyhow!("gestalt member has an empty canonical local ID"));
}
let canonical_subject_id = crate::domain::gestalt_member_subject_id(&local_id);
let legacy_subject_id = format!("member:{old_local_id}");
if old_local_id != local_id {
changed = true;
insert_identity_alias(
&mut subject_aliases,
legacy_subject_id.clone(),
canonical_subject_id.clone(),
)?;
}
match member.materialized_actor_id.clone() {
Some(old_actor_id) => {
let actor = campaign.actors.get(&old_actor_id).ok_or_else(|| {
anyhow!(
"materialized gestalt member {} references missing actor {}",
old_local_id,
old_actor_id
)
})?;
if actor.id != old_actor_id {
return Err(anyhow!(
"materialized actor map key {:?} disagrees with record ID {:?}",
old_actor_id,
actor.id
));
}
if old_actor_id != canonical_subject_id {
changed = true;
insert_identity_alias(
&mut subject_aliases,
old_actor_id,
canonical_subject_id.clone(),
)?;
}
member.materialized_actor_id = Some(canonical_subject_id);
}
None => {
if campaign.actors.contains_key(&canonical_subject_id)
|| (legacy_subject_id != canonical_subject_id
&& campaign.actors.contains_key(&legacy_subject_id))
{
return Err(anyhow!(
"dormant gestalt member {} collides with a materialized actor",
old_local_id
));
}
}
}
member.id = local_id.clone();
if normalized_members
.insert(local_id.clone(), member)
.is_some()
{
return Err(anyhow!(
"gestalt member IDs collide after normalization at {local_id}"
));
}
}
campaign.gestalt_members = normalized_members;
if !changed {
return Ok(false);
}
let mut normalized_actors = BTreeMap::new();
for (stored_key, mut actor) in std::mem::take(&mut campaign.actors) {
if stored_key != actor.id {
return Err(anyhow!(
"actor map key {:?} disagrees with record ID {:?}",
stored_key,
actor.id
));
}
let actor_id = rewritten_subject_id(&subject_aliases, &stored_key);
actor.id = actor_id.clone();
if normalized_actors.insert(actor_id.clone(), actor).is_some() {
return Err(anyhow!(
"actor IDs collide after gestalt member normalization at {actor_id}"
));
}
}
campaign.actors = normalized_actors;
campaign.player_actor_id = rewritten_subject_id(&subject_aliases, &campaign.player_actor_id);
for actor in campaign.actors.values_mut() {
rewrite_relationship_targets(&mut actor.relationships, &subject_aliases)?;
}
for member in campaign.gestalt_members.values_mut() {
rewrite_relationship_targets(&mut member.relationships, &subject_aliases)?;
}
for proposal in &mut campaign.pending_world_proposals {
proposal.actor_id = rewritten_subject_id(&subject_aliases, &proposal.actor_id);
for reference in &mut proposal.state_references {
*reference = rewritten_state_reference(&subject_aliases, reference);
}
}
let mut normalized_profiles = BTreeMap::new();
for (stored_key, mut profile) in std::mem::take(&mut campaign.agency_profiles) {
let subject_id = rewritten_subject_id(&subject_aliases, &profile.subject_id);
let profile_key = rewritten_subject_id(&subject_aliases, &stored_key);
if profile_key != subject_id {
return Err(anyhow!(
"agency profile key {:?} disagrees with normalized subject {:?}",
profile_key,
subject_id
));
}
if let Some(old_subject_id) = subject_aliases
.iter()
.find_map(|(old, new)| (new == &subject_id).then_some(old))
{
let legacy_profile_id = format!("agency-profile:{old_subject_id}");
if profile.id == legacy_profile_id {
profile.id = format!("agency-profile:{subject_id}");
}
}
profile.subject_id = subject_id.clone();
profile.collective_authority_id = profile
.collective_authority_id
.as_deref()
.map(|id| rewritten_subject_id(&subject_aliases, id));
profile.parent_subject_id = profile
.parent_subject_id
.as_deref()
.map(|id| rewritten_subject_id(&subject_aliases, id));
if normalized_profiles
.insert(subject_id.clone(), profile)
.is_some()
{
return Err(anyhow!(
"agency profiles collide after gestalt member normalization at {subject_id}"
));
}
}
campaign.agency_profiles = normalized_profiles;
for relation in campaign.agency_relations.values_mut() {
relation.from_subject_id =
rewritten_subject_id(&subject_aliases, &relation.from_subject_id);
relation.to_subject_id = rewritten_subject_id(&subject_aliases, &relation.to_subject_id);
}
for pin in campaign.resolution_pins.values_mut() {
pin.subject_ids = pin
.subject_ids
.iter()
.map(|id| rewritten_subject_id(&subject_aliases, id))
.collect();
}
campaign.resolution_cover = None;
Ok(true)
}
fn insert_identity_alias(
aliases: &mut BTreeMap<String, String>,
old: String,
new: String,
) -> Result<()> {
if old == new {
return Ok(());
}
if aliases
.insert(old.clone(), new.clone())
.is_some_and(|prior| prior != new)
{
return Err(anyhow!(
"legacy subject {old} maps to multiple canonical identities"
));
}
Ok(())
}
fn rewritten_subject_id(aliases: &BTreeMap<String, String>, value: &str) -> String {
aliases
.get(value)
.cloned()
.unwrap_or_else(|| value.to_owned())
}
fn rewritten_state_reference(aliases: &BTreeMap<String, String>, value: &str) -> String {
if let Some(rewritten) = aliases.get(value) {
return rewritten.clone();
}
for namespace in ["subject:", "actor:"] {
if let Some(subject_id) = value.strip_prefix(namespace)
&& let Some(rewritten) = aliases.get(subject_id)
{
return format!("{namespace}{rewritten}");
}
}
value.to_owned()
}
fn rewrite_relationship_targets(
relationships: &mut BTreeMap<String, String>,
aliases: &BTreeMap<String, String>,
) -> Result<()> {
let mut normalized = BTreeMap::new();
for (subject_id, description) in std::mem::take(relationships) {
let subject_id = rewritten_subject_id(aliases, &subject_id);
if normalized.insert(subject_id.clone(), description).is_some() {
return Err(anyhow!(
"relationship targets collide after gestalt member normalization at {subject_id}"
));
}
}
*relationships = normalized;
Ok(())
}
fn base_profile(
subject_id: &str,
kind: AgencySubjectKind,
@ -2510,7 +2752,7 @@ pub fn member_state_references(campaign: &Campaign, member_id: &str) -> Result<B
.get(&member.gestalt_id)
.ok_or_else(|| anyhow!("gestalt member baseline is unknown"))?;
let mut references = BTreeSet::from([
format!("member:{member_id}"),
crate::domain::gestalt_member_subject_id(member_id),
format!("gestalt:{}", member.gestalt_id),
format!(
"location:{}",
@ -2726,7 +2968,7 @@ pub fn strategic_activity_targets(campaign: &Campaign, subject_id: &str) -> BTre
source
.location_ids
.contains(&location)
.then(|| format!("member:{}", member.id))
.then(|| crate::domain::gestalt_member_subject_id(&member.id))
}));
}
targets
@ -2741,7 +2983,7 @@ pub fn member_activity_targets(campaign: &Campaign, member_id: &str) -> Result<B
dormant_member_location(campaign, member_id)?;
let mut targets = strategic_activity_targets(campaign, &member.gestalt_id);
targets.insert(member.gestalt_id.clone());
targets.remove(&format!("member:{member_id}"));
targets.remove(&crate::domain::gestalt_member_subject_id(member_id));
Ok(targets)
}
@ -2750,16 +2992,18 @@ pub fn member_activity_targets(campaign: &Campaign, member_id: &str) -> Result<B
/// stable ID, so the `member:` prefix cannot own their current subject kind.
pub fn dormant_member_id_for_subject<'a>(
campaign: &'a Campaign,
subject_id: &'a str,
subject_id: &str,
) -> Option<&'a str> {
let member_id = subject_id.strip_prefix("member:")?;
campaign
.gestalt_members
.get(member_id)
.values()
.filter(|member| {
member.materialized_actor_id.is_none() && !campaign.actors.contains_key(subject_id)
member.materialized_actor_id.is_none()
&& crate::domain::gestalt_member_subject_id(&member.id) == subject_id
&& !campaign.actors.contains_key(subject_id)
})
.map(|_| member_id)
.map(|member| member.id.as_str())
.next()
}
pub fn validate_active_gestalt_presence_location(
@ -3254,6 +3498,151 @@ pub(crate) mod tests {
assert_eq!(dormant_member_id_for_subject(&value, "member:sable"), None);
}
#[test]
fn legacy_prefixed_member_identity_migrates_once_without_losing_person_state() {
let mut value = campaign(1, 1);
value.gestalts.insert(
"raincross-households".into(),
GestaltPersonaState {
schema: "ghostlight.gestalt_persona_state.v1".into(),
id: "raincross-households".into(),
name: "Raincross households".into(),
version: 0,
home_location_id: "center".into(),
shared_capabilities: BTreeSet::new(),
shared_knowledge: BTreeSet::new(),
resources: BTreeSet::new(),
goals: vec![],
pressures: vec![],
},
);
value.gestalt_members.insert(
"member:cal_rusk".into(),
GestaltMemberDelta {
schema: "ghostlight.gestalt_member_delta.v1".into(),
id: "member:cal_rusk".into(),
gestalt_id: "raincross-households".into(),
version: 3,
name: "Cal Rusk".into(),
capability_additions: BTreeSet::from(["read flood trails".into()]),
capability_removals: BTreeSet::new(),
knowledge_additions: BTreeSet::from(["the bypass is passable".into()]),
knowledge_removals: BTreeSet::new(),
equipment: BTreeSet::new(),
conditions: BTreeSet::new(),
obligations: BTreeSet::new(),
relationships: BTreeMap::from([(
"member:member:cal_rusk".into(),
"trusts his own survey".into(),
)]),
goals: vec!["keep the households alive".into()],
memories: vec!["surveyed the bypass".into()],
last_location_id: Some("center".into()),
materialized_actor_id: Some("member:member:cal_rusk".into()),
last_relevant_revision: 5,
relevance_lease_until_revision: 9,
},
);
value.actors.insert(
"member:member:cal_rusk".into(),
ActorState {
id: "member:member:cal_rusk".into(),
name: "Cal Rusk".into(),
location_id: "center".into(),
capabilities: BTreeSet::from(["read flood trails".into()]),
knowledge: BTreeSet::from(["the bypass is passable".into()]),
equipment: BTreeSet::new(),
conditions: BTreeSet::new(),
obligations: BTreeSet::new(),
relationships: BTreeMap::new(),
goals: vec!["keep the households alive".into()],
memories: vec!["surveyed the bypass".into()],
},
);
value.actors["player"].relationships.insert(
"member:member:cal_rusk".into(),
"heard his careful report".into(),
);
value.pending_world_proposals.push(WorldActionProposal {
actor_id: "member:member:cal_rusk".into(),
intent: "check the route".into(),
intended_effect: "keep the route legible".into(),
priority: 1,
state_references: vec!["subject:member:member:cal_rusk".into()],
});
ensure_agency_profiles(&mut value);
value
.agency_profiles
.get_mut("member:member:cal_rusk")
.unwrap()
.detail_debt = 17;
value.agency_relations.insert(
"cal-households".into(),
AgencyRelation {
schema: "ghostlight.agency_relation.v1".into(),
id: "cal-households".into(),
from_subject_id: "member:member:cal_rusk".into(),
to_subject_id: "raincross-households".into(),
kind: AgencyRelationKind::Membership,
strength: 100,
active: true,
evidence_receipt_ids: vec![],
},
);
value.resolution_pins.insert(
"pin-cal".into(),
ResolutionPin {
schema: "ghostlight.resolution_pin.v1".into(),
id: "pin-cal".into(),
kind: ResolutionPinKind::MinimumIndividualDetail,
subject_ids: BTreeSet::from(["member:member:cal_rusk".into()]),
reason: "foreground".into(),
created_world_revision: 5,
},
);
value.resolution_cover =
Some(plan_cover(&value, default_demand(&value, "legacy cover")).unwrap());
assert!(normalize_legacy_gestalt_member_identities(&mut value).unwrap());
assert!(!normalize_legacy_gestalt_member_identities(&mut value).unwrap());
assert!(!value.gestalt_members.contains_key("member:cal_rusk"));
let member = &value.gestalt_members["cal_rusk"];
assert_eq!(member.id, "cal_rusk");
assert_eq!(
member.materialized_actor_id.as_deref(),
Some("member:cal_rusk")
);
assert!(
member
.knowledge_additions
.contains("the bypass is passable")
);
assert!(member.memories.contains(&"surveyed the bypass".into()));
assert!(member.relationships.contains_key("member:cal_rusk"));
assert!(!value.actors.contains_key("member:member:cal_rusk"));
assert_eq!(value.actors["member:cal_rusk"].name, "Cal Rusk");
assert!(
value.actors["player"]
.relationships
.contains_key("member:cal_rusk")
);
assert_eq!(value.pending_world_proposals[0].actor_id, "member:cal_rusk");
assert_eq!(
value.pending_world_proposals[0].state_references,
["subject:member:cal_rusk"]
);
assert_eq!(value.agency_profiles["member:cal_rusk"].detail_debt, 17);
assert_eq!(
value.agency_relations["cal-households"].from_subject_id,
"member:cal_rusk"
);
assert_eq!(
value.resolution_pins["pin-cal"].subject_ids,
BTreeSet::from(["member:cal_rusk".into()])
);
assert!(value.resolution_cover.is_none());
}
#[test]
fn aetheria_scale_cover_is_complete_unique_and_budgeted() {
for budget in [1, 4, 8, 32] {

View file

@ -699,7 +699,7 @@ fn member_exceptions(campaign: &Campaign, cell: &SimulationCell) -> Result<Vec<C
member.goals.clone()
};
let mut permitted_state_references = BTreeSet::from([
format!("member:{}", member.id),
crate::domain::gestalt_member_subject_id(&member.id),
format!("gestalt:{}", member.gestalt_id),
format!("location:{origin}"),
]);
@ -723,7 +723,7 @@ fn member_exceptions(campaign: &Campaign, cell: &SimulationCell) -> Result<Vec<C
let migration_destinations = migration_destination_slices(campaign, &destinations)?;
let activity_targets = activity_target_slices(campaign, &activity_target_ids)?;
Ok(CellMemberSlice {
subject_id: format!("member:{}", member.id),
subject_id: crate::domain::gestalt_member_subject_id(&member.id),
member_id: member.id.clone(),
name: member.name.clone(),
source_gestalt_id: member.gestalt_id.clone(),

View file

@ -63,7 +63,7 @@ pub async fn resolve_speech_addresses(
return None;
}
Some((
format!("member:{}", member.id),
crate::domain::gestalt_member_subject_id(&member.id),
serde_json::json!({
"public_name": member.name,
"presence": "nearby_folded_person",