Seal mutation ownership and atomic versioning

This commit is contained in:
Meta Vens 2026-08-23 14:42:27 +02:00
commit 6fbe47ad88
3 changed files with 165 additions and 49 deletions

View file

@ -155,6 +155,15 @@ fn execute_mutation_batch(
batch: crate::transition::WorldMutationBatch,
) -> Result<CommandResult, KernelError> {
let key = batch.campaign_id.to_string();
if store
.load::<Campaign>("campaign.v1", &key)
.map_err(persist)?
.is_some()
{
return Err(KernelError::Invalid(
"aggregate campaigns accept mutations only through their WorldCommand mailbox".into(),
));
}
let (row, state) = store
.load::<crate::transition::ComponentWorldState>("component_world_state.v1", &key)
.map_err(persist)?
@ -2977,10 +2986,11 @@ fn commit_governed_group_travel(
.locations
.get(&proposal.origin_location_id)
.and_then(|location| {
location.routes.iter().find_map(|(route_id, route)| {
(route.destination_id == proposal.destination_location_id)
.then_some(route.travel_minutes)
})
location
.routes
.values()
.find(|route| route.destination_id == proposal.destination_location_id)
.map(|route| route.travel_minutes)
})
.ok_or_else(|| KernelError::Invalid("group-travel route no longer exists".into()))?;
if route_minutes != proposal.travel_minutes {
@ -3162,6 +3172,15 @@ mod tests {
plan
}
fn assert_only_strategic_obligations_advanced(before: &Campaign, after: &Campaign) {
let mut expected = before.clone();
expected.world_time += Duration::hours(i64::from(expected.tick_hours));
for clock in expected.clocks.values_mut() {
clock.progress = clock.progress.saturating_add(1).min(clock.threshold);
}
assert_eq!(after, &expected);
}
fn campaign() -> Campaign {
let id = uuid::Uuid::new_v4();
let actor = ActorState {
@ -3339,7 +3358,7 @@ mod tests {
assert!(
kernel
.commit_mutation_batch(authority, batch)
.commit_mutation_batch(authority.clone(), batch.clone())
.await
.is_err()
);
@ -3350,6 +3369,26 @@ mod tests {
.1;
assert_eq!(stored.revision, 8);
assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1);
let mut aggregate = campaign();
aggregate.id = campaign_id;
store.create_campaign(&aggregate, &[], &[]).unwrap();
let error = kernel
.commit_mutation_batch(authority, batch)
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("only through their WorldCommand mailbox")
);
let stored = store
.load::<ComponentWorldState>("component_world_state.v1", &campaign_id.to_string())
.unwrap()
.unwrap()
.1;
assert_eq!(stored.revision, 8);
assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1);
}
fn hierarchical_refugee_campaign() -> Campaign {
@ -3795,7 +3834,7 @@ mod tests {
assert_eq!(actor.memories, old.memories);
}
for (id, before, version_increment) in [
("refugees-east", source_before, 2),
("refugees-east", source_before, 1),
("dock-neighbors", docks_before, 1),
("hill-neighbors", hills_before, 1),
] {
@ -4073,7 +4112,7 @@ mod tests {
}),
)
.unwrap();
assert_eq!(value, before);
assert_only_strategic_obligations_advanced(&before, &value);
assert_eq!(events.len(), 2);
assert_eq!(events[0].kind, "gestalt_activity");
assert_eq!(
@ -4274,7 +4313,7 @@ mod tests {
}),
)
.unwrap();
assert_eq!(value, before);
assert_only_strategic_obligations_advanced(&before, &value);
assert_eq!(
events[0].summary,
"Eastern transit refugees begins a local investigation."
@ -4302,7 +4341,7 @@ mod tests {
}),
)
.unwrap();
assert_eq!(value, before);
assert_only_strategic_obligations_advanced(&before, &value);
assert_eq!(
events[0].summary,
"Eastern transit refugees attempts a local communication."
@ -4331,7 +4370,7 @@ mod tests {
}),
)
.unwrap();
assert_eq!(value, before);
assert_only_strategic_obligations_advanced(&before, &value);
assert_eq!(
events[0].summary,
"Eastern transit refugees attempts local interference."
@ -4361,7 +4400,7 @@ mod tests {
}),
)
.unwrap();
assert_eq!(value, before);
assert_only_strategic_obligations_advanced(&before, &value);
assert_eq!(
events[0].summary,
"Mira Venn attempts a local communication."
@ -4396,7 +4435,7 @@ mod tests {
}),
)
.unwrap();
assert_eq!(value, before);
assert_only_strategic_obligations_advanced(&before, &value);
assert_eq!(events.len(), 2);
assert_eq!(
events[0].summary,
@ -4488,7 +4527,7 @@ mod tests {
}),
)
.unwrap();
assert_eq!(value, before);
assert_only_strategic_obligations_advanced(&before, &value);
assert_eq!(events.len(), 2);
assert_eq!(events[0].kind, "gestalt_member_activity");
assert_eq!(
@ -4533,6 +4572,12 @@ mod tests {
.unwrap();
assert_eq!(stored.revision, 1);
assert_eq!(stored.world_time, seed.world_time + Duration::minutes(30));
assert_eq!(
store.keys("mutation_authority_envelope.v1").unwrap().len(),
1
);
assert_eq!(store.keys("world_mutation_batch.v1").unwrap().len(), 1);
assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1);
}
#[tokio::test]
@ -4799,7 +4844,7 @@ mod tests {
}
#[tokio::test]
async fn strategic_tick_persists_channel_news_without_using_legacy_host_as_truth() {
async fn strategic_tick_without_an_admitted_wave_advances_only_deterministic_obligations() {
let dir = tempfile::tempdir().unwrap();
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
let kernel = WorldKernel::start(store.clone());
@ -4835,18 +4880,19 @@ mod tests {
let CommandResult::Committed { campaign, .. } = result else {
panic!("expected commit")
};
assert_eq!(campaign.events.len(), 1);
assert_eq!(campaign.news.len(), 1);
assert_eq!(campaign.news[0].channel, "institution:board");
assert!(campaign.events.is_empty());
assert!(campaign.news.is_empty());
assert_eq!(campaign.away_ticks_processed, 1);
assert!(campaign.institutions["board"].posture.contains("acting"));
assert_eq!(campaign.institutions["board"].posture, "watching");
assert_eq!(campaign.world_time, seed.world_time + Duration::hours(6));
let ticks = store
.load_all::<crate::domain::StrategicTickReceipt>("strategic_tick.v1")
.unwrap();
assert_eq!(ticks.len(), 1);
assert_eq!(ticks[0].source, TickSource::Scheduler);
assert_eq!(ticks[0].event_ids, vec![campaign.events[0].id.clone()]);
assert!(ticks[0].event_ids.is_empty());
assert!(ticks[0].model_receipt_hash.is_none());
assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1);
}
#[tokio::test]
@ -6526,6 +6572,12 @@ mod tests {
campaign.agency_profiles["guest"].location_ids,
BTreeSet::from(["harbor".into()])
);
assert_eq!(
store.keys("mutation_authority_envelope.v1").unwrap().len(),
1
);
assert_eq!(store.keys("world_mutation_batch.v1").unwrap().len(), 1);
assert_eq!(store.keys("world_mutation_receipt.v1").unwrap().len(), 1);
let mut reload_projection = campaign.clone();
crate::resolution::ensure_agency_profiles(&mut reload_projection);
assert_eq!(reload_projection, campaign);

View file

@ -1328,6 +1328,8 @@ fn project_mutated_components(
) -> Result<()> {
let mut resources_changed = false;
let mut resource_owners_changed = BTreeSet::new();
let mut touched_members = BTreeSet::new();
let mut touched_gestalts = BTreeSet::new();
for permitted in &batch.mutations {
match &permitted.mutation {
WorldMutation::TransferCustody {
@ -1354,6 +1356,7 @@ fn project_mutated_components(
.get_mut(member_id)
.ok_or_else(|| anyhow!("accepted relocation member vanished"))?;
member.last_location_id = Some(destination.id.clone());
touched_members.insert(member_id.to_string());
if let Some(actor) = campaign.actors.get_mut(&subject.id) {
actor.location_id = destination.id.clone();
}
@ -1375,7 +1378,7 @@ fn project_mutated_components(
.get_mut(&subject.id)
.ok_or_else(|| anyhow!("accepted relocation population vanished"))?;
gestalt.home_location_id = destination.id.clone();
gestalt.version = gestalt.version.saturating_add(1);
touched_gestalts.insert(subject.id.clone());
let profile = campaign
.agency_profiles
.get_mut(&subject.id)
@ -1426,7 +1429,7 @@ fn project_mutated_components(
.ok_or_else(|| anyhow!("accepted knowledge member vanished"))?;
member.knowledge_removals.remove(&statement);
member.knowledge_additions.insert(statement.clone());
member.version = member.version.saturating_add(1);
touched_members.insert(member_id.to_string());
if let Some(actor) = campaign.actors.get_mut(&subject.id) {
actor.knowledge.insert(statement.clone());
}
@ -1445,7 +1448,7 @@ fn project_mutated_components(
.get_mut(&subject.id)
.ok_or_else(|| anyhow!("accepted knowledge gestalt vanished"))?;
gestalt.shared_knowledge.insert(statement.clone());
gestalt.version = gestalt.version.saturating_add(1);
touched_gestalts.insert(subject.id.clone());
}
_ => {
return Err(anyhow!(
@ -1499,7 +1502,7 @@ fn project_mutated_components(
actor.relationships.remove(&target.id);
}
}
member.version = member.version.saturating_add(1);
touched_members.insert(member_id.to_string());
} else {
let actor = campaign
.actors
@ -1529,7 +1532,7 @@ fn project_mutated_components(
.ok_or_else(|| anyhow!("accepted memory member vanished"))?;
if !member.memories.contains(&value.summary) {
member.memories.push(value.summary.clone());
member.version = member.version.saturating_add(1);
touched_members.insert(member_id.to_string());
}
if let Some(actor) = campaign.actors.get_mut(&subject.id) {
if actor.memories.len() < 64 && !actor.memories.contains(&value.summary) {
@ -1577,7 +1580,7 @@ fn project_mutated_components(
});
}
}
member.version = member.version.saturating_add(1);
touched_members.insert(member_id.to_string());
if let Some(actor) = campaign.actors.get_mut(&subject.id) {
match (kind, value) {
(CommitmentKind::Goal, Some(value)) => {
@ -1642,7 +1645,7 @@ fn project_mutated_components(
if !value.resolved {
gestalt.pressures.push(value.label.clone());
}
gestalt.version = gestalt.version.saturating_add(1);
touched_gestalts.insert(value.owner.id.clone());
} else {
return Err(anyhow!("aggregate pressure owner is unsupported"));
}
@ -1699,6 +1702,9 @@ fn project_mutated_components(
&source.id,
&destination.id,
)?;
touched_members.insert(member_id.to_string());
touched_gestalts.insert(source.id.clone());
touched_gestalts.insert(destination.id.clone());
}
WorldMutation::AdvanceWorldTime { .. } => {
campaign.world_time = next.world_time;
@ -1712,7 +1718,28 @@ fn project_mutated_components(
}
}
if resources_changed {
project_all_resources(campaign, next, &resource_owners_changed)?;
project_all_resources(campaign, next)?;
for owner in resource_owners_changed {
match owner.kind {
SubjectKind::Actor if owner.id.starts_with("member:") => {
touched_members.insert(owner.id.trim_start_matches("member:").to_string());
}
SubjectKind::Population => {
touched_gestalts.insert(owner.id);
}
_ => {}
}
}
}
for member_id in touched_members {
if let Some(member) = campaign.gestalt_members.get_mut(&member_id) {
member.version = member.version.saturating_add(1);
}
}
for gestalt_id in touched_gestalts {
if let Some(gestalt) = campaign.gestalts.get_mut(&gestalt_id) {
gestalt.version = gestalt.version.saturating_add(1);
}
}
Ok(())
}
@ -1792,15 +1819,10 @@ fn project_member_population_transfer(
effective_goals
};
member.gestalt_id = destination_population_id.into();
member.version = member.version.saturating_add(1);
Ok(())
}
fn project_all_resources(
campaign: &mut Campaign,
next: &ComponentWorldState,
changed_owners: &BTreeSet<SubjectRef>,
) -> Result<()> {
fn project_all_resources(campaign: &mut Campaign, next: &ComponentWorldState) -> Result<()> {
for actor in campaign.actors.values_mut() {
actor.equipment.clear();
}
@ -1856,22 +1878,6 @@ fn project_all_resources(
_ => return Err(anyhow!("aggregate resource custodian is unsupported")),
}
}
for owner in changed_owners {
match owner.kind {
SubjectKind::Actor if owner.id.starts_with("member:") => {
let member_id = owner.id.trim_start_matches("member:");
if let Some(member) = campaign.gestalt_members.get_mut(member_id) {
member.version = member.version.saturating_add(1);
}
}
SubjectKind::Population => {
if let Some(gestalt) = campaign.gestalts.get_mut(&owner.id) {
gestalt.version = gestalt.version.saturating_add(1);
}
}
_ => {}
}
}
for institution in campaign.institutions.values_mut() {
institution.resources.sort();
institution.resources.dedup();

View file

@ -4266,6 +4266,64 @@ mod tests {
assert_eq!(invalid_state.revision, state.revision);
}
#[test]
fn exact_relocation_permit_cannot_cross_a_closed_or_different_route() {
let state = component_world();
let sable = subject(SubjectKind::Actor, "actor:sable");
let camp = subject(SubjectKind::Place, "place:camp");
let trail = subject(SubjectKind::Place, "place:trail");
let permit = permit(
"permit:move",
WorldMutationOperation::Relocate,
vec![
(
MutationSubjectRole::Subject,
BTreeSet::from([sable.clone()]),
),
(
MutationSubjectRole::OriginPlace,
BTreeSet::from([camp.clone()]),
),
(
MutationSubjectRole::DestinationPlace,
BTreeSet::from([trail.clone()]),
),
],
vec![(MutationStringRole::RouteId, exact(&["route:camp-trail"]))],
vec![],
);
let envelope = envelope_for(&state, vec![permit]);
let batch = batch_for(
&state,
&envelope,
vec![(
"permit:move",
WorldMutation::Relocate {
subject: sable.clone(),
from_place: camp.clone(),
to_place: trail.clone(),
route_id: "route:camp-trail".into(),
},
)],
);
let mut closed = state.clone();
closed.topology.get_mut("route:camp-trail").unwrap().open = false;
assert!(apply_component_world_batch(&closed, &envelope, &batch, Utc::now()).is_err());
assert_eq!(closed.occupancy[&sable], camp);
assert_eq!(closed.revision, state.revision);
let mut redirected = state.clone();
redirected
.topology
.get_mut("route:camp-trail")
.unwrap()
.to_place = subject(SubjectKind::Place, "place:east-trail");
assert!(apply_component_world_batch(&redirected, &envelope, &batch, Utc::now()).is_err());
assert_eq!(redirected.occupancy[&sable], camp);
assert_eq!(redirected.revision, state.revision);
}
#[test]
fn resource_split_and_transfer_conserve_quantity_and_exact_custody() {
let state = component_world();