Give the world an Authority, Selection, and Redress subgraph
A subject stops being able to do anything to anyone it can name, and starts needing a jurisdiction that covers the target: held directly, or lent to it by an institution through an office it occupies. Three partitions on `WorldState`, written only by `apply_operation` and validated only by `resolve_patch`: `authority` maps a subject to grants of a world-declared kind over a subject or a place subtree, `selection` maps an institution to its offices and their incumbents, and `redress` maps a grievance kind to a forum and its standing. Eight `ComponentOp` variants reach them through the existing `AdmitPatch` lane, and four payload-carrying `ComponentOpKind` variants reach them from an affordance's effect slots. One covering predicate, `covers`, answers every jurisdictional question: `Precondition::Authorized`, `Precondition::HasStanding`, the delegation monotonicity rule, `route_admits`, and the snapshot's redress projection. `AccessKind::Restricted` now names the authority kind that opens the route, so a restricted route admits exactly the subject holding that kind over its destination — checked identically by `resolve_patch`'s `Relocate` arm, by `apply_operation`'s, and by `Reachable`'s edge admission. An office's delegated grants are copied into its incumbent's `ScopeComponents`, so one scope digest covers the grant, the office, and the institution's jurisdiction: revocation, reconstitution, installation, and vacancy are all rebinds. What the actor reaches stays out, so a target that walks out of a jurisdiction fails at `Authorized` instead. `OverlappingJurisdiction` makes it impossible for one subject to hold two grants of one kind over overlapping ground, so nothing ever arbitrates between two sources of one permission; overlap between subjects is layered government and stays legal. The reserved `actor` role binds the acting subject, which is what makes a levy's payee unforgeable. Six world-authored fixture entries — levy, delegate, deploy, sanction, appoint, petition — are the worked examples. No decree and no contract: their effect is `Commitment`, and without it either lowers to speech in an institution's coat. Schema moves to `ghostlight.world_state.authority.v1`; a previous store is refused with no migration adapter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
40e4efcc18
commit
fa3d95dca9
5 changed files with 3536 additions and 78 deletions
|
|
@ -11,9 +11,9 @@ use super::patch::{
|
|||
WorldPatch,
|
||||
};
|
||||
use super::{
|
||||
AccessKind, AffordanceId, CommandId, DecisionEvent, DecisionInvocation, DecisionOpportunity,
|
||||
EdgeId, EntityId, EventId, GrantedAffordance, KernelError, Magnitude, SubjectId, Target,
|
||||
WorldId, WorldState, digest,
|
||||
AffordanceId, AuthorityGrant, AuthorityTarget, CommandId, DecisionEvent, DecisionInvocation,
|
||||
DecisionOpportunity, EdgeId, EntityId, EventId, GrantedAffordance, KernelError, Magnitude,
|
||||
SubjectId, Target, WorldId, WorldState, digest,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
|
@ -70,6 +70,25 @@ pub(crate) enum ActionMismatch {
|
|||
SpeechRequired,
|
||||
SpeechNotCarried,
|
||||
EmptySpeech,
|
||||
/// The acting subject's effective authority covers no such target under
|
||||
/// this kind. A target that walked out of the jurisdiction while the
|
||||
/// proposal was in flight arrives here, not as a rebind: what the actor is
|
||||
/// authorized over is in the digest, the world it reaches is not.
|
||||
NotAuthorized {
|
||||
precondition: usize,
|
||||
},
|
||||
/// No forum takes this grievance, or its standing does not reach the actor.
|
||||
NoStanding {
|
||||
precondition: usize,
|
||||
},
|
||||
/// An invocation bound the reserved `actor` role.
|
||||
ActorRoleBound,
|
||||
/// A slot would grant authority the acting subject does not itself hold
|
||||
/// over that ground. Without this, any holder mints unlimited authority in
|
||||
/// one step and the whole component is decorative.
|
||||
DelegationNotMonotone {
|
||||
slot: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// The band draw's whole preimage. Five fields, none of them from the
|
||||
|
|
@ -103,9 +122,12 @@ pub(super) fn exercise(
|
|||
let actor = current.scope.subject_id;
|
||||
let mut rejections = Vec::new();
|
||||
|
||||
let bindings = bind_roles(state, entry, invocation, &mut rejections);
|
||||
check_preconditions(state, actor, entry, &bindings, &mut rejections);
|
||||
let components = super::scope_components(state, actor);
|
||||
let authority = super::effective_authority(&components.authority, &components.delegated);
|
||||
let bindings = bind_roles(state, actor, entry, invocation, &mut rejections);
|
||||
check_preconditions(state, actor, &authority, entry, &bindings, &mut rejections);
|
||||
check_proposals(entry, invocation, &mut rejections);
|
||||
check_delegation(state, &authority, entry, &bindings, &mut rejections);
|
||||
|
||||
if !rejections.is_empty() {
|
||||
rejections.sort();
|
||||
|
|
@ -147,10 +169,13 @@ pub(super) fn exercise(
|
|||
})
|
||||
}
|
||||
|
||||
/// Stage 2. The acting subject is never a role: it is always the scope's own
|
||||
/// subject. A binding that happens to name the actor is legal and unremarkable.
|
||||
/// Stage 2. The acting subject is never a declared role: the kernel binds the
|
||||
/// reserved name `actor` to it and refuses an invocation that tries to. A
|
||||
/// binding that happens to name the actor through a declared role is legal and
|
||||
/// unremarkable.
|
||||
fn bind_roles(
|
||||
state: &WorldState,
|
||||
actor: SubjectId,
|
||||
entry: &Affordance,
|
||||
invocation: &DecisionInvocation,
|
||||
rejections: &mut Vec<ActionMismatch>,
|
||||
|
|
@ -163,6 +188,10 @@ fn bind_roles(
|
|||
let mut bound: BTreeMap<Role, Target> = BTreeMap::new();
|
||||
let mut seen: BTreeSet<&Role> = BTreeSet::new();
|
||||
for binding in &invocation.bindings {
|
||||
if binding.role.0 == patch::ACTOR_ROLE {
|
||||
rejections.push(ActionMismatch::ActorRoleBound);
|
||||
continue;
|
||||
}
|
||||
if !seen.insert(&binding.role) {
|
||||
rejections.push(ActionMismatch::DuplicateRoleBinding {
|
||||
role: binding.role.clone(),
|
||||
|
|
@ -202,6 +231,12 @@ fn bind_roles(
|
|||
});
|
||||
}
|
||||
}
|
||||
// The kernel's own binding, added after the caller's so nothing can shadow
|
||||
// it: a slot that must land on the actor says `actor` and the proposer has
|
||||
// no say. Without it, a levy's `Transfer` would need the proposer to name
|
||||
// the payee, and an authorized collector could lawfully take a tax and send
|
||||
// it to a friend.
|
||||
bound.insert(Role(patch::ACTOR_ROLE.into()), Target::Subject(actor));
|
||||
bound
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +264,7 @@ fn live_kind(state: &WorldState, target: Target) -> Option<RefKind> {
|
|||
fn check_preconditions(
|
||||
state: &WorldState,
|
||||
actor: SubjectId,
|
||||
authority: &BTreeSet<AuthorityGrant>,
|
||||
entry: &Affordance,
|
||||
bindings: &BTreeMap<Role, Target>,
|
||||
rejections: &mut Vec<ActionMismatch>,
|
||||
|
|
@ -249,7 +285,7 @@ fn check_preconditions(
|
|||
let Some(Target::Entity(place)) = bindings.get(to).copied() else {
|
||||
continue;
|
||||
};
|
||||
if !reachable(state, actor, place, *within) {
|
||||
if !reachable(state, actor, authority, place, *within) {
|
||||
rejections.push(ActionMismatch::TargetUnreachable {
|
||||
precondition: index,
|
||||
});
|
||||
|
|
@ -270,11 +306,83 @@ fn check_preconditions(
|
|||
});
|
||||
}
|
||||
}
|
||||
Precondition::Authorized { over, kind } => {
|
||||
let Some(target) = bindings.get(over).copied() else {
|
||||
continue;
|
||||
};
|
||||
if !authority
|
||||
.iter()
|
||||
.any(|grant| &grant.kind == kind && super::covers(state, grant.over, target))
|
||||
{
|
||||
rejections.push(ActionMismatch::NotAuthorized {
|
||||
precondition: index,
|
||||
});
|
||||
}
|
||||
}
|
||||
Precondition::HasStanding { grievance } => {
|
||||
if !state.redress.get(grievance).is_some_and(|forum| {
|
||||
super::covers(state, forum.standing, Target::Subject(actor))
|
||||
}) {
|
||||
rejections.push(ActionMismatch::NoStanding {
|
||||
precondition: index,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dijkstra over the live route graph, admitting only open public routes and
|
||||
/// Stage 4, beside the ceilings: an authority a slot would grant must not
|
||||
/// exceed the granter's own. Every slot is checked, not just the drawn band's,
|
||||
/// for the same reason every ceiling is — the proposer commits to a complete
|
||||
/// attempt and cannot see the band.
|
||||
///
|
||||
/// The rule is action-lane only, and that is not two truths. The patch lane's
|
||||
/// `GrantAuthority` is admitted by the world owner; monotonicity *is* the
|
||||
/// action lane's authority envelope, and both lanes reach one
|
||||
/// `apply_operation` with the identical mutation.
|
||||
fn check_delegation(
|
||||
state: &WorldState,
|
||||
authority: &BTreeSet<AuthorityGrant>,
|
||||
entry: &Affordance,
|
||||
bindings: &BTreeMap<Role, Target>,
|
||||
rejections: &mut Vec<ActionMismatch>,
|
||||
) {
|
||||
for (index, slot) in entry.effect_slots.iter().enumerate() {
|
||||
let ComponentOpKind::GrantAuthority { kind } = &slot.op_kind else {
|
||||
continue;
|
||||
};
|
||||
let Some(over) = slot
|
||||
.roles
|
||||
.get(1)
|
||||
.and_then(|role| bindings.get(role))
|
||||
.copied()
|
||||
.and_then(authority_target_of)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !authority.iter().any(|grant| {
|
||||
&grant.kind == kind && super::covers(state, grant.over, over.as_referent())
|
||||
}) {
|
||||
rejections.push(ActionMismatch::DelegationNotMonotone { slot: index });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The authority target a bound referent names. A route is jurisdictional
|
||||
/// ground but never a grant's target, so a slot bound to one lowers to nothing.
|
||||
fn authority_target_of(target: Target) -> Option<AuthorityTarget> {
|
||||
match target {
|
||||
Target::Subject(subject_id) => Some(AuthorityTarget::Subject(subject_id)),
|
||||
Target::Entity(entity_id) => Some(AuthorityTarget::PlaceSubtree(entity_id)),
|
||||
Target::Edge(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dijkstra over the live route graph, admitting the open routes this subject
|
||||
/// may traverse — the same `route_admits` rule the resolver applies to a
|
||||
/// `Relocate`, so a subject with the right authority can plan a path through
|
||||
/// its own restricted ground — and
|
||||
/// relaxing in `EdgeId` order, pruning any path whose accumulated cost exceeds
|
||||
/// `within`. Cost 0 — already standing there — succeeds; `Present` is the strict
|
||||
/// form. An unplaced actor reaches nothing.
|
||||
|
|
@ -284,7 +392,13 @@ fn check_preconditions(
|
|||
/// and rejects it whenever anything near the actor commits. Admission runs at
|
||||
/// commit against live state instead, so a route that closed while the proposal
|
||||
/// was in flight makes this fail rather than letting a stale path commit.
|
||||
fn reachable(state: &WorldState, actor: SubjectId, destination: EntityId, within: Cost) -> bool {
|
||||
fn reachable(
|
||||
state: &WorldState,
|
||||
actor: SubjectId,
|
||||
authority: &BTreeSet<AuthorityGrant>,
|
||||
destination: EntityId,
|
||||
within: Cost,
|
||||
) -> bool {
|
||||
let Some(origin) = state.positions.get(&actor).map(|position| position.place) else {
|
||||
return false;
|
||||
};
|
||||
|
|
@ -307,7 +421,7 @@ fn reachable(state: &WorldState, actor: SubjectId, destination: EntityId, within
|
|||
let outbound: Vec<(EdgeId, EntityId, u64)> = state
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|(_, record)| record.is_open() && record.access() == AccessKind::Public)
|
||||
.filter(|(_, record)| record.is_open())
|
||||
.filter_map(|(edge_id, record)| {
|
||||
let (from, to) = record.endpoints();
|
||||
let next = if from == place {
|
||||
|
|
@ -317,7 +431,8 @@ fn reachable(state: &WorldState, actor: SubjectId, destination: EntityId, within
|
|||
} else {
|
||||
return None;
|
||||
};
|
||||
Some((*edge_id, next, u64::from(record.cost().0)))
|
||||
patch::route_admits(state, authority, record.access(), next)
|
||||
.then(|| (*edge_id, next, u64::from(record.cost().0)))
|
||||
})
|
||||
.collect();
|
||||
for (_, next, step) in outbound {
|
||||
|
|
@ -509,7 +624,18 @@ fn lower(
|
|||
Target::Edge(edge_id) => patch::DependencyRef::Route(patch::Ref::Existing(edge_id)),
|
||||
})
|
||||
};
|
||||
operations.push(match slot.op_kind {
|
||||
let grant_over = |position: usize| -> Result<patch::AuthorityTargetRef, KernelError> {
|
||||
Ok(match target(position)? {
|
||||
Target::Subject(subject_id) => {
|
||||
patch::AuthorityTargetRef::Subject(patch::Ref::Existing(subject_id))
|
||||
}
|
||||
Target::Entity(entity_id) => {
|
||||
patch::AuthorityTargetRef::PlaceSubtree(patch::Ref::Existing(entity_id))
|
||||
}
|
||||
Target::Edge(_) => return Err(malformed()),
|
||||
})
|
||||
};
|
||||
operations.push(match &slot.op_kind {
|
||||
ComponentOpKind::Relocate => patch::ComponentOp::Relocate {
|
||||
subject: subject(0)?,
|
||||
via: edge(1)?,
|
||||
|
|
@ -548,6 +674,29 @@ fn lower(
|
|||
subject: subject(0)?,
|
||||
target: dependency(1)?,
|
||||
},
|
||||
ComponentOpKind::GrantAuthority { kind } => patch::ComponentOp::GrantAuthority {
|
||||
holder: subject(0)?,
|
||||
grant: patch::AuthorityGrantRef {
|
||||
kind: kind.clone(),
|
||||
over: grant_over(1)?,
|
||||
},
|
||||
},
|
||||
ComponentOpKind::RevokeAuthority { kind } => patch::ComponentOp::RevokeAuthority {
|
||||
holder: subject(0)?,
|
||||
grant: patch::AuthorityGrantRef {
|
||||
kind: kind.clone(),
|
||||
over: grant_over(1)?,
|
||||
},
|
||||
},
|
||||
ComponentOpKind::InstallIncumbent { office } => patch::ComponentOp::InstallIncumbent {
|
||||
institution: subject(0)?,
|
||||
office: office.clone(),
|
||||
incumbent: subject(1)?,
|
||||
},
|
||||
ComponentOpKind::VacateOffice { office } => patch::ComponentOp::VacateOffice {
|
||||
institution: subject(0)?,
|
||||
office: office.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(operations)
|
||||
|
|
@ -557,14 +706,20 @@ fn lower(
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::world::custody_tests::custody_kernel;
|
||||
use crate::world::patch::{ComponentOp, Ref as PatchRef, ResolvedOp};
|
||||
use crate::world::patch::{
|
||||
AffordanceDeclaration, ComponentOp, DraftHandle, OutcomeBand, Ref as PatchRef, ResolvedOp,
|
||||
RoleSpec,
|
||||
};
|
||||
use crate::world::tests::{
|
||||
OPENING_BALANCE, activate, affordance_named, auth_principal, command, custody_world,
|
||||
operations, opportunity_for, player, submit_owner,
|
||||
ADMIT_KIND, COMMAND_KIND, Civic, LEVY_KIND, OPENING_BALANCE, SEIZURE_GRIEVANCE, Topology,
|
||||
WARDEN_OFFICE, activate, affordance_named, auth_principal, civic_world, command,
|
||||
custody_world, grant_to, grievance, office, operations, opportunity_for, over_place,
|
||||
over_subject, player, reject_owner, submit_owner,
|
||||
};
|
||||
use crate::world::{
|
||||
AuthenticatedCaller, CallerId, CommandBody, EntityKind, ProposedEffect, RoleBinding,
|
||||
SubmitReceipt, Utterance, WorldEffect, WorldKernel, WorldSnapshot, apply_effect,
|
||||
AffordanceKindName, AuthenticatedCaller, CallerId, CommandBody, Declaration, EntityKind,
|
||||
ProposedEffect, RoleBinding, SubmitReceipt, Utterance, WorldEffect, WorldKernel,
|
||||
WorldPatch, WorldSnapshot, apply_effect,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
|
|
@ -1290,4 +1445,567 @@ mod tests {
|
|||
);
|
||||
assert_eq!(kernel.state.holdings, holdings);
|
||||
}
|
||||
|
||||
/// The civic bench: one world where an institution holds jurisdiction over
|
||||
/// a hall, a person holds the same jurisdiction only through an office, and
|
||||
/// two more people stand inside and outside that ground.
|
||||
struct Civics {
|
||||
kernel: WorldKernel,
|
||||
active: WorldSnapshot,
|
||||
topology: Topology,
|
||||
civic: Civic,
|
||||
}
|
||||
|
||||
fn civics(directory: &std::path::Path, title: &str) -> Civics {
|
||||
let mut kernel = custody_kernel(directory, title);
|
||||
let (topology, civic, active) = civic_world(&mut kernel);
|
||||
Civics {
|
||||
kernel,
|
||||
active,
|
||||
topology,
|
||||
civic,
|
||||
}
|
||||
}
|
||||
|
||||
impl Civics {
|
||||
fn refresh(&mut self) {
|
||||
self.active = self.kernel.snapshot().unwrap();
|
||||
}
|
||||
|
||||
fn call(
|
||||
&self,
|
||||
kind: &str,
|
||||
bindings: Vec<RoleBinding>,
|
||||
proposed: Vec<ProposedEffect>,
|
||||
speech: Option<Utterance>,
|
||||
) -> DecisionInvocation {
|
||||
DecisionInvocation {
|
||||
affordance: affordance_named(&self.active, kind),
|
||||
bindings,
|
||||
proposed,
|
||||
speech,
|
||||
}
|
||||
}
|
||||
|
||||
/// A levy of `qty` from `payer`, always paid to the actor: the payee is
|
||||
/// the reserved role, so it is not a binding the caller supplies.
|
||||
fn levy(&self, payer: SubjectId, qty: u64) -> DecisionInvocation {
|
||||
self.call(
|
||||
"levy",
|
||||
vec![
|
||||
binding("payer", Target::Subject(payer)),
|
||||
binding("resource", Target::Entity(self.civic.grain)),
|
||||
],
|
||||
vec![ProposedEffect {
|
||||
slot: 0,
|
||||
magnitude: Magnitude::Quantity(Quantity(qty)),
|
||||
}],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn delegate(&self, deputy: SubjectId, ground: EntityId) -> DecisionInvocation {
|
||||
self.call(
|
||||
"delegate",
|
||||
vec![
|
||||
binding("deputy", Target::Subject(deputy)),
|
||||
binding("ground", Target::Entity(ground)),
|
||||
],
|
||||
vec![ProposedEffect {
|
||||
slot: 0,
|
||||
magnitude: Magnitude::None,
|
||||
}],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn try_as(
|
||||
&self,
|
||||
actor: SubjectId,
|
||||
invocation: &DecisionInvocation,
|
||||
) -> Result<DecisionEvent, KernelError> {
|
||||
draw_once(
|
||||
&self.kernel.state,
|
||||
CommandId::issue(),
|
||||
&opportunity_for(&self.active, actor),
|
||||
invocation,
|
||||
)
|
||||
}
|
||||
|
||||
fn rejected_as(
|
||||
&self,
|
||||
actor: SubjectId,
|
||||
invocation: &DecisionInvocation,
|
||||
) -> Vec<ActionMismatch> {
|
||||
match self.try_as(actor, invocation) {
|
||||
Err(KernelError::ActionRejected(rejected)) => rejected,
|
||||
other => panic!("expected an action rejection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_as(&mut self, actor: SubjectId, invocation: DecisionInvocation) -> SubmitReceipt {
|
||||
let opportunity = opportunity_for(&self.active, actor);
|
||||
let caller = CallerId::Controller(opportunity.controller_id);
|
||||
let receipt = self
|
||||
.kernel
|
||||
.submit(
|
||||
command(
|
||||
&self.active,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::ExerciseDecision {
|
||||
opportunity,
|
||||
invocation,
|
||||
},
|
||||
),
|
||||
&AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
.expect("the invocation commits");
|
||||
self.refresh();
|
||||
receipt
|
||||
}
|
||||
|
||||
fn admit(&mut self, ops: Vec<ComponentOp>) {
|
||||
let before = self.kernel.snapshot().unwrap();
|
||||
let receipt = submit_owner(&mut self.kernel, &before, operations(ops));
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
fn refuse(&mut self, ops: Vec<ComponentOp>) -> Vec<crate::world::Mismatch> {
|
||||
let before = self.kernel.snapshot().unwrap();
|
||||
reject_owner(&mut self.kernel, &before, operations(ops))
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract 7's third rejection: commanding outside authority, with the
|
||||
/// exact failed precondition and no allocation.
|
||||
#[test]
|
||||
fn an_unauthorized_actor_names_the_failed_precondition() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let bench = civics(directory.path(), "Unauthorized");
|
||||
let before = bench.kernel.state.clone();
|
||||
assert_eq!(
|
||||
bench.rejected_as(bench.civic.farmer, &bench.levy(bench.civic.reeve, 1)),
|
||||
vec![ActionMismatch::NotAuthorized { precondition: 0 }]
|
||||
);
|
||||
assert_eq!(bench.kernel.state.revision, before.revision);
|
||||
assert_eq!(bench.kernel.state.holdings, before.holdings);
|
||||
assert_eq!(bench.kernel.state.events, before.events);
|
||||
}
|
||||
|
||||
/// A place jurisdiction reaches everything under it and nothing else, and
|
||||
/// the answer is read live: a target that walks out of the subtree stops
|
||||
/// being authorized without any digest moving.
|
||||
#[test]
|
||||
fn place_jurisdiction_reaches_a_containment_subtree_and_stops_at_its_edge() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut bench = civics(directory.path(), "Subtree");
|
||||
let treasury = bench.civic.treasury;
|
||||
let farmer = bench.civic.farmer;
|
||||
|
||||
// The farmer stands in the chamber, whose container is the hall.
|
||||
assert!(bench.try_as(treasury, &bench.levy(farmer, 1)).is_ok());
|
||||
|
||||
// The pedlar stands on the road, outside the hall entirely, and an
|
||||
// unplaced subject is covered by no place target.
|
||||
assert_eq!(
|
||||
bench.rejected_as(treasury, &bench.levy(bench.civic.outsider, 1)),
|
||||
vec![ActionMismatch::NotAuthorized { precondition: 0 }]
|
||||
);
|
||||
let unplaced = *bench
|
||||
.kernel
|
||||
.state
|
||||
.subjects
|
||||
.keys()
|
||||
.find(|subject_id| !bench.kernel.state.positions.contains_key(subject_id))
|
||||
.expect("the genesis world declares an unplaced subject");
|
||||
assert_eq!(
|
||||
bench.rejected_as(treasury, &bench.levy(unplaced, 1)),
|
||||
vec![ActionMismatch::NotAuthorized { precondition: 0 }]
|
||||
);
|
||||
|
||||
// Walking the farmer out of the hall flips the same invocation, and a
|
||||
// grant naming the subject directly authorizes it again.
|
||||
bench.admit(vec![
|
||||
ComponentOp::Relocate {
|
||||
subject: PatchRef::Existing(farmer),
|
||||
via: PatchRef::Existing(bench.civic.passage),
|
||||
},
|
||||
ComponentOp::Relocate {
|
||||
subject: PatchRef::Existing(farmer),
|
||||
via: PatchRef::Existing(bench.civic.causeway),
|
||||
},
|
||||
]);
|
||||
assert_eq!(
|
||||
bench.rejected_as(treasury, &bench.levy(farmer, 1)),
|
||||
vec![ActionMismatch::NotAuthorized { precondition: 0 }]
|
||||
);
|
||||
bench.admit(vec![grant_to(treasury, LEVY_KIND, over_subject(farmer))]);
|
||||
assert!(bench.try_as(treasury, &bench.levy(farmer, 1)).is_ok());
|
||||
}
|
||||
|
||||
/// An office lends jurisdiction its holder does not own, vacating takes it
|
||||
/// back, and a proposal bound before the vacate rebinds rather than
|
||||
/// rejecting: revocation mid-flight is a `ScopeChanged`.
|
||||
#[test]
|
||||
fn office_delegation_grants_and_vacating_revokes() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut bench = civics(directory.path(), "Office");
|
||||
let reeve = bench.civic.reeve;
|
||||
let farmer = bench.civic.farmer;
|
||||
assert!(bench.kernel.state.authority.get(&reeve).is_none());
|
||||
assert!(bench.try_as(reeve, &bench.levy(farmer, 1)).is_ok());
|
||||
|
||||
// A proposal bound before the vacate rebinds rather than rejecting.
|
||||
let bound = opportunity_for(&bench.active, reeve);
|
||||
let caller = CallerId::Controller(bound.controller_id);
|
||||
let invocation = bench.levy(farmer, 1);
|
||||
bench.admit(vec![ComponentOp::VacateOffice {
|
||||
institution: PatchRef::Existing(bench.civic.treasury),
|
||||
office: office(WARDEN_OFFICE),
|
||||
}]);
|
||||
|
||||
let holdings = bench.kernel.state.holdings.clone();
|
||||
let error = bench
|
||||
.kernel
|
||||
.submit(
|
||||
command(
|
||||
&bench.active,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::ExerciseDecision {
|
||||
opportunity: bound.clone(),
|
||||
invocation,
|
||||
},
|
||||
),
|
||||
&AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
.unwrap_err();
|
||||
let KernelError::ScopeChanged {
|
||||
scope, expected, ..
|
||||
} = error
|
||||
else {
|
||||
panic!("a vacated office is a rebind, not a rejection");
|
||||
};
|
||||
assert_eq!(scope, bound.scope);
|
||||
assert_eq!(expected, bound.scope_digest);
|
||||
assert_eq!(bench.kernel.state.holdings, holdings);
|
||||
assert_eq!(
|
||||
bench.rejected_as(reeve, &bench.levy(farmer, 1)),
|
||||
vec![ActionMismatch::NotAuthorized { precondition: 0 }]
|
||||
);
|
||||
}
|
||||
|
||||
/// The reserved role in both directions: an invocation may not bind it, and
|
||||
/// a catalog entry may not declare it.
|
||||
#[test]
|
||||
fn a_levy_cannot_be_directed_away_from_the_actor() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let bench = civics(directory.path(), "ActorRole");
|
||||
let mut redirected = bench.levy(bench.civic.farmer, 1);
|
||||
redirected
|
||||
.bindings
|
||||
.push(binding("actor", Target::Subject(bench.civic.outsider)));
|
||||
assert_eq!(
|
||||
bench.rejected_as(bench.civic.treasury, &redirected),
|
||||
vec![ActionMismatch::ActorRoleBound]
|
||||
);
|
||||
|
||||
// The declaration half is Draft-only, so it runs against a fresh world.
|
||||
let draft_directory = tempfile::tempdir().unwrap();
|
||||
let mut draft = custody_kernel(draft_directory.path(), "ReservedRole");
|
||||
let before = draft.snapshot().unwrap();
|
||||
let handle = DraftHandle::new("thief");
|
||||
let rejected = reject_owner(
|
||||
&mut draft,
|
||||
&before,
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch: WorldPatch {
|
||||
declarations: vec![Declaration::Affordance(AffordanceDeclaration {
|
||||
handle: handle.clone(),
|
||||
kind: AffordanceKindName("thief".into()),
|
||||
roles: vec![RoleSpec {
|
||||
role: Role("actor".into()),
|
||||
kind: RefKind::Subject(None),
|
||||
}],
|
||||
preconditions: Vec::new(),
|
||||
effect_slots: Vec::new(),
|
||||
outcome_bands: vec![OutcomeBand {
|
||||
weight: 1,
|
||||
effects: Vec::new(),
|
||||
}],
|
||||
carries_speech: true,
|
||||
})],
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
},
|
||||
);
|
||||
assert!(rejected.contains(&crate::world::Mismatch::ReservedRole { handle }));
|
||||
}
|
||||
|
||||
/// The two rejection lanes stay distinguishable: an over-levy inside its
|
||||
/// declared ceiling passes every affordance check and dies in the ledger.
|
||||
#[test]
|
||||
fn an_over_levy_fails_in_the_ledger_not_in_the_contract() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let bench = civics(directory.path(), "OverLevy");
|
||||
let error = bench
|
||||
.try_as(bench.civic.treasury, &bench.levy(bench.civic.farmer, 9))
|
||||
.unwrap_err();
|
||||
let KernelError::PatchRejected(rejected) = error else {
|
||||
panic!("an over-levy is a ledger rejection");
|
||||
};
|
||||
assert_eq!(
|
||||
rejected,
|
||||
vec![crate::world::Mismatch::InsufficientCustody { operation: 0 }]
|
||||
);
|
||||
}
|
||||
|
||||
/// A delegated grant may not exceed the granter's own, and the grant it
|
||||
/// does mint is still subject to disjointness.
|
||||
#[test]
|
||||
fn a_delegated_grant_may_not_exceed_the_granter() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut bench = civics(directory.path(), "Monotone");
|
||||
let treasury = bench.civic.treasury;
|
||||
let outsider = bench.civic.outsider;
|
||||
|
||||
// The treasury commands the road but does not levy it, so the
|
||||
// precondition passes and the delegation alone is refused.
|
||||
let road = bench.topology.road;
|
||||
bench.admit(vec![grant_to(treasury, COMMAND_KIND, over_place(road))]);
|
||||
let outside = bench.delegate(outsider, road);
|
||||
assert_eq!(
|
||||
bench.rejected_as(treasury, &outside),
|
||||
vec![ActionMismatch::DelegationNotMonotone { slot: 0 }]
|
||||
);
|
||||
|
||||
// The chamber is inside it, so the same act commits and the deputy can
|
||||
// then levy there.
|
||||
let inside = bench.delegate(outsider, bench.civic.chamber);
|
||||
assert!(matches!(
|
||||
bench.commit_as(treasury, inside),
|
||||
SubmitReceipt::Applied(_)
|
||||
));
|
||||
assert!(
|
||||
bench
|
||||
.try_as(outsider, &bench.levy(bench.civic.farmer, 1))
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
// An office lends exactly the kinds it delegates: the warden's `levy`
|
||||
// does not make its holder a commander.
|
||||
let again = bench.delegate(outsider, bench.civic.chamber);
|
||||
assert_eq!(
|
||||
bench.rejected_as(bench.civic.reeve, &again),
|
||||
vec![ActionMismatch::NotAuthorized { precondition: 0 }]
|
||||
);
|
||||
|
||||
// The minted grant is ordinary state: repeating it changes nothing, and
|
||||
// widening it to the hall would overlap what the deputy already holds.
|
||||
let error = bench.try_as(treasury, &again).unwrap_err();
|
||||
let KernelError::PatchRejected(rejected) = error else {
|
||||
panic!("a duplicate delegation is a patch rejection");
|
||||
};
|
||||
assert_eq!(
|
||||
rejected,
|
||||
vec![crate::world::Mismatch::NoOperationEffect { operation: 0 }]
|
||||
);
|
||||
let hall = bench.civic.hall;
|
||||
assert_eq!(
|
||||
bench.refuse(vec![grant_to(outsider, LEVY_KIND, over_place(hall))]),
|
||||
vec![crate::world::Mismatch::OverlappingJurisdiction { operation: 0 }]
|
||||
);
|
||||
}
|
||||
|
||||
/// `Redress` with a reducer reader: standing is the covering predicate over
|
||||
/// the forum's target, and closing the forum removes the verb's ground.
|
||||
#[test]
|
||||
fn a_petition_requires_standing() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut bench = civics(directory.path(), "Petition");
|
||||
let petition = bench.call(
|
||||
"petition",
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Some(Utterance::new("The tithe was taken twice.").unwrap()),
|
||||
);
|
||||
assert_eq!(
|
||||
bench.rejected_as(bench.civic.outsider, &petition),
|
||||
vec![ActionMismatch::NoStanding { precondition: 0 }]
|
||||
);
|
||||
|
||||
let farmer = bench.civic.farmer;
|
||||
assert!(matches!(
|
||||
bench.commit_as(farmer, petition.clone()),
|
||||
SubmitReceipt::Applied(_)
|
||||
));
|
||||
let event = bench
|
||||
.kernel
|
||||
.state
|
||||
.events
|
||||
.last()
|
||||
.expect("the committed event");
|
||||
assert!(event.effects.is_empty());
|
||||
assert_eq!(
|
||||
event.invocation.speech.as_ref().map(Utterance::as_str),
|
||||
Some("The tithe was taken twice.")
|
||||
);
|
||||
|
||||
bench.admit(vec![ComponentOp::CloseForum {
|
||||
grievance: grievance(SEIZURE_GRIEVANCE),
|
||||
}]);
|
||||
assert_eq!(
|
||||
bench.rejected_as(farmer, &petition),
|
||||
vec![ActionMismatch::NoStanding { precondition: 0 }]
|
||||
);
|
||||
}
|
||||
|
||||
/// One predicate, two callers: the resolver's `Relocate` arm and
|
||||
/// `Reachable`'s edge admission read the same `Restricted` rule, and a
|
||||
/// same-patch grant then move resolves through the candidate shadow.
|
||||
#[test]
|
||||
fn a_restricted_route_admits_exactly_its_named_authority() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut bench = civics(directory.path(), "Restricted");
|
||||
let walker = bench.topology.walker;
|
||||
let step = ComponentOp::Relocate {
|
||||
subject: PatchRef::Existing(walker),
|
||||
via: PatchRef::Existing(bench.civic.postern),
|
||||
};
|
||||
|
||||
// Holding nothing, and holding `admit` over the origin only, are both
|
||||
// refused: the rule asks about the destination.
|
||||
assert!(
|
||||
bench
|
||||
.refuse(vec![step.clone()])
|
||||
.contains(&crate::world::Mismatch::RouteAccessRestricted { operation: 0 })
|
||||
);
|
||||
let yard = bench.topology.yard;
|
||||
bench.admit(vec![grant_to(walker, ADMIT_KIND, over_place(yard))]);
|
||||
assert!(
|
||||
bench
|
||||
.refuse(vec![step.clone()])
|
||||
.contains(&crate::world::Mismatch::RouteAccessRestricted { operation: 0 })
|
||||
);
|
||||
|
||||
// Reachable reads the same rule from the other side.
|
||||
let authority = crate::world::subject_authority(&bench.kernel.state, walker);
|
||||
assert!(!reachable(
|
||||
&bench.kernel.state,
|
||||
walker,
|
||||
&authority,
|
||||
bench.civic.chamber,
|
||||
Cost(4)
|
||||
));
|
||||
|
||||
// A grant covering the destination, minted in the same patch as the
|
||||
// move, resolves through the candidate authority shadow.
|
||||
let hall = bench.civic.hall;
|
||||
let authority = crate::world::subject_authority(&bench.kernel.state, walker);
|
||||
assert!(!patch::route_admits(
|
||||
&bench.kernel.state,
|
||||
&authority,
|
||||
bench.kernel.state.edges[&bench.civic.postern].access(),
|
||||
bench.civic.chamber
|
||||
));
|
||||
bench.admit(vec![grant_to(walker, ADMIT_KIND, over_place(hall)), step]);
|
||||
assert_eq!(
|
||||
bench.kernel.state.positions[&walker].place,
|
||||
bench.civic.chamber
|
||||
);
|
||||
let authority = crate::world::subject_authority(&bench.kernel.state, walker);
|
||||
assert!(patch::route_admits(
|
||||
&bench.kernel.state,
|
||||
&authority,
|
||||
bench.kernel.state.edges[&bench.civic.postern].access(),
|
||||
bench.civic.chamber
|
||||
));
|
||||
}
|
||||
|
||||
/// The remaining institutional affordances, each proving one lowering arm:
|
||||
/// `sanction` closes shared topology under a legitimacy check with an
|
||||
/// interdiction band that fails, and `appoint` makes succession an act
|
||||
/// someone performs under authority rather than a declared method string.
|
||||
#[test]
|
||||
fn sanction_and_appoint_reach_the_reducer_under_authority() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut bench = civics(directory.path(), "Institutional");
|
||||
let treasury = bench.civic.treasury;
|
||||
let passage = bench.civic.passage;
|
||||
let sanction = bench.call(
|
||||
"sanction",
|
||||
vec![binding("road", Target::Edge(passage))],
|
||||
vec![ProposedEffect {
|
||||
slot: 0,
|
||||
magnitude: Magnitude::None,
|
||||
}],
|
||||
None,
|
||||
);
|
||||
|
||||
// The reeve holds the verb but its office lends only `levy`, so the same
|
||||
// act names the precondition it failed.
|
||||
assert_eq!(
|
||||
bench.rejected_as(bench.civic.reeve, &sanction),
|
||||
vec![ActionMismatch::NotAuthorized { precondition: 0 }]
|
||||
);
|
||||
|
||||
// The treasury commands the hall, and the passage has both endpoints
|
||||
// under it. The draw decides whether the interdiction lands.
|
||||
let opportunity = opportunity_for(&bench.active, treasury);
|
||||
let closing = (0..256)
|
||||
.map(|_| CommandId::issue())
|
||||
.find(|candidate| {
|
||||
draw_once(&bench.kernel.state, *candidate, &opportunity, &sanction)
|
||||
.expect("the invocation is admissible")
|
||||
.band
|
||||
== 0
|
||||
})
|
||||
.expect("a two-band entry reaches its first band over 256 command ids");
|
||||
let caller = CallerId::Controller(opportunity.controller_id);
|
||||
let receipt = bench
|
||||
.kernel
|
||||
.submit(
|
||||
command(
|
||||
&bench.active,
|
||||
closing,
|
||||
caller.clone(),
|
||||
CommandBody::ExerciseDecision {
|
||||
opportunity,
|
||||
invocation: sanction,
|
||||
},
|
||||
),
|
||||
&AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
.expect("the sanction commits");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
bench.refresh();
|
||||
assert!(!bench.kernel.state.edges[&passage].is_open());
|
||||
|
||||
// Appointment installs an incumbent through the action lane.
|
||||
let farmer = bench.civic.farmer;
|
||||
let appoint = bench.call(
|
||||
"appoint",
|
||||
vec![
|
||||
binding("institution", Target::Subject(treasury)),
|
||||
binding("candidate", Target::Subject(farmer)),
|
||||
],
|
||||
vec![ProposedEffect {
|
||||
slot: 0,
|
||||
magnitude: Magnitude::None,
|
||||
}],
|
||||
None,
|
||||
);
|
||||
assert!(matches!(
|
||||
bench.commit_as(treasury, appoint),
|
||||
SubmitReceipt::Applied(_)
|
||||
));
|
||||
assert_eq!(
|
||||
bench.kernel.state.selection[&treasury][&office("bailiff")].incumbent,
|
||||
Some(farmer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@
|
|||
//! affordance fields.
|
||||
|
||||
use crate::world::{
|
||||
AffordanceId, AffordanceSnapshot, Bounds, CommandId, CommitReceipt, ControllerMode, Cost,
|
||||
DecisionInvocation, DecisionOpportunity, DependencyTarget, EdgeId, EntityId, EntityKind,
|
||||
KernelError, Magnitude, MailboxError, ProposedEffect, Quantity, RefKind, RoleBinding,
|
||||
SubjectId, SubjectSnapshot, SubmitReceipt, Target, Utterance, WorldMailbox, WorldSnapshot,
|
||||
AffordanceId, AffordanceSnapshot, AuthorityGrant, Bounds, CommandId, CommitReceipt,
|
||||
ControllerMode, Cost, DecisionInvocation, DecisionOpportunity, DependencyTarget, EdgeId,
|
||||
EntityId, EntityKind, KernelError, Magnitude, MailboxError, OfficeSnapshot, ProposedEffect,
|
||||
Quantity, RefKind, RoleBinding, SubjectId, SubjectSnapshot, SubmitReceipt, Target, Utterance,
|
||||
WorldMailbox, WorldSnapshot,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
|
@ -2602,11 +2603,59 @@ impl SelectedDecision {
|
|||
"routes": self.typed_routes(),
|
||||
"holdings": self.typed_holdings(),
|
||||
"dependencies": self.typed_dependencies(),
|
||||
"authority": self.typed_authority(),
|
||||
"offices_held": Self::typed_offices(&self.subject.offices_held),
|
||||
"offices_granted": Self::typed_offices(&self.subject.offices_granted),
|
||||
"redress": self.typed_redress(),
|
||||
"visible_events": self.typed_visible_events(),
|
||||
}))
|
||||
.map_err(|error| ControllerError::Serialization(error.to_string()))
|
||||
}
|
||||
|
||||
/// The acting subject's own jurisdictions, with no label resolved for
|
||||
/// anything inside a target: a jurisdiction may name places and subjects
|
||||
/// this subject has never heard of, and label resolution is a `Knowledge`
|
||||
/// question. Nothing here marks whether a jurisdiction is occupied.
|
||||
fn typed_authority(&self) -> Vec<Value> {
|
||||
self.subject
|
||||
.authority
|
||||
.iter()
|
||||
.map(Self::typed_grant)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn typed_grant(grant: &AuthorityGrant) -> Value {
|
||||
json!({"kind": grant.kind, "over": grant.over})
|
||||
}
|
||||
|
||||
/// The offices this subject occupies, and — for an institution — the
|
||||
/// offices it grants. No global office gazetteer, and no other subject's
|
||||
/// grants.
|
||||
fn typed_offices(offices: &[OfficeSnapshot]) -> Vec<Value> {
|
||||
offices
|
||||
.iter()
|
||||
.map(|office| {
|
||||
json!({
|
||||
"institution": office.institution,
|
||||
"office": office.office,
|
||||
"incumbent": office.incumbent,
|
||||
"authority": office.authority.iter().map(Self::typed_grant).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The forums this subject may petition. Standing is not carried: a subject
|
||||
/// learns that it may bring a grievance, not the boundary of everyone
|
||||
/// else's standing.
|
||||
fn typed_redress(&self) -> Vec<Value> {
|
||||
self.subject
|
||||
.redress
|
||||
.iter()
|
||||
.map(|forum| json!({"grievance": forum.grievance, "forum": forum.forum}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Only the acting subject's own place, so the typed surface carries what a
|
||||
/// precondition reads and not a world gazetteer.
|
||||
fn typed_place(&self) -> Value {
|
||||
|
|
@ -3873,6 +3922,10 @@ mod tests {
|
|||
holdings: BTreeMap::new(),
|
||||
dependencies: BTreeSet::new(),
|
||||
incident_routes: Vec::new(),
|
||||
authority: BTreeSet::new(),
|
||||
offices_held: Vec::new(),
|
||||
offices_granted: Vec::new(),
|
||||
redress: Vec::new(),
|
||||
};
|
||||
let speaker = SubjectSnapshot {
|
||||
id: speaker_id,
|
||||
|
|
@ -3886,6 +3939,10 @@ mod tests {
|
|||
holdings: BTreeMap::new(),
|
||||
dependencies: BTreeSet::new(),
|
||||
incident_routes: Vec::new(),
|
||||
authority: BTreeSet::new(),
|
||||
offices_held: Vec::new(),
|
||||
offices_granted: Vec::new(),
|
||||
redress: Vec::new(),
|
||||
};
|
||||
let snapshot = WorldSnapshot {
|
||||
world_id: opportunity.world_id,
|
||||
|
|
@ -4005,6 +4062,10 @@ mod tests {
|
|||
holdings: BTreeMap::new(),
|
||||
dependencies: BTreeSet::new(),
|
||||
incident_routes: vec![first_edge],
|
||||
authority: BTreeSet::new(),
|
||||
offices_held: Vec::new(),
|
||||
offices_granted: Vec::new(),
|
||||
redress: Vec::new(),
|
||||
};
|
||||
let other = SubjectSnapshot {
|
||||
id: other_id,
|
||||
|
|
@ -4018,6 +4079,10 @@ mod tests {
|
|||
holdings: BTreeMap::new(),
|
||||
dependencies: BTreeSet::new(),
|
||||
incident_routes: vec![second_edge],
|
||||
authority: BTreeSet::new(),
|
||||
offices_held: Vec::new(),
|
||||
offices_granted: Vec::new(),
|
||||
redress: Vec::new(),
|
||||
};
|
||||
let named_place = |id, label: &str| PlaceSnapshot {
|
||||
id,
|
||||
|
|
@ -4113,6 +4178,34 @@ mod tests {
|
|||
for leaked in ["The Vault Hoard", "The Vault Stair"] {
|
||||
assert!(!view.contains(leaked), "the typed view leaked `{leaked}`");
|
||||
}
|
||||
|
||||
// The civic blocks are scoped the same way: the subject's own grants,
|
||||
// the offices it occupies and the offices it grants, and the forums it
|
||||
// may petition. No standing boundary, no label for anything inside a
|
||||
// target, and no other subject's jurisdiction.
|
||||
let mut civic = custodial;
|
||||
let hall = EntityId::issue();
|
||||
civic.subject.authority = BTreeSet::from([crate::world::AuthorityGrant {
|
||||
kind: crate::world::AuthorityKindName("levy".into()),
|
||||
over: crate::world::AuthorityTarget::PlaceSubtree(hall),
|
||||
}]);
|
||||
civic.subject.offices_held = vec![OfficeSnapshot {
|
||||
institution: other_id,
|
||||
office: crate::world::OfficeName("warden".into()),
|
||||
incumbent: Some(actor_id),
|
||||
authority: civic.subject.authority.clone(),
|
||||
}];
|
||||
civic.subject.redress = vec![crate::world::ForumSnapshot {
|
||||
grievance: crate::world::GrievanceKindName("seizure".into()),
|
||||
forum: other_id,
|
||||
}];
|
||||
let view = civic.typed_view().unwrap();
|
||||
assert!(view.contains(r#""kind": "levy""#));
|
||||
assert!(view.contains(r#""office": "warden""#));
|
||||
assert!(view.contains(r#""grievance": "seizure""#));
|
||||
assert!(view.contains(encoded_id(&hall).unwrap().as_str()));
|
||||
assert!(!view.contains("standing"));
|
||||
assert!(!view.contains("The Sealed Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use std::{
|
|||
};
|
||||
use thiserror::Error;
|
||||
|
||||
const STATE_ROW: &str = "world_state.affordance.v1";
|
||||
const COMMIT_ROW: &str = "world_commit.affordance.v1";
|
||||
const STATE_ROW: &str = "world_state.authority.v1";
|
||||
const COMMIT_ROW: &str = "world_commit.authority.v1";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(super) enum JournalError {
|
||||
|
|
@ -527,6 +527,73 @@ fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> {
|
|||
));
|
||||
}
|
||||
}
|
||||
// The civic subgraph, in the slot the deleted `authority_scope` check
|
||||
// occupied: a jurisdiction names live ground under a canonical kind, an
|
||||
// office sits on an institution and lends something to a person who holds
|
||||
// no other office there, and a forum names a live subject.
|
||||
let target_is_canonical = |target: &super::AuthorityTarget| match target {
|
||||
super::AuthorityTarget::Subject(subject_id) => state.subjects.contains_key(subject_id),
|
||||
super::AuthorityTarget::PlaceSubtree(entity_id) => is_place(entity_id),
|
||||
};
|
||||
for (subject_id, grants) in &state.authority {
|
||||
if !state.subjects.contains_key(subject_id)
|
||||
|| grants.is_empty()
|
||||
|| !grants.iter().all(|grant| {
|
||||
super::patch::is_civic_name(&grant.kind.0) && target_is_canonical(&grant.over)
|
||||
})
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"authority does not name a canonical subject and live ground under a canonical kind"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (institution, offices) in &state.selection {
|
||||
let mut incumbents = BTreeSet::new();
|
||||
if state.subjects.get(institution).map(|subject| subject.kind)
|
||||
!= Some(super::SubjectKind::Institution)
|
||||
|| offices.is_empty()
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"an office register does not name a canonical institution".into(),
|
||||
));
|
||||
}
|
||||
for (name, office) in offices {
|
||||
let incumbent_is_person = office.incumbent.is_none_or(|incumbent| {
|
||||
state.subjects.get(&incumbent).map(|subject| subject.kind)
|
||||
== Some(super::SubjectKind::Person)
|
||||
&& incumbents.insert(incumbent)
|
||||
});
|
||||
if !super::patch::is_civic_name(&name.0)
|
||||
|| office.delegated.is_empty()
|
||||
|| !office
|
||||
.delegated
|
||||
.iter()
|
||||
.all(|kind| super::patch::is_civic_name(&kind.0))
|
||||
|| !incumbent_is_person
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"an office lends nothing, is misnamed, or is held twice or by a non-person"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (grievance, forum) in &state.redress {
|
||||
if !super::patch::is_civic_name(&grievance.0)
|
||||
|| !state.subjects.contains_key(&forum.forum)
|
||||
|| !target_is_canonical(&forum.standing)
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"a forum does not name a canonical grievance, subject, and standing".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if super::overlapping_holder(state).is_some() {
|
||||
return Err(JournalError::Corrupt(
|
||||
"one subject holds two overlapping jurisdictions of one kind".into(),
|
||||
));
|
||||
}
|
||||
let mut controller_ids = BTreeSet::new();
|
||||
for (subject_id, subject) in &state.subjects {
|
||||
if !super::patch::is_canonical_text(&subject.label) {
|
||||
|
|
@ -1249,6 +1316,240 @@ mod tests {
|
|||
"unexpected refusal: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A committed levy survives a restart exactly: the snapshot, the band, the
|
||||
/// lowered effects, and the re-derived authority verdict all come back, and
|
||||
/// the same envelope is already applied.
|
||||
#[test]
|
||||
fn restart_replay_after_a_levy_is_exact() {
|
||||
use crate::world::tests::{affordance_named, civic_world, command, opportunity_for, owner};
|
||||
use crate::world::{
|
||||
DecisionInvocation, Magnitude, ProposedEffect, Quantity, Role, RoleBinding,
|
||||
SubmitReceipt, Target,
|
||||
};
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
let (mut kernel, _) = WorldKernel::create(
|
||||
&path,
|
||||
crate::world::tests::creation(CommandId::new(), "Replayed Levy"),
|
||||
&crate::world::tests::auth_principal(owner()),
|
||||
)
|
||||
.unwrap();
|
||||
let (_, civic, active) = civic_world(&mut kernel);
|
||||
let opportunity = opportunity_for(&active, civic.treasury);
|
||||
let caller = CallerId::Controller(opportunity.controller_id);
|
||||
let envelope = command(
|
||||
&active,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::ExerciseDecision {
|
||||
opportunity,
|
||||
invocation: DecisionInvocation {
|
||||
affordance: affordance_named(&active, "levy"),
|
||||
bindings: vec![
|
||||
RoleBinding {
|
||||
role: Role("payer".into()),
|
||||
target: Target::Subject(civic.farmer),
|
||||
},
|
||||
RoleBinding {
|
||||
role: Role("resource".into()),
|
||||
target: Target::Entity(civic.grain),
|
||||
},
|
||||
],
|
||||
proposed: vec![ProposedEffect {
|
||||
slot: 0,
|
||||
magnitude: Magnitude::Quantity(Quantity(3)),
|
||||
}],
|
||||
speech: None,
|
||||
},
|
||||
},
|
||||
);
|
||||
kernel
|
||||
.submit(
|
||||
envelope.clone(),
|
||||
&AuthenticatedCaller::fixture(caller.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let committed = kernel.snapshot().unwrap();
|
||||
let event = kernel.state.events.last().expect("the levy event").clone();
|
||||
let world_id = committed.world_id;
|
||||
drop(kernel);
|
||||
|
||||
let mut reopened = WorldKernel::open(&path, world_id).unwrap();
|
||||
assert_eq!(reopened.snapshot().unwrap(), committed);
|
||||
let replayed = reopened.state.events.last().expect("the replayed event");
|
||||
assert_eq!(replayed.band, event.band);
|
||||
assert_eq!(replayed.effects, event.effects);
|
||||
assert!(matches!(
|
||||
reopened
|
||||
.submit(envelope, &AuthenticatedCaller::fixture(caller))
|
||||
.unwrap(),
|
||||
SubmitReceipt::AlreadyApplied(_)
|
||||
));
|
||||
}
|
||||
|
||||
/// Soul falsification: the civic shape checks are not decoration. A forged
|
||||
/// store row that widens a jurisdiction, doubles an incumbency, seats an
|
||||
/// institution, or empties a delegation is refused by `recover`.
|
||||
#[test]
|
||||
fn a_forged_authority_or_incumbency_is_corrupt() {
|
||||
use crate::world::tests::{
|
||||
BAILIFF_OFFICE, LEVY_KIND, WARDEN_OFFICE, auth_principal, authority_kind, civic_world,
|
||||
office, owner,
|
||||
};
|
||||
use crate::world::{AuthorityGrant, AuthorityTarget, Office};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
let (mut kernel, _) = WorldKernel::create(
|
||||
&path,
|
||||
crate::world::tests::creation(CommandId::new(), "Forged Civics"),
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
.unwrap();
|
||||
let (topology, civic, _) = civic_world(&mut kernel);
|
||||
let commits: Vec<CultCacheEnvelope> = kernel
|
||||
.journal
|
||||
.commits
|
||||
.values()
|
||||
.map(|commit| {
|
||||
envelope(COMMIT_ROW, COMMIT_SCHEMA, commit.command.id().key(), commit).unwrap()
|
||||
})
|
||||
.collect();
|
||||
let rows_for = |state: &WorldState| {
|
||||
let mut rows = commits.clone();
|
||||
rows.push(envelope(STATE_ROW, STATE_SCHEMA, state.world_id.key(), state).unwrap());
|
||||
rows
|
||||
};
|
||||
assert!(recover(rows_for(&kernel.state), None).is_ok());
|
||||
|
||||
let widened = {
|
||||
let mut state = kernel.state.clone();
|
||||
state.authority.insert(
|
||||
civic.reeve,
|
||||
BTreeSet::from([AuthorityGrant {
|
||||
kind: authority_kind(LEVY_KIND),
|
||||
over: AuthorityTarget::PlaceSubtree(civic.chamber),
|
||||
}]),
|
||||
);
|
||||
state
|
||||
};
|
||||
let dangling = {
|
||||
let mut state = kernel.state.clone();
|
||||
state.authority.insert(
|
||||
civic.outsider,
|
||||
BTreeSet::from([AuthorityGrant {
|
||||
kind: authority_kind(LEVY_KIND),
|
||||
over: AuthorityTarget::PlaceSubtree(EntityId::issue()),
|
||||
}]),
|
||||
);
|
||||
state
|
||||
};
|
||||
let doubled = {
|
||||
let mut state = kernel.state.clone();
|
||||
state
|
||||
.selection
|
||||
.get_mut(&civic.treasury)
|
||||
.unwrap()
|
||||
.get_mut(&office(BAILIFF_OFFICE))
|
||||
.unwrap()
|
||||
.incumbent = Some(civic.reeve);
|
||||
state
|
||||
};
|
||||
let seated_institution = {
|
||||
let mut state = kernel.state.clone();
|
||||
state
|
||||
.selection
|
||||
.get_mut(&civic.treasury)
|
||||
.unwrap()
|
||||
.get_mut(&office(WARDEN_OFFICE))
|
||||
.unwrap()
|
||||
.incumbent = Some(civic.treasury);
|
||||
state
|
||||
};
|
||||
let inert_office = {
|
||||
let mut state = kernel.state.clone();
|
||||
state.selection.get_mut(&civic.treasury).unwrap().insert(
|
||||
office(WARDEN_OFFICE),
|
||||
Office {
|
||||
incumbent: Some(civic.reeve),
|
||||
delegated: BTreeSet::new(),
|
||||
},
|
||||
);
|
||||
state
|
||||
};
|
||||
let office_on_person = {
|
||||
let mut state = kernel.state.clone();
|
||||
state
|
||||
.selection
|
||||
.insert(topology.walker, state.selection[&civic.treasury].clone());
|
||||
state
|
||||
};
|
||||
for forged in [
|
||||
widened,
|
||||
dangling,
|
||||
doubled,
|
||||
seated_institution,
|
||||
inert_office,
|
||||
office_on_person,
|
||||
] {
|
||||
let error = recover(rows_for(&forged), None).unwrap_err();
|
||||
assert!(
|
||||
matches!(error, JournalError::Corrupt(_)),
|
||||
"a forged civic row recovered: {error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Soul falsification: a store written before the civic partitions is
|
||||
/// refused outright, with no migration adapter in the path.
|
||||
#[test]
|
||||
fn a_store_from_the_previous_schema_is_refused() {
|
||||
use crate::world::tests::{auth_principal, civic_world, owner};
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
let (mut kernel, _) = WorldKernel::create(
|
||||
&path,
|
||||
crate::world::tests::creation(CommandId::new(), "Pre-Authority"),
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
.unwrap();
|
||||
civic_world(&mut kernel);
|
||||
let mut rows: Vec<CultCacheEnvelope> = kernel
|
||||
.journal
|
||||
.commits
|
||||
.values()
|
||||
.map(|commit| {
|
||||
envelope(
|
||||
"world_commit.affordance.v1",
|
||||
COMMIT_SCHEMA,
|
||||
commit.command.id().key(),
|
||||
commit,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
rows.push(
|
||||
envelope(
|
||||
"world_state.affordance.v1",
|
||||
STATE_SCHEMA,
|
||||
kernel.state.world_id.key(),
|
||||
&kernel.state,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let error = recover(rows, None).unwrap_err();
|
||||
let JournalError::Corrupt(message) = error else {
|
||||
panic!("expected a corrupt store, got {error:?}");
|
||||
};
|
||||
assert!(
|
||||
message.contains("unadmitted row type"),
|
||||
"unexpected refusal: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue