diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 7da5c54..b039ece 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -1270,6 +1270,11 @@ fn apply_strategic_tick_plan( campaign: &mut Campaign, plan: crate::domain::StrategicTickPlan, ) -> Result, KernelError> { + // Every action in a strategic wave was chosen against the same committed + // snapshot. Keep that snapshot immutable while applying to a private copy + // so action ordering cannot rewrite another action's permissions and an + // invalid late action cannot leave this primitive partially mutated. + let mut next = campaign.clone(); let revision = campaign.revision + 1; let at = campaign.world_time; let mut events = Vec::new(); @@ -1295,7 +1300,7 @@ fn apply_strategic_tick_plan( )); } validate_public_channels(&action.public_channels)?; - let institution = campaign + let institution = next .institutions .get_mut(&action.institution_id) .ok_or_else(|| KernelError::Invalid("strategic plan invented an institution".into()))?; @@ -1330,7 +1335,7 @@ fn apply_strategic_tick_plan( )); } validate_public_channels(&action.public_channels)?; - let gestalt = campaign + let gestalt = next .gestalts .get_mut(&action.gestalt_id) .ok_or_else(|| KernelError::Invalid("strategic plan invented a gestalt".into()))?; @@ -1397,13 +1402,13 @@ fn apply_strategic_tick_plan( let destination_name = campaign.gestalts[&action.destination_gestalt_id] .name .clone(); - let gestalt = campaign + let gestalt = next .gestalts .get_mut(&action.gestalt_id) .expect("gestalt migration source was validated"); gestalt.home_location_id = action.destination_location_id.clone(); gestalt.version = gestalt.version.saturating_add(1); - let profile = campaign + let profile = next .agency_profiles .get_mut(&action.gestalt_id) .expect("gestalt migration profile was validated"); @@ -1553,8 +1558,7 @@ fn apply_strategic_tick_plan( } let origin = actor.location_id.clone(); let actor_name = actor.name.clone(); - campaign - .actors + next.actors .get_mut(&action.actor_id) .expect("actor was validated") .location_id = action.destination_id.clone(); @@ -1686,7 +1690,7 @@ fn apply_strategic_tick_plan( .clone() }); let member_name = campaign.gestalt_members[&action.member_id].name.clone(); - rebase_member_migration(campaign, &action)?; + rebase_member_migration(&mut next, &action)?; events.push(crate::domain::Event { id: format!("strategic:{revision}:member:{}", action.member_id), at, @@ -1702,6 +1706,7 @@ fn apply_strategic_tick_plan( public_channels: action.public_channels, }); } + *campaign = next; Ok(events) } @@ -2714,6 +2719,86 @@ mod tests { ); } + #[test] + fn simultaneous_migration_preserves_snapshot_scoped_activity() { + let mut value = hierarchical_refugee_campaign(); + value.gestalts.insert( + "camp-neighbors".into(), + GestaltPersonaState { + schema: "ghostlight.gestalt_persona_state.v1".into(), + id: "camp-neighbors".into(), + name: "Camp neighbors".into(), + version: 0, + home_location_id: "camp".into(), + shared_capabilities: BTreeSet::from(["raise signal fires".into()]), + shared_knowledge: BTreeSet::new(), + resources: BTreeSet::new(), + goals: vec!["keep departures visible".into()], + pressures: vec![], + }, + ); + crate::resolution::ensure_agency_profiles(&mut value); + + let events = apply_strategic_tick_plan( + &mut value, + StrategicTickPlan { + gestalt_migrations: vec![StrategicGestaltMigration { + gestalt_id: "refugees-east".into(), + destination_gestalt_id: "dock-neighbors".into(), + destination_location_id: "docks".into(), + public_channels: vec![], + }], + gestalt_activities: vec![StrategicGestaltActivity { + gestalt_id: "camp-neighbors".into(), + activity: StrategicActivityKind::Communicate, + target_subject_ids: vec!["refugees-east".into()], + location_ids: vec!["camp".into()], + public_channels: vec![], + }], + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(value.gestalts["refugees-east"].home_location_id, "docks"); + assert_eq!(events.len(), 2); + assert_eq!(events[1].kind, "gestalt_activity"); + assert_eq!(events[1].location_ids, vec!["camp"]); + assert_eq!( + events[1].summary, + "Camp neighbors sends a communication to Eastern transit refugees." + ); + } + + #[test] + fn invalid_late_strategic_action_cannot_partially_apply_an_earlier_action() { + let mut value = hierarchical_refugee_campaign(); + let before = value.clone(); + let error = apply_strategic_tick_plan( + &mut value, + StrategicTickPlan { + gestalt_migrations: vec![StrategicGestaltMigration { + gestalt_id: "refugees-east".into(), + destination_gestalt_id: "dock-neighbors".into(), + destination_location_id: "docks".into(), + public_channels: vec![], + }], + gestalt_activities: vec![StrategicGestaltActivity { + gestalt_id: "refugees-east".into(), + activity: StrategicActivityKind::Prepare, + target_subject_ids: vec![], + location_ids: vec!["camp".into()], + public_channels: vec![], + }], + ..Default::default() + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("acts twice")); + assert_eq!(value, before); + } + #[test] fn population_cannot_move_without_its_exact_migration_relation() { let mut value = hierarchical_refugee_campaign(); diff --git a/docs/architecture/ghostlight-multiresolution-agency.md b/docs/architecture/ghostlight-multiresolution-agency.md index 1dfd09b..242f781 100644 --- a/docs/architecture/ghostlight-multiresolution-agency.md +++ b/docs/architecture/ghostlight-multiresolution-agency.md @@ -454,6 +454,16 @@ population transition receives its exact current pressure set and the failed transition reason. The correction model is not expected to reconstruct a validator from a generic rejection. +The WorldKernel resolves every strategic batch against one immutable campaign +snapshot. It applies validated transitions to a private working copy and swaps +that copy into the campaign only after the whole batch succeeds. Earlier +actions in the batch therefore cannot rewrite the spatial, relationship, or +knowledge permissions used by later actions. This matters when, for example, a +camp population signals refugees during the same strategic horizon in which +those refugees depart: both choices are judged from the shared starting world, +while the committed result still contains the migration. A late invalid action +discards the working copy and leaves the campaign untouched. + An actionful cell has a fourth private stage after the Interpreter: the `cell_effect_verifier`. This cheap structured reader compares the natural Persona decision with each attributed typed effect. It rejects reversals, diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index a76c0e7..1d88c39 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -332,6 +332,17 @@ or waits from the Persona turn, forbids absence-as-inaction, and requests reasons within 160 characters. Rerun a 23-wave fairness-length budget-8 profile at provider parallelism 8 from the same committed migration baseline. +The next exact-build profile passed the original Mira action/inaction conflict +after local validation began returning the exact failed condition instead of a +four-way generic error. It then exposed a deeper kernel seam: one camp Gestalt +communicated with `refugees-east` in the same wave that the refugees migrated. +The proposal was valid against the shared snapshot, but the apply loop moved +the refugees before rechecking the later communication. Strategic application +now reads the original snapshot, mutates a private working campaign, and swaps +it in only on total success. Regressions cover the simultaneous +migration/communication case and prove that an invalid late action cannot +partially apply an earlier migration. + Do not call setting-wide material agency finished yet. Most population output is still an attributed `gestalt_activity` attempt stored as history. The long run became repetitive around ferry loading and shelter requests because no diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index a1ac9a2..887174d 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -192,6 +192,13 @@ cannot duplicate, cross cell authority, or coexist with an action from the same subject. They are non-mutating audit/fairness evidence. The former global inaction string is no longer an owner or runtime path. +Strategic plan application reads authority and permissions from one immutable +command snapshot. All writes land in a private working campaign which replaces +the command-local campaign only after every action validates. Sequential loop +order is no longer a fictional owner: a migration earlier in the apply loop +cannot invalidate another subject's same-snapshot communication, and a late +invalid action cannot leave earlier in-memory mutations behind. + The active cell budget and provider concurrency limit are separate controls. Budget and pins increment `resolution_epoch` without advancing world revision or fictional time. Provider concurrency increments only diff --git a/state/map.yaml b/state/map.yaml index 7504bc0..8186763 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -267,6 +267,6 @@ data_flow: - If state grows faster than understanding, cut it back. current_status: - summary: GhostlightDungeon production remains on the verified Starfire release. The 31-wave budget-8/provider-parallelism-4 live profile covered every subject, committed 194 consequences, recovered 15 rejected pulses without partial mutation, preserved player and rival/member agency, and returned the same Mira. Its trace exposed the missing attributed-inaction representation; the global inaction string has now been replaced by exact non-mutating per-subject inactions. Production state remains untouched. - next_action: Rerun a 23-wave budget-8/provider-parallelism-8 profile after forbidding invented inaction for unvoiced subjects, then compare retry rate, cache efficiency, latency, and exact cover behavior against the 6253f9f parallelism-4 baseline. Keep a future strategic outcome resolver separate from Interpreter authority. + summary: GhostlightDungeon production remains on the verified Starfire release. The 31-wave budget-8/provider-parallelism-4 live profile covered every subject, committed 194 consequences, recovered 15 rejected pulses without partial mutation, preserved player and rival/member agency, and returned the same Mira. Attributed inaction is now exact and non-mutating. The latest live profile also exposed and repaired sequential strategic application: every batch now validates from one immutable snapshot and swaps in a private working campaign only after total success. Production state remains untouched. + next_action: Rerun the 23-wave budget-8/provider-parallelism-8 profile from the committed migration baseline after the immutable-snapshot strategic apply cut, then compare retry rate, cache efficiency, latency, exact cover behavior, and earned callback against the 6253f9f parallelism-4 baseline. Keep a future strategic outcome resolver separate from Interpreter authority. gestalt_population: Gestalt Personas own shared population baselines; persistent member deltas own named individuality; WorldKernel materialization and approval-gated fission preserve lineage and identity. Presence projection is limited to nearby active leaves and exact-location dormant members, includes reciprocal player relationship evidence, and may propose an earned cast without committing it. Simulation cells are derived covers only.