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:
parent
a6e949219c
commit
fd64595908
3 changed files with 185 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue