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:
Meta Vens 2026-09-04 21:46:34 +02:00
commit fa3d95dca9
5 changed files with 3536 additions and 78 deletions

View file

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

View file

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

View file

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