From fd64595908be80e5e6895f0a425a7e3732661af3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 06:49:24 +0200 Subject: [PATCH] Stop retirement and revocation from corrupting a subject's own history (PA.f31, PA.f32) `verify_state_shape` judged every past DecisionEvent against the subject's *current* controller and affordance grants, so retiring a subject that had acted, or revoking a verb it had used, failed `verify_append` with CorruptJournal. Authority is judged once, at commit, by `reduce`, and after that by replay (`verify_history` re-runs `reduce` for every commit) and the digest chain; the shape check now holds only the time-invariant facts (scope resolves to a live assignment, affordance is a catalog entry). Every production caller of `verify_state_shape` (`verify_append`, `verify_history`, both reached from `recover`/`initialize`) already replays, so no load path loses coverage. `required_approvers` reads the live controller assignment, and `Retired` carries no human principal, so an owner could retire the human's subject in Draft, approve alone, and activate without the human's approval. `Retire` now refuses a Human-controlled subject in Draft with a new `RetiresAnApprover` mismatch, re-decided at apply beside the resolver. In Active the human's subject may die: the journal's approval-shape check now holds only in Draft, since Active approvals are frozen history judged once by `ActivateWorld`'s `reduce` arm and after that by replay. Co-Authored-By: Claude Sonnet 5 --- crates/ghostlight/src/journal.rs | 38 +++++---- crates/ghostlight/src/lib.rs | 138 +++++++++++++++++++++++++++++++ crates/ghostlight/src/patch.rs | 24 ++++++ 3 files changed, 185 insertions(+), 15 deletions(-) diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index a32edc4..bd13589 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -876,19 +876,25 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> )); } } - let required = super::required_approvers(state); - if !state.draft_approvals.is_subset(&required) - || (state.phase != super::WorldPhase::Draft && !required.is_subset(&state.draft_approvals)) - { - return Err(JournalError::Corrupt( - "draft approvals do not match canonical controller ownership".into(), - )); + // Approval shape holds only in Draft. Once the world is Active, the + // approvals that admitted it are frozen history: they are judged once by + // `reduce`'s `ActivateWorld` arm and after that by replay, not re-judged + // here against the present-day controller roster. Retiring the human + // (PA.f32) or adding a later human-controlled subject must not make a + // past activation retroactively corrupt. + if state.phase == super::WorldPhase::Draft { + let required = super::required_approvers(state); + if !state.draft_approvals.is_subset(&required) { + return Err(JournalError::Corrupt( + "draft approvals do not match canonical controller ownership".into(), + )); + } } let mut event_ids = BTreeSet::new(); let mut claimed = BTreeSet::new(); let mut previous_event_revision = 0; for event in &state.events { - let assignment = state + state .controller_assignments .get(&event.scope) .ok_or_else(|| JournalError::Corrupt("event references an unknown scope".into()))?; @@ -915,13 +921,15 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> || event.revision == 0 || event.revision > state.revision || event.revision <= previous_event_revision - // A forged history in which a mirror acted fails here: its - // assignment has no controller id to match. - || Some(event.controller_id) != assignment.id() - || !state - .affordance_grants - .get(&event.scope) - .is_some_and(|granted| granted.contains(&event.affordance)) + // Present-tense controller identity and present-tense affordance + // grants are not judged here: retiring a subject or revoking a + // verb it once used must not corrupt the history of that use. + // Authority at commit time is judged once, by `reduce`, and after + // that by replay (`verify_history` re-runs `reduce` for every + // commit) and by the digest chain. This shape check only holds + // time-invariant facts: the event's scope resolves to a live + // assignment (checked above) and its affordance is a catalog + // entry (checked above). || event.band >= entry.outcome_bands.len() // Speech rides only a speech-carrying entry; such an entry always // addresses someone, by words or by a visible act; a display may diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 10d559f..38a4039 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -2992,6 +2992,16 @@ fn apply_operation( "retire operation names a mirror this world does not own".into(), )); } + // A human's Draft approval cannot be removed by removing the + // human: re-decided here beside the resolver's + // `RetiresAnApprover`, since resolution is speculative and + // this is the actual commit. Active retires the human freely; + // its approval is frozen history by then, judged by replay. + ControllerAssignment::Human { .. } if state.phase == WorldPhase::Draft => { + return Err(KernelError::Invariant( + "retire operation would remove a Draft approver".into(), + )); + } ControllerAssignment::Human { .. } | ControllerAssignment::NarrativePersona { .. } | ControllerAssignment::OperationalAgent { .. } => {} @@ -17648,4 +17658,132 @@ mod clock_tests { "{error:?}" ); } + + // ---- Retirement fix batch (PA.f31-PA.f35) ------------------------------ + + fn human_subject(snapshot: &WorldSnapshot) -> SubjectId { + snapshot + .subjects + .iter() + .find(|subject| subject.controller_mode == Some(ControllerMode::Human)) + .expect("the genesis human") + .id + } + + /// PA.f31: `verify_state_shape` no longer judges a `DecisionEvent` against + /// the subject's *present* controller identity. Retiring a subject that + /// has acted commits, and the history survives a full reopen-and-replay + /// (`recover` -> `verify_history` -> `verify_state_shape` for every + /// commit, per the caller census in the report). + #[test] + fn retiring_a_subject_that_acted_survives_reopen_and_replay() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let world_id = { + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "PAf31RetireActed"); + exercise( + &mut kernel, + clockwork.reeve, + clockwork.threaten, + vec![binding("target", Target::Subject(clockwork.farmer))], + ) + .expect("the threat commits"); + owner_retires(&mut kernel, clockwork.reeve) + .expect("a subject with history can be retired"); + kernel.state.world_id + }; + let reopened = WorldKernel::open(&path, world_id).expect("the store replays"); + let retired = reopened + .snapshot() + .unwrap() + .subjects + .iter() + .find(|subject| subject.label == "The Yard Reeve") + .expect("the reeve survives replay") + .retired; + assert!(retired); + } + + /// PA.f31: revoking a verb a subject has already used commits, and the + /// event that used it survives reopen-and-replay for the same reason: the + /// grant a `DecisionEvent` rode is judged once, at commit, not re-judged + /// against the present-day grant set. + #[test] + fn revoking_a_used_verb_survives_reopen_and_replay() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let world_id = { + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "PAf31RevokeUsed"); + exercise( + &mut kernel, + clockwork.reeve, + clockwork.threaten, + vec![binding("target", Target::Subject(clockwork.farmer))], + ) + .expect("the threat commits"); + let before = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &before, + operations(vec![ComponentOp::RevokeAffordance { + subject: Ref::Existing(clockwork.reeve), + affordance: Ref::Existing(clockwork.threaten), + }]), + ); + kernel.state.world_id + }; + let reopened = WorldKernel::open(&path, world_id).expect("the store replays"); + assert_eq!(reopened.state.events.len(), 1); + } + + /// PA.f32(a): the resolver refuses `Retire` for a `Human`-controlled + /// subject while the world is Draft, before the owner can retire the + /// human, approve alone, and activate without the human's approval. + #[test] + fn retiring_the_human_in_draft_is_refused() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = WorldKernel::create( + directory.path().join("world.cc"), + creation(CommandId::new(), "PAf32DraftRefusal"), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let human = human_subject(&kernel.snapshot().unwrap()); + let error = owner_retires(&mut kernel, human).unwrap_err(); + assert!( + matches!(&error, KernelError::PatchRejected(set) + if set.contains(&Mismatch::RetiresAnApprover { operation: 0 })), + "{error:?}" + ); + } + + /// PA.f32(b, c): once the world is Active, the human's Draft approval is + /// frozen history, so the owner may retire the human — the player's + /// subject may die — and the retire survives reopen-and-replay because + /// the journal's approval-shape check no longer re-reads the present-day + /// controller roster in Active. + #[test] + fn retiring_the_human_in_active_survives_reopen_and_replay() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let (world_id, human) = { + let (mut kernel, clockwork, active) = clock_kernel(directory.path(), "PAf32ActiveRetire"); + let _ = &clockwork; + let human = human_subject(&active); + owner_retires(&mut kernel, human).expect("the owner retires the human subject"); + (kernel.state.world_id, human) + }; + let reopened = WorldKernel::open(&path, world_id).expect("the store replays"); + let retired = reopened + .snapshot() + .unwrap() + .subjects + .iter() + .find(|subject| subject.id == human) + .expect("the human survives replay") + .retired; + assert!(retired); + } + } diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index b0ca996..e3da0f2 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -1600,6 +1600,12 @@ pub enum Mismatch { RetiresAMirror { operation: usize, }, + /// `Retire` named a `Human`-controlled subject while the world is still in + /// `Draft`. A human's Draft approval cannot be removed by removing the + /// human; the same retire is admitted once the world is `Active`. + RetiresAnApprover { + operation: usize, + }, /// `GrantAffordance` or `RevokeAffordance` named a retired or an /// `ExternallyControlled` subject: neither holds a grant this patch may /// touch. @@ -3815,6 +3821,24 @@ pub(super) fn resolve_patch( }); continue; } + // A human's Draft approval cannot be removed by removing the + // human: `required_approvers` reads the live controller + // assignment and `Retired` carries no human principal, so an + // owner could otherwise retire the human, approve alone, and + // activate. Once the world is Active, approvals are frozen + // history judged by replay, and the human's subject may die. + if state.phase == super::WorldPhase::Draft + && let Key::Existing(existing_id) = &subject_key + && state + .controller_assignments + .get(&super::DecisionScope { subject_id: *existing_id }) + .is_some_and(|assignment| assignment.human_principal().is_some()) + { + mismatches.push(Mismatch::RetiresAnApprover { + operation: position, + }); + continue; + } retired.insert(subject_key.clone()); positions.remove(&subject_key); affordance_holdings.remove(&subject_key);