Add subject retirement to the world kernel (play agent Cut 4, PA-Q6)
ControllerAssignment::Retired is a new value of the existing controller
assignment map, not a second partition: id() and mode() return None for it
like a mirror, so derive_opportunities mints nothing and it is never an
audience once its position is gone. expected_caller becomes Option<CallerId>
(None only for Retired; a mirror still returns Some, the one caller that
could ever act for it) and both exact-caller comparison sites move to
Some(&caller).
ComponentOp::Retire{subject} resolves beside Relocate: refuses an already
retired subject with NoOperationEffect and a mirror with the new
Mismatch::RetiresAMirror. Applying it sets the assignment to Retired and
removes the subject's positions and affordance_grants entries, so a witness
at its former place never reaches it while its holdings, commitments, and
knowledge stay untouched. confine_to_ground refuses Retire unconditionally
for every confined author, the same unconditional shape require_ruler already
gave a Ruled fact and a Mint: the owner and the play authority are the two
callers left. SubjectSnapshot gains pub retired: bool, set in snapshot().
PATCH_TOOLS gains retire (Operate, one subject field); catalog counts move to
(7, 32, 41). Schema and controller_work row version are unchanged: this rides
Cut 3's consumer.v6 / controller_work.v17.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7791bad845
commit
3725ba80fb
4 changed files with 462 additions and 24 deletions
|
|
@ -6717,6 +6717,7 @@ mod tests {
|
|||
human_controller: None,
|
||||
affordances: BTreeSet::from([speak_affordance]),
|
||||
position: None,
|
||||
retired: false,
|
||||
components: fixture_components(),
|
||||
offices_held: Vec::new(),
|
||||
offices_granted: Vec::new(),
|
||||
|
|
@ -6758,6 +6759,7 @@ mod tests {
|
|||
human_controller: None,
|
||||
affordances: BTreeSet::new(),
|
||||
position: None,
|
||||
retired: false,
|
||||
components: fixture_components(),
|
||||
offices_held: Vec::new(),
|
||||
offices_granted: Vec::new(),
|
||||
|
|
@ -6984,6 +6986,7 @@ mod tests {
|
|||
human_controller: None,
|
||||
affordances: BTreeSet::from([speak_affordance]),
|
||||
position: Some(yard),
|
||||
retired: false,
|
||||
components: ScopeComponents {
|
||||
routes: BTreeMap::from([(first_edge, fixture_route(yard, road))]),
|
||||
..fixture_components()
|
||||
|
|
@ -7006,6 +7009,7 @@ mod tests {
|
|||
human_controller: None,
|
||||
affordances: BTreeSet::new(),
|
||||
position: Some(vault),
|
||||
retired: false,
|
||||
components: ScopeComponents {
|
||||
routes: BTreeMap::from([(second_edge, fixture_route(road, vault))]),
|
||||
..fixture_components()
|
||||
|
|
@ -7452,6 +7456,7 @@ mod tests {
|
|||
human_controller: None,
|
||||
affordances: BTreeSet::from([speak_affordance]),
|
||||
position,
|
||||
retired: false,
|
||||
components,
|
||||
offices_held: Vec::new(),
|
||||
offices_granted: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -720,6 +720,11 @@ enum ControllerAssignment {
|
|||
ExternallyControlled {
|
||||
consumer: ConsumerId,
|
||||
},
|
||||
/// A subject `Retire` ended for good. Not a second partition:
|
||||
/// `controller_assignments` stays the sole answer to who may call, and
|
||||
/// this is one of its values. It has no controller id, no mode, and no
|
||||
/// caller, so it mints no opportunity and is never an audience.
|
||||
Retired,
|
||||
}
|
||||
|
||||
impl ControllerAssignment {
|
||||
|
|
@ -728,7 +733,7 @@ impl ControllerAssignment {
|
|||
Self::Human { controller_id, .. }
|
||||
| Self::NarrativePersona { controller_id }
|
||||
| Self::OperationalAgent { controller_id } => Some(*controller_id),
|
||||
Self::ExternallyControlled { .. } => None,
|
||||
Self::ExternallyControlled { .. } | Self::Retired => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -737,32 +742,34 @@ impl ControllerAssignment {
|
|||
Self::Human { .. } => Some(ControllerMode::Human),
|
||||
Self::NarrativePersona { .. } => Some(ControllerMode::NarrativePersona),
|
||||
Self::OperationalAgent { .. } => Some(ControllerMode::OperationalAgent),
|
||||
Self::ExternallyControlled { .. } => None,
|
||||
Self::ExternallyControlled { .. } | Self::Retired => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_caller(&self) -> CallerId {
|
||||
/// `None` for a retired subject: no caller may ever act for it again. A
|
||||
/// mirror still returns `Some`, the one caller that could ever act for
|
||||
/// its scope; the controller lane cannot mint it either, but that caller
|
||||
/// exists and is comparable.
|
||||
fn expected_caller(&self) -> Option<CallerId> {
|
||||
match self {
|
||||
Self::Human { principal, .. } => CallerId::Principal(principal.clone()),
|
||||
Self::Human { principal, .. } => Some(CallerId::Principal(principal.clone())),
|
||||
Self::NarrativePersona { controller_id } | Self::OperationalAgent { controller_id } => {
|
||||
CallerId::Controller(*controller_id)
|
||||
}
|
||||
// The only caller that could ever act for this scope, and the
|
||||
// controller lane cannot mint it.
|
||||
Self::ExternallyControlled { consumer } => {
|
||||
CallerId::System(SystemCapability::Consumer {
|
||||
consumer: *consumer,
|
||||
})
|
||||
Some(CallerId::Controller(*controller_id))
|
||||
}
|
||||
Self::ExternallyControlled { consumer } => Some(CallerId::System(
|
||||
SystemCapability::Consumer { consumer: *consumer },
|
||||
)),
|
||||
Self::Retired => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn consumer(&self) -> Option<ConsumerId> {
|
||||
match self {
|
||||
Self::ExternallyControlled { consumer } => Some(*consumer),
|
||||
Self::Human { .. } | Self::NarrativePersona { .. } | Self::OperationalAgent { .. } => {
|
||||
None
|
||||
}
|
||||
Self::Human { .. }
|
||||
| Self::NarrativePersona { .. }
|
||||
| Self::OperationalAgent { .. }
|
||||
| Self::Retired => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -771,7 +778,8 @@ impl ControllerAssignment {
|
|||
Self::Human { principal, .. } => Some(principal),
|
||||
Self::NarrativePersona { .. }
|
||||
| Self::OperationalAgent { .. }
|
||||
| Self::ExternallyControlled { .. } => None,
|
||||
| Self::ExternallyControlled { .. }
|
||||
| Self::Retired => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -982,6 +990,11 @@ pub struct SubjectSnapshot {
|
|||
pub human_controller: Option<PrincipalId>,
|
||||
pub affordances: BTreeSet<AffordanceId>,
|
||||
pub(crate) position: Option<EntityId>,
|
||||
/// Set for a subject `Retire` ended: no opportunity, no position, no
|
||||
/// audience, its history kept. The agent's view and the surface read this
|
||||
/// rather than inferring retirement from an absent position, which a
|
||||
/// merely unplaced live subject also has.
|
||||
pub retired: bool,
|
||||
/// Exactly what the scope digest binds for this subject, carried whole
|
||||
/// rather than lowered into projections that then have to be diffed back
|
||||
/// together: its own position, incident routes, holdings, dependencies,
|
||||
|
|
@ -1616,7 +1629,7 @@ fn reduce(state: &WorldState, command: &CommandEnvelope) -> Result<WorldEffect,
|
|||
.controller_assignments
|
||||
.get(¤t.scope)
|
||||
.ok_or(KernelError::OpportunityMismatch)?;
|
||||
if assignment.expected_caller() != command.caller {
|
||||
if assignment.expected_caller().as_ref() != Some(&command.caller) {
|
||||
return Err(KernelError::ControllerMismatch);
|
||||
}
|
||||
let granted = require_granted(state, ¤t, invocation.affordance)?;
|
||||
|
|
@ -1634,7 +1647,7 @@ fn reduce(state: &WorldState, command: &CommandEnvelope) -> Result<WorldEffect,
|
|||
.controller_assignments
|
||||
.get(¤t.scope)
|
||||
.ok_or(KernelError::OpportunityMismatch)?;
|
||||
if assignment.expected_caller() != command.caller {
|
||||
if assignment.expected_caller().as_ref() != Some(&command.caller) {
|
||||
return Err(KernelError::ControllerMismatch);
|
||||
}
|
||||
Ok(WorldEffect::DecisionDeclined {
|
||||
|
|
@ -2956,6 +2969,32 @@ fn apply_operation(
|
|||
}
|
||||
set_pressure(state, *target, *source, 0);
|
||||
}
|
||||
ResolvedOp::Retire { subject } => {
|
||||
let scope = DecisionScope {
|
||||
subject_id: *subject,
|
||||
};
|
||||
let assignment = state.controller_assignments.get(&scope).ok_or_else(unknown)?;
|
||||
match assignment {
|
||||
ControllerAssignment::Retired => {
|
||||
return Err(KernelError::Invariant(
|
||||
"retire operation names an already retired subject".into(),
|
||||
));
|
||||
}
|
||||
ControllerAssignment::ExternallyControlled { .. } => {
|
||||
return Err(KernelError::Invariant(
|
||||
"retire operation names a mirror this world does not own".into(),
|
||||
));
|
||||
}
|
||||
ControllerAssignment::Human { .. }
|
||||
| ControllerAssignment::NarrativePersona { .. }
|
||||
| ControllerAssignment::OperationalAgent { .. } => {}
|
||||
}
|
||||
state
|
||||
.controller_assignments
|
||||
.insert(scope, ControllerAssignment::Retired);
|
||||
state.positions.remove(subject);
|
||||
state.affordance_grants.remove(&scope);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -3726,6 +3765,7 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
|
|||
human_controller: controller.human_principal().cloned(),
|
||||
affordances,
|
||||
position: components.position.map(|position| position.place),
|
||||
retired: matches!(controller, ControllerAssignment::Retired),
|
||||
offices_held,
|
||||
offices_granted,
|
||||
redress,
|
||||
|
|
@ -4585,6 +4625,15 @@ fn confine_to_ground(
|
|||
}
|
||||
for (index, operation) in resolved.operations.iter().enumerate() {
|
||||
let site = Site::Operation(index);
|
||||
// Retirement is authority over the subject's standing, not structure
|
||||
// over ground: a confined author is refused whatever place the
|
||||
// subject stands under, exactly as `require_ruler` refuses a `Ruled`
|
||||
// fact or a `Mint` outright. The owner is unconfined and never
|
||||
// reaches this clause.
|
||||
if matches!(operation, ResolvedOp::Retire { .. }) {
|
||||
mismatches.push(Mismatch::OutsideJurisdiction { site: site.clone() });
|
||||
continue;
|
||||
}
|
||||
let (subjects, places, routes) = operation_ground(operation, &entities);
|
||||
for subject in subjects {
|
||||
confine_subject(&site, subject, &mut mismatches);
|
||||
|
|
@ -4802,6 +4851,12 @@ fn operation_ground(
|
|||
}
|
||||
(subjects, places(named), routes)
|
||||
}
|
||||
// Confinement never reaches this through the ordinary ground check:
|
||||
// `confine_to_ground` refuses `Retire` unconditionally for every
|
||||
// confined author before this function is called for it. The
|
||||
// subject is named here so the match stays total and correct if
|
||||
// that clause is ever bypassed.
|
||||
ResolvedOp::Retire { subject } => (vec![*subject], Vec::new(), Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4961,7 +5016,9 @@ fn apply_effect(
|
|||
.ok_or_else(|| {
|
||||
KernelError::Invariant("decision scope lost its controller".into())
|
||||
})?;
|
||||
if state.phase != WorldPhase::Active || caller != &assignment.expected_caller() {
|
||||
if state.phase != WorldPhase::Active
|
||||
|| assignment.expected_caller().as_ref() != Some(caller)
|
||||
{
|
||||
return Err(KernelError::Invariant(
|
||||
"decision effect does not match exact opportunity authority".into(),
|
||||
));
|
||||
|
|
@ -4992,7 +5049,9 @@ fn apply_effect(
|
|||
.ok_or_else(|| {
|
||||
KernelError::Invariant("decision scope lost its controller".into())
|
||||
})?;
|
||||
if state.phase != WorldPhase::Active || caller != &assignment.expected_caller() {
|
||||
if state.phase != WorldPhase::Active
|
||||
|| assignment.expected_caller().as_ref() != Some(caller)
|
||||
{
|
||||
return Err(KernelError::Invariant(
|
||||
"decline effect does not match exact opportunity authority".into(),
|
||||
));
|
||||
|
|
@ -16394,4 +16453,285 @@ mod clock_tests {
|
|||
WorldKernel::open(&path, world_id).expect("the store replays a second time");
|
||||
assert_eq!(replayed_again.snapshot().unwrap(), expected);
|
||||
}
|
||||
|
||||
// ---- Cut 4: retirement -------------------------------------------
|
||||
|
||||
fn retire_patch(subject: SubjectId) -> WorldPatch {
|
||||
WorldPatch {
|
||||
declarations: Vec::new(),
|
||||
operations: vec![ComponentOp::Retire {
|
||||
subject: Ref::Existing(subject),
|
||||
}],
|
||||
evidence: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner retires a subject in Active. `retire_patch` declares
|
||||
/// nothing and cites no evidence, so `require_answer`'s `declares` gate
|
||||
/// never opens for it and no answer is needed, exactly as an owner's
|
||||
/// ordinary bare operation needs none.
|
||||
fn owner_retires(kernel: &mut WorldKernel, subject: SubjectId) -> Result<SubmitReceipt, KernelError> {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch: retire_patch(subject),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Declares a mirror bound to a consumer this test invents, at the yard,
|
||||
/// via the owner in Active. Shared by the retirement and grant tests that
|
||||
/// need a subject `mode()` already excludes.
|
||||
fn declare_mirror(kernel: &mut WorldKernel, clockwork: &Clockwork, name: &str) -> SubjectId {
|
||||
let answered = dead_end_boundary(kernel);
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: Some(PatchAnswer::Boundary(answered)),
|
||||
patch: WorldPatch {
|
||||
declarations: vec![Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new("mirror"),
|
||||
label: "The Bound Hold".into(),
|
||||
kind: SubjectKind::Institution,
|
||||
controller: NewController::External {
|
||||
consumer: ConsumerId::of_name(name),
|
||||
},
|
||||
affordances: BTreeSet::new(),
|
||||
// At the dead end, so this declaration also clears
|
||||
// the boundary its own answer claims: an occupied
|
||||
// place is no longer unelaborated.
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
})],
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.expect("the owner declares a mirror in Active");
|
||||
kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.label == "The Bound Hold")
|
||||
.expect("the declared mirror")
|
||||
.id
|
||||
}
|
||||
|
||||
/// P4.1, replay half: reopening the journal reproduces a retirement —
|
||||
/// no opportunity and `retired` both survive the round trip.
|
||||
#[test]
|
||||
fn a_retired_subject_holds_no_opportunity_across_a_replay() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
let (world_id, reeve) = {
|
||||
let (mut kernel, clockwork, active) = clock_kernel(directory.path(), "RetireReplay");
|
||||
assert!(
|
||||
active
|
||||
.opportunities
|
||||
.iter()
|
||||
.any(|opportunity| opportunity.scope.subject_id == clockwork.reeve),
|
||||
"the reeve holds an opportunity before retirement"
|
||||
);
|
||||
owner_retires(&mut kernel, clockwork.reeve).expect("the owner retires the reeve");
|
||||
(kernel.state.world_id, clockwork.reeve)
|
||||
};
|
||||
let replayed = WorldKernel::open(&path, world_id).expect("the store replays");
|
||||
let snapshot = replayed.snapshot().unwrap();
|
||||
assert!(
|
||||
!snapshot
|
||||
.opportunities
|
||||
.iter()
|
||||
.any(|opportunity| opportunity.scope.subject_id == reeve),
|
||||
"a replayed retired subject holds no opportunity"
|
||||
);
|
||||
let subject = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.id == reeve)
|
||||
.expect("the retired subject's own row");
|
||||
assert!(subject.retired);
|
||||
}
|
||||
|
||||
/// P4.1: a retired subject's last opportunity is stale, not honoured.
|
||||
#[test]
|
||||
fn a_retired_subject_cannot_act() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, clockwork, active) = clock_kernel(directory.path(), "RetireCannotAct");
|
||||
let opportunity = opportunity_for(&active, clockwork.reeve);
|
||||
owner_retires(&mut kernel, clockwork.reeve).expect("the owner retires the reeve");
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Controller(opportunity.controller_id),
|
||||
CommandBody::DeclineDecision { opportunity },
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, KernelError::OpportunityMismatch), "{error:?}");
|
||||
}
|
||||
|
||||
/// P4.1: no position, so a witness at the retired subject's former place
|
||||
/// never reaches it, while a still-present subject at the same place
|
||||
/// does.
|
||||
#[test]
|
||||
fn a_retired_subject_hears_nothing() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "RetireHearsNothing");
|
||||
owner_retires(&mut kernel, clockwork.reeve).expect("the owner retires the reeve");
|
||||
let mut patch = ruled_fact_patch("news");
|
||||
patch.operations.push(ComponentOp::Witness {
|
||||
fact: Ref::Draft(DraftHandle::new("news")),
|
||||
place: Ref::Existing(clockwork.yard),
|
||||
confidence: Confidence::Certain,
|
||||
});
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
play_caller(),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch,
|
||||
},
|
||||
)
|
||||
.expect("the play authority witnesses at the yard");
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let reeve = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.id == clockwork.reeve)
|
||||
.expect("the retired reeve's own row");
|
||||
assert!(reeve.knowledge.is_empty(), "{:?}", reeve.knowledge);
|
||||
let farmer = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.id == clockwork.farmer)
|
||||
.expect("the farmer's own row");
|
||||
assert!(!farmer.knowledge.is_empty(), "the farmer still hears it");
|
||||
}
|
||||
|
||||
/// P4.2: events, knowledge, holdings, and commitments all survive
|
||||
/// retirement untouched.
|
||||
#[test]
|
||||
fn a_retired_subject_keeps_its_history() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "RetireKeepsHistory");
|
||||
let before_holdings = kernel.state.holdings.get(&clockwork.farmer).cloned();
|
||||
let before_commitments = kernel.state.commitments.get(&clockwork.farmer).cloned();
|
||||
assert!(before_holdings.is_some());
|
||||
assert!(before_commitments.is_some());
|
||||
owner_retires(&mut kernel, clockwork.farmer).expect("the owner retires the farmer");
|
||||
assert_eq!(
|
||||
kernel.state.holdings.get(&clockwork.farmer).cloned(),
|
||||
before_holdings
|
||||
);
|
||||
assert_eq!(
|
||||
kernel.state.commitments.get(&clockwork.farmer).cloned(),
|
||||
before_commitments
|
||||
);
|
||||
assert!(kernel.state.subjects.contains_key(&clockwork.farmer));
|
||||
}
|
||||
|
||||
/// P4.1: retiring twice is `NoOperationEffect`, not a second retirement.
|
||||
#[test]
|
||||
fn retiring_twice_is_no_effect() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "RetireTwice");
|
||||
owner_retires(&mut kernel, clockwork.reeve).expect("the first retirement commits");
|
||||
let error = owner_retires(&mut kernel, clockwork.reeve).unwrap_err();
|
||||
assert!(
|
||||
matches!(&error, KernelError::PatchRejected(set)
|
||||
if set.contains(&Mismatch::NoOperationEffect { operation: 0 })),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// P4.3: a mirror is owned by another world; this one cannot retire it.
|
||||
#[test]
|
||||
fn a_mirror_cannot_be_retired() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "RetireMirror");
|
||||
let mirror = declare_mirror(&mut kernel, &clockwork, "cut4-retire-mirror");
|
||||
let error = owner_retires(&mut kernel, mirror).unwrap_err();
|
||||
assert!(
|
||||
matches!(&error, KernelError::PatchRejected(set)
|
||||
if set.contains(&Mismatch::RetiresAMirror { operation: 0 })),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// P4.3: `confine_to_ground` refuses a `Retire` for a confined author
|
||||
/// unconditionally, even for a subject this same patch declares and
|
||||
/// places inside the elaborator's own jurisdiction.
|
||||
#[test]
|
||||
fn an_elaborator_cannot_retire_inside_its_jurisdiction() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "RetireElaborator");
|
||||
let speak = super::tests::speak_entry(&kernel);
|
||||
let answered = dead_end_boundary(&kernel);
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
elaborator(JurisdictionKey::PlaceSubtree(clockwork.dead_end)),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: Some(PatchAnswer::Boundary(answered)),
|
||||
patch: WorldPatch {
|
||||
declarations: vec![Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new("stray"),
|
||||
label: "The Roadside Stray".into(),
|
||||
kind: SubjectKind::Person,
|
||||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
})],
|
||||
operations: vec![ComponentOp::Retire {
|
||||
subject: Ref::Draft(DraftHandle::new("stray")),
|
||||
}],
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&error, KernelError::PatchRejected(set)
|
||||
if set.contains(&Mismatch::OutsideJurisdiction {
|
||||
site: Site::Operation(0)
|
||||
})),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// P4.3: the owner and the play authority are the two callers a
|
||||
/// `Retire` admits.
|
||||
#[test]
|
||||
fn the_owner_and_the_play_authority_may_retire() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mut kernel, clockwork, _) = clock_kernel(directory.path(), "RetireOwnerAndPlay");
|
||||
owner_retires(&mut kernel, clockwork.reeve).expect("the owner retires the reeve");
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
play_caller(),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch: retire_patch(clockwork.farmer),
|
||||
},
|
||||
)
|
||||
.expect("the play authority retires the farmer");
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
assert!(
|
||||
snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.id == clockwork.reeve)
|
||||
.unwrap()
|
||||
.retired
|
||||
);
|
||||
assert!(
|
||||
snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.id == clockwork.farmer)
|
||||
.unwrap()
|
||||
.retired
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1202,6 +1202,11 @@ pub(crate) enum ComponentOp {
|
|||
source: PressureSourceRef,
|
||||
target: Ref<SubjectId>,
|
||||
},
|
||||
/// Ends a subject's turn for good: no opportunity, no position, no
|
||||
/// audience, its history kept. Only the owner and `Play` may write it.
|
||||
Retire {
|
||||
subject: Ref<SubjectId>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an Active `AdmitPatch` answers. Draft answers nothing; Active must
|
||||
|
|
@ -1576,6 +1581,11 @@ pub enum Mismatch {
|
|||
RuledWithoutAuthority {
|
||||
site: Site,
|
||||
},
|
||||
/// `Retire` named an `ExternallyControlled` subject: a mirror is owned by
|
||||
/// another world, so this one cannot retire it.
|
||||
RetiresAMirror {
|
||||
operation: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -1870,6 +1880,9 @@ pub(crate) enum ResolvedOp {
|
|||
source: PressureSource,
|
||||
target: SubjectId,
|
||||
},
|
||||
Retire {
|
||||
subject: SubjectId,
|
||||
},
|
||||
}
|
||||
|
||||
/// One resource's movement across a whole patch. Accumulators are `u128` so an
|
||||
|
|
@ -3210,6 +3223,37 @@ pub(super) fn resolve_patch(
|
|||
)
|
||||
})
|
||||
.collect();
|
||||
// `Retire` and the affordance grant operations read and write these three
|
||||
// candidates. `retired` and `mirrors` seed from the committed
|
||||
// `ControllerAssignment`, which the resolver otherwise never reads: a
|
||||
// declared subject can only ever be a fresh, unretired controller (or a
|
||||
// freshly declared mirror, added below), so drafts start out of both sets.
|
||||
// `affordance_holdings` seeds from committed `affordance_grants` and is
|
||||
// extended below for a subject this same patch declares.
|
||||
let mut retired: BTreeSet<Key<SubjectId>> = state
|
||||
.controller_assignments
|
||||
.iter()
|
||||
.filter(|(_, assignment)| matches!(assignment, ControllerAssignment::Retired))
|
||||
.map(|(scope, _)| Key::Existing(scope.subject_id))
|
||||
.collect();
|
||||
let mut mirrors: BTreeSet<Key<SubjectId>> = state
|
||||
.controller_assignments
|
||||
.iter()
|
||||
.filter(|(_, assignment)| {
|
||||
matches!(assignment, ControllerAssignment::ExternallyControlled { .. })
|
||||
})
|
||||
.map(|(scope, _)| Key::Existing(scope.subject_id))
|
||||
.collect();
|
||||
let mut affordance_holdings: BTreeMap<Key<SubjectId>, BTreeSet<Key<AffordanceId>>> = state
|
||||
.affordance_grants
|
||||
.iter()
|
||||
.map(|(scope, granted)| {
|
||||
(
|
||||
Key::Existing(scope.subject_id),
|
||||
granted.iter().map(|id| Key::Existing(*id)).collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut channels: BTreeMap<Key<EntityId>, ChannelCandidate> = state
|
||||
.channels
|
||||
.iter()
|
||||
|
|
@ -3398,14 +3442,23 @@ pub(super) fn resolve_patch(
|
|||
}
|
||||
}
|
||||
Declaration::Subject(subject) => {
|
||||
let mut granted = BTreeSet::new();
|
||||
for reference in &subject.affordances {
|
||||
resolve_affordance(
|
||||
if let Some(affordance_key) = resolve_affordance(
|
||||
Site::Declaration(subject.handle.clone()),
|
||||
reference,
|
||||
&index,
|
||||
&state.affordance_catalog,
|
||||
&mut mismatches,
|
||||
);
|
||||
) {
|
||||
granted.insert(affordance_key);
|
||||
}
|
||||
}
|
||||
if !granted.is_empty() {
|
||||
affordance_holdings.insert(Key::Draft(subject.handle.clone()), granted);
|
||||
}
|
||||
if matches!(subject.controller, NewController::External { .. }) {
|
||||
mirrors.insert(Key::Draft(subject.handle.clone()));
|
||||
}
|
||||
if let Some(reference) = &subject.position
|
||||
&& let Some(place) = resolve_entity(
|
||||
|
|
@ -3706,6 +3759,32 @@ pub(super) fn resolve_patch(
|
|||
positions.insert(subject_key, route.to);
|
||||
}
|
||||
}
|
||||
ComponentOp::Retire { subject } => {
|
||||
let Some(subject_key) = resolve_subject(
|
||||
Site::Operation(position),
|
||||
subject,
|
||||
&index,
|
||||
&state.subjects,
|
||||
&mut mismatches,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if mirrors.contains(&subject_key) {
|
||||
mismatches.push(Mismatch::RetiresAMirror {
|
||||
operation: position,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if retired.contains(&subject_key) {
|
||||
mismatches.push(Mismatch::NoOperationEffect {
|
||||
operation: position,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
retired.insert(subject_key.clone());
|
||||
positions.remove(&subject_key);
|
||||
affordance_holdings.remove(&subject_key);
|
||||
}
|
||||
ComponentOp::OpenRoute { route } | ComponentOp::CloseRoute { route } => {
|
||||
let open = matches!(operation, ComponentOp::OpenRoute { .. });
|
||||
let Some(route_key) = resolve_route(
|
||||
|
|
@ -5157,6 +5236,9 @@ pub(super) fn resolve_patch(
|
|||
source: pressure_source_of(source),
|
||||
target: subject_id_of(&key_of(target)),
|
||||
},
|
||||
ComponentOp::Retire { subject } => ResolvedOp::Retire {
|
||||
subject: subject_id_of(&key_of(subject)),
|
||||
},
|
||||
ComponentOp::Relocate { subject, via } => ResolvedOp::Relocate {
|
||||
subject_id: subject_id_of(&key_of(subject)),
|
||||
edge_id: edge_id_of(&key_of(via)),
|
||||
|
|
@ -6194,6 +6276,12 @@ pub(crate) const PATCH_TOOLS: &[PatchTool] = &[
|
|||
variant: "resolve_pressure",
|
||||
},
|
||||
},
|
||||
PatchTool {
|
||||
name: "retire",
|
||||
description: "Retire a subject for good: no opportunity, no position, no audience, its history kept. Only the owner and the play table may retire.",
|
||||
fields: &[field("subject", PatchFieldKind::Reference("subject"))],
|
||||
shape: PatchToolShape::Operate { variant: "retire" },
|
||||
},
|
||||
PatchTool {
|
||||
name: RECORD_GAP_PATCH_TOOL,
|
||||
description: "Record something the world needs that this vocabulary cannot say. It changes nothing.",
|
||||
|
|
@ -6935,7 +7023,7 @@ mod catalog_tests {
|
|||
.iter()
|
||||
.filter(|entry| matches!(entry.shape, PatchToolShape::Operate { .. }))
|
||||
.count();
|
||||
assert_eq!((declarations, operations, PATCH_TOOLS.len()), (7, 31, 40));
|
||||
assert_eq!((declarations, operations, PATCH_TOOLS.len()), (7, 32, 41));
|
||||
|
||||
// Every declaration variant the vocabulary owns is reachable, and the
|
||||
// two payload-carrying entity kinds are not exposed as an `Entity`
|
||||
|
|
@ -7030,6 +7118,7 @@ mod catalog_tests {
|
|||
ComponentOp::AdvancePressure { .. } => "advance_pressure",
|
||||
ComponentOp::ReducePressure { .. } => "reduce_pressure",
|
||||
ComponentOp::ResolvePressure { .. } => "resolve_pressure",
|
||||
ComponentOp::Retire { .. } => "retire",
|
||||
}
|
||||
}
|
||||
PATCH_TOOLS
|
||||
|
|
@ -7081,7 +7170,7 @@ mod catalog_tests {
|
|||
.count();
|
||||
assert_eq!(
|
||||
(declare_tools, operate_tools, PATCH_TOOLS.len()),
|
||||
(7, 31, 40)
|
||||
(7, 32, 41)
|
||||
);
|
||||
|
||||
let declarations = every_declaration();
|
||||
|
|
|
|||
|
|
@ -143,6 +143,10 @@
|
|||
"name": "resolve_pressure",
|
||||
"parameters_json": "{\"additionalProperties\":false,\"properties\":{\"source\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"from\":{\"const\":\"commitment\",\"type\":\"string\"},\"of\":{\"additionalProperties\":false,\"properties\":{\"key\":{\"additionalProperties\":false,\"properties\":{\"command\":{\"description\":\"the command id that created the commitment\",\"type\":\"string\"},\"index\":{\"maximum\":4294967295,\"minimum\":0,\"type\":\"integer\"}},\"required\":[\"command\",\"index\"],\"type\":\"object\"},\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"subject\",\"key\"],\"type\":\"object\"}},\"required\":[\"from\",\"of\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"from\":{\"const\":\"dependency\",\"type\":\"string\"},\"of\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing resource\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"target\":{\"const\":\"resource\",\"type\":\"string\"}},\"required\":[\"target\",\"ref\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing route\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"target\":{\"const\":\"route\",\"type\":\"string\"}},\"required\":[\"target\",\"ref\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"},\"target\":{\"const\":\"subject\",\"type\":\"string\"}},\"required\":[\"target\",\"ref\"],\"type\":\"object\"}]}},\"required\":[\"from\",\"of\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"from\":{\"const\":\"subject\",\"type\":\"string\"},\"of\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"from\",\"of\"],\"type\":\"object\"}]},\"target\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"source\",\"target\"],\"type\":\"object\"}"
|
||||
},
|
||||
{
|
||||
"name": "retire",
|
||||
"parameters_json": "{\"additionalProperties\":false,\"properties\":{\"subject\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"subject\"],\"type\":\"object\"}"
|
||||
},
|
||||
{
|
||||
"name": "revoke_authority",
|
||||
"parameters_json": "{\"additionalProperties\":false,\"properties\":{\"grant\":{\"additionalProperties\":false,\"properties\":{\"kind\":{\"description\":\"the authority kind\",\"type\":\"string\"},\"over\":{\"anyOf\":[{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"subject\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"},{\"additionalProperties\":false,\"properties\":{\"over\":{\"const\":\"place_subtree\",\"type\":\"string\"},\"ref\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing place\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"over\",\"ref\"],\"type\":\"object\"}]}},\"required\":[\"kind\",\"over\"],\"type\":\"object\"},\"holder\":{\"additionalProperties\":false,\"properties\":{\"ref\":{\"enum\":[\"draft\",\"existing\"],\"type\":\"string\"},\"value\":{\"description\":\"a draft handle declared in this same patch, or the canonical id of an existing subject\",\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}},\"required\":[\"holder\",\"grant\"],\"type\":\"object\"}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue