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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 06:49:24 +02:00
commit fd64595908
3 changed files with 185 additions and 15 deletions

View file

@ -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

View file

@ -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);
}
}

View file

@ -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);