From bc1852dfeff5208468eacec67743b411b808e3f0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 07:53:53 +0200 Subject: [PATCH] Refuse a genesis self-retiring human, a retired promisor, and a retired institution's lent authority (PA.f36-f38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PA.f36: the resolver tracked a freshly declared mirror in `mirrors` but never tracked a freshly declared human, so a genesis patch that declared and retired the human in the same patch was admitted; only the apply-time re-check caught it, after `open_owner` had already committed an empty world.cc. The resolver now tracks a `humans` candidate set the same way, seeded from the committed controller assignment and extended for a subject this patch declares as `NewController::Human`, and refuses its retirement in Draft with `RetiresAnApprover`. PA.f37: a retired subject could still act through the clock and through commitments. `CreateCommitment` now refuses a retired promisor with `RetiredSubjectActed`, and `derive_motion` now skips a retired subject's commitments and dependencies entirely, so its routines stop rolling and its obligations, goals, and dependencies stop pressing. Nothing is discharged or deleted; a retired subject's commitments stay on record exactly as they were the moment it retired. PA.f38: `delegated_authority` read a retired institution's grants, so a live office-holder kept a dissolved institution's authority. `Retire` does not vacate offices — the incumbency stays on the ledger — but a retired institution now lends nothing: `delegated_authority` skips a retired institution's offices at the one site that turns an office into effective authority. Co-Authored-By: Claude Sonnet 5 --- crates/ghostlight/src/clock.rs | 12 +- crates/ghostlight/src/lib.rs | 239 +++++++++++++++++++++++++++++++++ crates/ghostlight/src/patch.rs | 37 ++++- 3 files changed, 280 insertions(+), 8 deletions(-) diff --git a/crates/ghostlight/src/clock.rs b/crates/ghostlight/src/clock.rs index 9d32fd6..a02c215 100644 --- a/crates/ghostlight/src/clock.rs +++ b/crates/ghostlight/src/clock.rs @@ -7,7 +7,7 @@ //! the span. use super::patch::{Commitment, CommitmentKey, CommitmentKind, DependencyTarget, MAX_ROUTE_COST}; -use super::{PressureMagnitude, PressureSource, SubjectId, WorldState, action}; +use super::{PressureMagnitude, PressureSource, SubjectId, WorldState, action, is_retired}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -103,6 +103,13 @@ pub(super) fn derive_motion(state: &WorldState, to: FictionalMinutes) -> Motion let mut pressed: BTreeMap<(SubjectId, PressureSource), u32> = BTreeMap::new(); for (subject, held) in &state.commitments { + // A retired subject cannot act, and neither fulfilling a routine nor + // pressing an obligation or goal is anything but that subject acting: + // its commitments stay in history, un-discharged and un-deleted, but + // the clock stops moving them the moment they are its. + if is_retired(state, *subject) { + continue; + } for (key, commitment) in held { if commitment.due > to { continue; @@ -134,6 +141,9 @@ pub(super) fn derive_motion(state: &WorldState, to: FictionalMinutes) -> Motion } for (subject, targets) in &state.dependencies { + if is_retired(state, *subject) { + continue; + } for target in targets { if dependency_unavailable(state, *subject, *target) { let source = PressureSource::Dependency(*target); diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 9bab0d3..4b5f316 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -3464,6 +3464,13 @@ fn delegated_authority( let mut held: BTreeMap>> = BTreeMap::new(); for (institution, offices) in &state.selection { + // A retired institution lends nothing: `Retire` does not vacate + // offices, so the incumbency stays on record, but a dissolved + // institution's grants stop reaching its office-holders here, the one + // site that turns an office into effective authority. + if is_retired(state, *institution) { + continue; + } for (name, office) in offices { if office.incumbent != Some(subject_id) { continue; @@ -8515,6 +8522,49 @@ mod tests { "the key covers the door, but the door's far side is still a draft" ); } + + /// PA.f38: a retired institution lends nothing. `Retire` does not vacate + /// the office the reeve holds — the incumbency stays on the ledger — but + /// `delegated_authority` stops reading a dissolved institution's grants, + /// the one site that turns an office into effective authority. + #[test] + fn a_retired_institution_lends_no_authority_to_its_office_holder() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = crate::custody_tests::custody_kernel(directory.path(), "RetiredLends"); + let (_, civic, before) = civic_world(&mut kernel); + let over_hall = AuthorityGrant { + kind: authority_kind(LEVY_KIND), + over: AuthorityTarget::PlaceSubtree(civic.hall), + }; + assert!( + subject_authority(&kernel.state, civic.reeve).contains(&over_hall), + "the reeve holds levy only through the warden office before retirement" + ); + + submit_owner( + &mut kernel, + &before, + operations(vec![ComponentOp::Retire { + subject: Ref::Existing(civic.treasury), + }]), + ); + + let warden = kernel + .state + .selection + .get(&civic.treasury) + .and_then(|offices| offices.get(&office(WARDEN_OFFICE))) + .expect("retire did not vacate the office"); + assert_eq!( + warden.incumbent, + Some(civic.reeve), + "the reeve still holds the office after the institution retires" + ); + assert!( + !subject_authority(&kernel.state, civic.reeve).contains(&over_hall), + "a retired institution still lent authority through its office" + ); + } } #[cfg(test)] @@ -18179,4 +18229,193 @@ mod clock_tests { ); assert_eq!(error, vec![Mismatch::NoOperationEffect { operation: 1 }]); } + + // ---- Retirement fix batch (PA.f36-PA.f38) ------------------------------ + + /// PA.f36: the resolver refuses a genesis patch that declares a + /// Human-controlled subject and retires it in the same patch. The + /// resolver's `humans` candidate set tracks a freshly declared human the + /// same way `mirrors` tracks a freshly declared mirror, so this is caught + /// before `open_owner` ever commits an empty `world.cc` for a human who + /// never approved anything. + #[test] + fn genesis_cannot_declare_and_retire_the_human_in_one_patch() { + let directory = tempfile::tempdir().unwrap(); + let mut genesis = creation(CommandId::new(), "PAf36GenesisRetire"); + genesis.patch.operations.push(ComponentOp::Retire { + subject: Ref::Draft(DraftHandle::new("human")), + }); + let Err(error) = WorldKernel::create( + directory.path().join("world.cc"), + genesis, + &auth_principal(owner()), + ) else { + panic!("genesis admitted a patch that declares and retires the human"); + }; + assert!( + matches!(&error, KernelError::PatchRejected(set) + if set.contains(&Mismatch::RetiresAnApprover { operation: 0 })), + "{error:?}" + ); + } + + /// PA.f36: pins the apply-time re-check directly, beside the resolver's + /// refusal above, since resolution is speculative and this arm is the + /// actual commit. If the resolver's `humans` tracking were ever disabled + /// or bypassed, this is the arm that must still refuse removing a Draft + /// approver. + #[test] + fn apply_refuses_removing_a_draft_approver_directly() { + let directory = tempfile::tempdir().unwrap(); + let kernel = WorldKernel::create( + directory.path().join("world.cc"), + creation(CommandId::new(), "PAf36ApplyArm"), + &auth_principal(owner()), + ) + .expect("a created world") + .0; + let human = human_subject(&kernel.snapshot().unwrap()); + let mut state = kernel.state.clone(); + assert!( + matches!( + apply_operation(&mut state, &ResolvedOp::Retire { subject: human }, &[]) + .unwrap_err(), + KernelError::Invariant(message) + if message == "retire operation would remove a Draft approver" + ), + "the apply-time arm no longer refuses removing the Draft human" + ); + } + + /// PA.f37: a retired subject cannot become a promisor. `CreateCommitment` + /// refuses it with `RetiredSubjectActed`, the same mismatch a retired + /// office incumbent draws. + #[test] + fn a_retired_subject_cannot_be_a_promisor() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "PAf37Promisor"); + owner_retires(&mut kernel, clockwork.farmer).expect("retire"); + let before = kernel.snapshot().unwrap(); + let error = reject_owner( + &mut kernel, + &before, + operations(vec![ComponentOp::CreateCommitment { + subject: Ref::Existing(clockwork.farmer), + counterparty: None, + kind: CommitmentKind::Obligation, + due: FictionalMinutes(LATE_DUE + 1), + period: None, + checks: Vec::new(), + statement: Statement::new("What was promised, as the promisor would say it.") + .unwrap(), + }]), + ); + assert_eq!(error, vec![Mismatch::RetiredSubjectActed { operation: 0 }]); + } + + /// PA.f37: once a subject is retired, the clock stops moving what it + /// holds — its routine no longer rolls and its obligation no longer + /// presses — while a live subject's goal and dependency still move on the + /// same tick. Nothing is discharged or deleted; the retired subject's + /// commitments stay on record, un-fulfilled and un-pressed, exactly as + /// they were the moment it retired. + #[test] + fn after_retirement_a_tick_moves_none_of_the_retired_subjects_commitments() { + let directory = tempfile::tempdir().unwrap(); + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "PAf37Frozen"); + let snapshot = kernel.snapshot().unwrap(); + submit_owner( + &mut kernel, + &snapshot, + operations(vec![ComponentOp::CloseRoute { + route: Ref::Existing(clockwork.gate), + }]), + ); + owner_retires(&mut kernel, clockwork.farmer).expect("retire the farmer"); + let routine_before = due(&kernel, clockwork.farmer, clockwork.routine); + let dependency_source = PressureSource::Dependency(DependencyTarget::Route(clockwork.gate)); + + tick(&mut kernel, ROUTINE_DUE as u32 + LATE_DUE as u32).expect("the tick commits"); + + assert_eq!( + due(&kernel, clockwork.farmer, clockwork.routine), + routine_before, + "a retired subject's routine still rolled" + ); + assert_eq!( + pressure( + &kernel, + clockwork.farmer, + PressureSource::Commitment { + subject: clockwork.farmer, + key: clockwork.obligation, + } + ), + 0, + "a retired subject's obligation still pressed" + ); + assert_eq!( + pressure( + &kernel, + clockwork.reeve, + PressureSource::Commitment { + subject: clockwork.reeve, + key: clockwork.goal, + } + ), + 1, + "the live subject's goal did not press" + ); + assert_eq!( + pressure(&kernel, clockwork.reeve, dependency_source), + 1, + "the live subject's dependency did not press" + ); + + assert!( + kernel.state.commitments[&clockwork.farmer].contains_key(&clockwork.routine), + "the frozen routine was discharged or deleted rather than left as history" + ); + assert!( + kernel.state.commitments[&clockwork.farmer].contains_key(&clockwork.obligation), + "the frozen obligation was discharged or deleted rather than left as history" + ); + } + + /// PA.f37: the freeze survives a full reopen-and-replay, the same way + /// every other retirement effect in this batch does. + #[test] + fn a_retired_subjects_frozen_commitments_survive_reopen_and_replay() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("world.cc"); + let (world_id, farmer, routine, obligation) = { + let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "PAf37Replay"); + owner_retires(&mut kernel, clockwork.farmer).expect("retire the farmer"); + tick(&mut kernel, ROUTINE_DUE as u32 + LATE_DUE as u32).expect("the tick commits"); + ( + kernel.state.world_id, + clockwork.farmer, + clockwork.routine, + clockwork.obligation, + ) + }; + let reopened = WorldKernel::open(&path, world_id).expect("the store replays"); + assert_eq!( + due(&reopened, farmer, routine), + FictionalMinutes(ROUTINE_DUE), + "the routine rolled somewhere along the replayed path" + ); + assert_eq!( + pressure( + &reopened, + farmer, + PressureSource::Commitment { + subject: farmer, + key: obligation, + } + ), + 0, + "the obligation pressed somewhere along the replayed path" + ); + } } diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index 760e3a6..16d07ac 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -3292,6 +3292,18 @@ pub(super) fn resolve_patch( }) .map(|(scope, _)| Key::Existing(scope.subject_id)) .collect(); + // Tracked the same way as `mirrors`: seeded from the committed human + // controller, extended below for a subject this same patch declares as + // `NewController::Human`. `Retire` refuses a Draft-phase approver out of + // this set instead of relying solely on the apply-time re-check, which + // otherwise only catches a genesis patch that declares and retires the + // human in the same breath after `open_owner` has already committed. + let mut humans: BTreeSet> = state + .controller_assignments + .iter() + .filter(|(_, assignment)| assignment.human_principal().is_some()) + .map(|(scope, _)| Key::Existing(scope.subject_id)) + .collect(); let mut affordance_holdings: BTreeMap, BTreeSet>> = state .affordance_grants .iter() @@ -3508,6 +3520,9 @@ pub(super) fn resolve_patch( if matches!(subject.controller, NewController::External { .. }) { mirrors.insert(Key::Draft(subject.handle.clone())); } + if matches!(subject.controller, NewController::Human { .. }) { + humans.insert(Key::Draft(subject.handle.clone())); + } if let Some(reference) = &subject.position && let Some(place) = resolve_entity( Site::Declaration(subject.handle.clone()), @@ -3643,6 +3658,15 @@ pub(super) fn resolve_patch( let Some(subject_key) = subject_key else { continue; }; + // A retired subject cannot act, and making a promise is + // acting: refused here the same way `RetiredSubjectActed` + // refuses a retired office incumbent below. + if retired.contains(&subject_key) { + mismatches.push(Mismatch::RetiredSubjectActed { + operation: position, + }); + continue; + } if let Some(counterparty_key) = &counterparty_key { match counterparty_key { None => continue, @@ -3835,13 +3859,12 @@ pub(super) fn resolve_patch( // 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()) - { + // `humans` tracks a subject declared as `NewController::Human` + // in this same patch exactly as `mirrors` tracks a freshly + // declared mirror, so a genesis patch that declares and + // retires the human in one breath is refused here rather than + // only by the apply-time re-check after admission. + if state.phase == super::WorldPhase::Draft && humans.contains(&subject_key) { mismatches.push(Mismatch::RetiresAnApprover { operation: position, });