From fa3d95dca9a129a4b51733e74717ef25c4d82093 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 21:46:34 +0200 Subject: [PATCH] Give the world an Authority, Selection, and Redress subgraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ghostlight-dungeon/src/world/action.rs | 754 ++++++++- .../src/world/controllers.rs | 101 +- .../ghostlight-dungeon/src/world/journal.rs | 305 +++- crates/ghostlight-dungeon/src/world/mod.rs | 1407 ++++++++++++++++- crates/ghostlight-dungeon/src/world/patch.rs | 1047 +++++++++++- 5 files changed, 3536 insertions(+), 78 deletions(-) diff --git a/crates/ghostlight-dungeon/src/world/action.rs b/crates/ghostlight-dungeon/src/world/action.rs index 64b06a8..4f63a72 100644 --- a/crates/ghostlight-dungeon/src/world/action.rs +++ b/crates/ghostlight-dungeon/src/world/action.rs @@ -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, @@ -163,6 +188,10 @@ fn bind_roles( let mut bound: BTreeMap = 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 { fn check_preconditions( state: &WorldState, actor: SubjectId, + authority: &BTreeSet, entry: &Affordance, bindings: &BTreeMap, rejections: &mut Vec, @@ -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, + entry: &Affordance, + bindings: &BTreeMap, + rejections: &mut Vec, +) { + 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 { + 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, + 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 { + 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, + proposed: Vec, + speech: Option, + ) -> 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 { + draw_once( + &self.kernel.state, + CommandId::issue(), + &opportunity_for(&self.active, actor), + invocation, + ) + } + + fn rejected_as( + &self, + actor: SubjectId, + invocation: &DecisionInvocation, + ) -> Vec { + 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) { + 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) -> Vec { + 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) + ); + } } diff --git a/crates/ghostlight-dungeon/src/world/controllers.rs b/crates/ghostlight-dungeon/src/world/controllers.rs index 5da16de..4ef42d5 100644 --- a/crates/ghostlight-dungeon/src/world/controllers.rs +++ b/crates/ghostlight-dungeon/src/world/controllers.rs @@ -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 { + 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 { + offices + .iter() + .map(|office| { + json!({ + "institution": office.institution, + "office": office.office, + "incumbent": office.incumbent, + "authority": office.authority.iter().map(Self::typed_grant).collect::>(), + }) + }) + .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 { + 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] diff --git a/crates/ghostlight-dungeon/src/world/journal.rs b/crates/ghostlight-dungeon/src/world/journal.rs index c1e2d5a..e7bd4b4 100644 --- a/crates/ghostlight-dungeon/src/world/journal.rs +++ b/crates/ghostlight-dungeon/src/world/journal.rs @@ -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 = 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 = 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)] diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index 4a07652..615df94 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -20,13 +20,16 @@ pub(crate) use controllers::{ }; pub(crate) use mailbox::{MailboxError, WorldMailbox}; pub(crate) use patch::{ - AccessKind, Affordance, AffordanceKindName, Bounds, ComponentOpKind, Cost, Declaration, - DependencyTarget, DraftHandle, EffectSlot, EntityDeclaration, EntityKind, EvidenceRef, - Mismatch, OutcomeBand, PatchAnswer, Position, Precondition, Quantity, Ref, RefKind, Role, + AccessKind, Affordance, AffordanceKindName, AuthorityGrant, AuthorityKindName, AuthorityTarget, + Bounds, ComponentOpKind, Cost, Declaration, DependencyTarget, DraftHandle, EffectSlot, + EntityDeclaration, EntityKind, EvidenceRef, Forum, GrievanceKindName, Mismatch, Office, + OfficeName, OutcomeBand, PatchAnswer, Position, Precondition, Quantity, Ref, RefKind, Role, RoleSpec, SubjectDeclaration, WorldPatch, }; #[cfg(test)] use patch::{AffordanceDeclaration, ComponentOp, DependencyRef, RouteDeclaration}; +#[cfg(test)] +pub(crate) use patch::{AuthorityGrantRef, AuthorityTargetRef}; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; use chrono::{DateTime, Utc}; @@ -38,8 +41,8 @@ use std::path::Path; use thiserror::Error; use uuid::Uuid; -pub(crate) const STATE_SCHEMA: &str = "ghostlight.world_state.affordance.v1"; -pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.affordance.v1"; +pub(crate) const STATE_SCHEMA: &str = "ghostlight.world_state.authority.v1"; +pub(crate) const COMMIT_SCHEMA: &str = "ghostlight.world_commit.authority.v1"; /// Compatibility tag derived from [`STATE_SCHEMA`]: the trailing /// `-` pair (e.g. `foundation-v1`). Callers that publish a @@ -480,6 +483,21 @@ struct WorldState { holdings: BTreeMap>, /// What each subject depends on. Never empty for a present key. dependencies: BTreeMap>, + /// What ground each subject has jurisdiction over, and under which + /// world-declared kind. Never empty for a present key. No subject holds two + /// grants of one kind over overlapping ground, so nothing ever arbitrates + /// between two sources of one permission. + authority: BTreeMap>, + /// The offices each institution constitutes, and who sits in them. An + /// incumbent never exercises its institution's opportunity: + /// `controller_assignments` stays the sole answer to who may call, and an + /// office answers the different question of what its incumbent has + /// jurisdiction over. + selection: BTreeMap>, + /// Where each kind of grievance goes, and who may bring it. Keyed by + /// neither a subject nor a referent, so it enters no scope digest and is + /// admission-only state. + redress: BTreeMap, controller_assignments: BTreeMap, /// What an affordance *is*. World-authored, Draft-only, written by /// `admit_resolved` alone. @@ -568,12 +586,41 @@ pub(crate) struct SubjectSnapshot { pub(crate) human_controller: Option, pub(crate) affordances: BTreeSet, pub(crate) position: Option, - /// This subject's own holdings and dependencies, and the routes incident to - /// its place: exactly what its scope digest reads, lowered from the one - /// `scope_components` owner. + /// Lowered from the one `scope_components` owner, so these are exactly what + /// the scope digest binds: this subject's own holdings, dependencies, and + /// grants, the routes incident to its place, and the offices it occupies + /// with what each lends. A counterparty's components never enter. pub(crate) holdings: BTreeMap, pub(crate) dependencies: BTreeSet, pub(crate) incident_routes: Vec, + pub(crate) authority: BTreeSet, + pub(crate) offices_held: Vec, + /// View-only, and covered by no digest: an institution's own offices, so + /// its controller can see what it lends and to whom. No precondition reads + /// it, and installing a warden therefore does not reject the institution's + /// in-flight proposals — what an institution lends is not what it may do. + pub(crate) offices_granted: Vec, + /// View-only: the forums whose standing covers this subject, through the + /// same covering predicate `HasStanding` uses. + pub(crate) redress: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OfficeSnapshot { + pub(crate) institution: SubjectId, + pub(crate) office: OfficeName, + pub(crate) incumbent: Option, + /// What the office actually lends: the institution's live grants whose kind + /// the office delegates. + pub(crate) authority: BTreeSet, +} + +/// A forum a subject may petition. It carries no standing: a subject learns +/// that it may bring a grievance, not the boundary of everyone else's standing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ForumSnapshot { + pub(crate) grievance: GrievanceKindName, + pub(crate) forum: SubjectId, } /// One catalog entry as consumers read it. The whole entry, because every @@ -1125,6 +1172,9 @@ impl WorldState { positions: BTreeMap::new(), holdings: BTreeMap::new(), dependencies: BTreeMap::new(), + authority: BTreeMap::new(), + selection: BTreeMap::new(), + redress: BTreeMap::new(), controller_assignments: BTreeMap::new(), affordance_catalog: BTreeMap::new(), affordance_grants: BTreeMap::new(), @@ -1380,6 +1430,22 @@ fn apply_operations( if patch::check_ledger(&deltas).is_some() { return Err(KernelError::Invariant("custody does not conserve".into())); } + // Disjointness, re-derived over the committed partitions for the same + // reason conservation is: a forged effect reaches this function without + // passing the resolver. + if operations.iter().any(|operation| { + matches!( + operation, + ResolvedOp::GrantAuthority { .. } + | ResolvedOp::OpenOffice { .. } + | ResolvedOp::InstallIncumbent { .. } + ) + }) && overlapping_holder(state).is_some() + { + return Err(KernelError::Invariant( + "one subject holds two overlapping jurisdictions of one kind".into(), + )); + } Ok(()) } @@ -1480,12 +1546,14 @@ fn apply_operation( .get(edge_id) .ok_or_else(|| KernelError::Invariant("relocation names no route".into()))?; let (from, to) = route.endpoints(); - if !route.is_open() - || route.access() != AccessKind::Public + let open = route.is_open(); + let access = route.access().clone(); + if !open || state.positions.get(subject_id) != Some(&Position { place: from }) + || !patch::route_admits(state, &subject_authority(state, *subject_id), &access, to) { return Err(KernelError::Invariant( - "relocation does not traverse an open public route from the subject's place" + "relocation does not traverse an open route the subject may take from its place" .into(), )); } @@ -1641,10 +1709,234 @@ fn apply_operation( } } } + ResolvedOp::GrantAuthority { holder, grant } + | ResolvedOp::RevokeAuthority { holder, grant } => { + let granting = matches!(operation, ResolvedOp::GrantAuthority { .. }); + if !state.subjects.contains_key(holder) + || !authority_referent_exists(state, grant.over) + || !patch::is_civic_name(&grant.kind.0) + { + return Err(unknown()); + } + let held = state + .authority + .get(holder) + .is_some_and(|grants| grants.contains(grant)); + if held == granting { + return Err(KernelError::Invariant( + "authority operation changes nothing".into(), + )); + } + if granting { + state + .authority + .entry(*holder) + .or_default() + .insert(grant.clone()); + } else { + let empty = if let Some(grants) = state.authority.get_mut(holder) { + grants.remove(grant); + grants.is_empty() + } else { + false + }; + if empty { + state.authority.remove(holder); + } + } + } + ResolvedOp::OpenOffice { + institution, + office, + delegated, + } => { + require_institution(state, *institution, office)?; + if delegated.is_empty() || delegated.iter().any(|kind| !patch::is_civic_name(&kind.0)) { + return Err(KernelError::Invariant( + "an office lends no canonical authority kind".into(), + )); + } + let current = state + .selection + .get(institution) + .and_then(|offices| offices.get(office)); + if current.is_some_and(|seat| &seat.delegated == delegated) { + return Err(KernelError::Invariant( + "office operation changes nothing".into(), + )); + } + // A sitting incumbent survives a reconstitution: clipping an + // office's powers under its holder is a political act, not a + // vacancy. + let incumbent = current.and_then(|seat| seat.incumbent); + state.selection.entry(*institution).or_default().insert( + office.clone(), + Office { + incumbent, + delegated: delegated.clone(), + }, + ); + } + ResolvedOp::CloseOffice { + institution, + office, + } + | ResolvedOp::VacateOffice { + institution, + office, + } => { + let closing = matches!(operation, ResolvedOp::CloseOffice { .. }); + require_institution(state, *institution, office)?; + let no_office = || KernelError::Invariant("office operation names no office".into()); + let offices = state.selection.get_mut(institution).ok_or_else(no_office)?; + let occupied = offices + .get(office) + .map(|seat| seat.incumbent.is_some()) + .ok_or_else(no_office)?; + if closing { + offices.remove(office); + let empty = offices.is_empty(); + if empty { + state.selection.remove(institution); + } + } else if !occupied { + return Err(KernelError::Invariant( + "office operation changes nothing".into(), + )); + } else { + offices.get_mut(office).ok_or_else(no_office)?.incumbent = None; + } + } + ResolvedOp::InstallIncumbent { + institution, + office, + incumbent, + } => { + require_institution(state, *institution, office)?; + if state.subjects.get(incumbent).map(|subject| subject.kind) + != Some(SubjectKind::Person) + { + return Err(KernelError::Invariant( + "an office holder is not a person subject".into(), + )); + } + let offices = state + .selection + .get_mut(institution) + .ok_or_else(|| KernelError::Invariant("office operation names no office".into()))?; + if offices + .iter() + .any(|(name, seat)| name != office && seat.incumbent == Some(*incumbent)) + { + return Err(KernelError::Invariant( + "one person holds two offices of one institution".into(), + )); + } + let seat = offices + .get_mut(office) + .ok_or_else(|| KernelError::Invariant("office operation names no office".into()))?; + if seat.incumbent == Some(*incumbent) { + return Err(KernelError::Invariant( + "office operation changes nothing".into(), + )); + } + seat.incumbent = Some(*incumbent); + } + ResolvedOp::OpenForum { + grievance, + forum, + standing, + } => { + if !state.subjects.contains_key(forum) + || !authority_referent_exists(state, *standing) + || !patch::is_civic_name(&grievance.0) + { + return Err(unknown()); + } + let seat = Forum { + forum: *forum, + standing: *standing, + }; + if state.redress.get(grievance) == Some(&seat) { + return Err(KernelError::Invariant( + "forum operation changes nothing".into(), + )); + } + state.redress.insert(grievance.clone(), seat); + } + ResolvedOp::CloseForum { grievance } => { + if state.redress.remove(grievance).is_none() { + return Err(KernelError::Invariant( + "forum operation names no forum".into(), + )); + } + } } Ok(()) } +/// Whether an authority target names a live subject or a live place. +fn authority_referent_exists(state: &WorldState, target: AuthorityTarget) -> bool { + match target { + AuthorityTarget::Subject(subject_id) => state.subjects.contains_key(&subject_id), + AuthorityTarget::PlaceSubtree(entity_id) => state + .entities + .get(&entity_id) + .is_some_and(|record| record.kind == EntityKind::Place), + } +} + +/// The shared half of every office operation: a canonical office name on a live +/// institution subject. +fn require_institution( + state: &WorldState, + institution: SubjectId, + office: &OfficeName, +) -> Result<(), KernelError> { + if !patch::is_civic_name(&office.0) { + return Err(KernelError::Invariant( + "office name is not canonical".into(), + )); + } + if state.subjects.get(&institution).map(|subject| subject.kind) + != Some(SubjectKind::Institution) + { + return Err(KernelError::Invariant( + "an office was opened on a subject that is not an institution".into(), + )); + } + Ok(()) +} + +/// The subject, if any, whose effective authority holds two grants of one kind +/// over overlapping ground. Re-derived over the committed partitions after every +/// batch that could widen a jurisdiction, so a forged effect cannot install the +/// split-brained state the resolver refuses. +fn overlapping_holder(state: &WorldState) -> Option { + let holders: BTreeSet = state + .authority + .keys() + .copied() + .chain( + state + .selection + .values() + .flat_map(|offices| offices.values()) + .filter_map(|office| office.incumbent), + ) + .collect(); + holders.into_iter().find(|holder| { + let effective: Vec = + subject_authority(state, *holder).into_iter().collect(); + effective.iter().enumerate().any(|(index, one)| { + effective[index + 1..].iter().any(|other| { + one.kind == other.kind + && patch::targets_overlap(&state.entities, one.over, other.over) + }) + }) + }) +} + /// Exactly the components a subject's verification reads. One owner, consumed by /// the scope digest and by the snapshot, so the two cannot drift. `routes` is /// every edge whose `from` or `to` is the subject's place: an inbound route @@ -1652,12 +1944,115 @@ fn apply_operation( /// reading too much only costs an extra rejection. `holdings` and `dependencies` /// are the acting subject's own; a counterparty's holdings do not enter, because /// a transfer changes both subjects' components and so changes both digests. +/// It also holds what the actor *is* authorized over, and nothing about the +/// world the actor reaches: the target's position, the target's container +/// chain, the forum's state, and any subordinate's components stay out. A +/// realm-wide authority whose preimage held its jurisdiction's occupancy would +/// conflict with every commit in the realm, which is whole-world binding +/// restored by the front door. A target that walks out of a jurisdiction +/// mid-flight therefore fails at `Authorized` rather than rebinding, while a +/// revoked grant is a `ScopeChanged`. #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub(super) struct ScopeComponents { position: Option, routes: BTreeMap, holdings: BTreeMap, dependencies: BTreeSet, + /// This subject's own grants. + authority: BTreeSet, + /// What each held office lends: for every office whose incumbent is this + /// subject, the institution's live grants whose kind that office delegates. + /// The institution's authority is copied in through the office link, so one + /// scope digest covers the grant, the office, and the institution's + /// jurisdiction, and only delegated kinds enter — an institution's `judge` + /// jurisdiction never churns a `levy`-only incumbent's proposals. + delegated: BTreeMap>>, +} + +/// Whether an authority target covers a referent. The one statement of +/// jurisdictional membership, reached by `Authorized`, `HasStanding`, +/// `route_admits`, the delegation monotonicity rule, and the snapshot's redress +/// projection. +/// +/// A place subtree covers a subject standing anywhere under it, a place under +/// it, and a route with either endpoint under it — a city must be able to close +/// its own gate, and a gate's far end is outside the city by construction. A +/// resource, fact, or channel is covered by nothing; `RoleKindUnfit` refuses an +/// `Authorized` over such a role at declaration. +fn covers(state: &WorldState, target: AuthorityTarget, of: Target) -> bool { + let under = |place: EntityId, root: EntityId| patch::covers_place(&state.entities, root, place); + match (target, of) { + (AuthorityTarget::Subject(holder), Target::Subject(subject_id)) => holder == subject_id, + (AuthorityTarget::Subject(_), _) => false, + (AuthorityTarget::PlaceSubtree(root), Target::Subject(subject_id)) => state + .positions + .get(&subject_id) + .is_some_and(|position| under(position.place, root)), + (AuthorityTarget::PlaceSubtree(root), Target::Entity(entity_id)) => state + .entities + .get(&entity_id) + .is_some_and(|record| record.kind == EntityKind::Place && under(entity_id, root)), + (AuthorityTarget::PlaceSubtree(root), Target::Edge(edge_id)) => { + state.edges.get(&edge_id).is_some_and(|record| { + let (from, to) = record.endpoints(); + under(from, root) || under(to, root) + }) + } + } +} + +/// Own grants plus what every held office lends. The single statement of what a +/// subject may invoke authority over. There is no precedence between a direct +/// grant and a delegated one, because `OverlappingJurisdiction` makes it +/// impossible for both to answer for one kind over overlapping ground. +fn effective_authority( + own: &BTreeSet, + delegated: &BTreeMap>>, +) -> BTreeSet { + let mut effective = own.clone(); + for lent in delegated.values().flat_map(|offices| offices.values()) { + effective.extend(lent.iter().cloned()); + } + effective +} + +/// Every office this subject occupies, keyed by institution, with what each one +/// lends of that institution's live grants. +fn delegated_authority( + state: &WorldState, + subject_id: SubjectId, +) -> BTreeMap>> { + let mut held: BTreeMap>> = + BTreeMap::new(); + for (institution, offices) in &state.selection { + for (name, office) in offices { + if office.incumbent != Some(subject_id) { + continue; + } + let lent: BTreeSet = state + .authority + .get(institution) + .into_iter() + .flatten() + .filter(|grant| office.delegated.contains(&grant.kind)) + .cloned() + .collect(); + held.entry(*institution) + .or_default() + .insert(name.clone(), lent); + } + } + held +} + +/// The effective authority of one subject read straight from the partitions, +/// for the reducer paths that do not already hold a `ScopeComponents`. +fn subject_authority(state: &WorldState, subject_id: SubjectId) -> BTreeSet { + let empty = BTreeSet::new(); + effective_authority( + state.authority.get(&subject_id).unwrap_or(&empty), + &delegated_authority(state, subject_id), + ) } fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponents { @@ -1684,6 +2079,12 @@ fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponent .get(&subject_id) .cloned() .unwrap_or_default(), + authority: state + .authority + .get(&subject_id) + .cloned() + .unwrap_or_default(), + delegated: delegated_authority(state, subject_id), } } @@ -1704,6 +2105,48 @@ fn snapshot(state: &WorldState) -> Result { .cloned() .unwrap_or_default(); let components = scope_components(state, *subject_id); + let offices_held = components + .delegated + .iter() + .flat_map(|(institution, offices)| { + offices + .iter() + .map(move |(office, authority)| OfficeSnapshot { + institution: *institution, + office: office.clone(), + incumbent: Some(*subject_id), + authority: authority.clone(), + }) + }) + .collect(); + let offices_granted = state + .selection + .get(subject_id) + .into_iter() + .flatten() + .map(|(office, seat)| OfficeSnapshot { + institution: *subject_id, + office: office.clone(), + incumbent: seat.incumbent, + authority: state + .authority + .get(subject_id) + .into_iter() + .flatten() + .filter(|grant| seat.delegated.contains(&grant.kind)) + .cloned() + .collect(), + }) + .collect(); + let redress = state + .redress + .iter() + .filter(|(_, forum)| covers(state, forum.standing, Target::Subject(*subject_id))) + .map(|(grievance, forum)| ForumSnapshot { + grievance: grievance.clone(), + forum: forum.forum, + }) + .collect(); Ok(SubjectSnapshot { id: *subject_id, label: subject.label.clone(), @@ -1716,6 +2159,10 @@ fn snapshot(state: &WorldState) -> Result { holdings: components.holdings, dependencies: components.dependencies, incident_routes: components.routes.into_keys().collect(), + authority: components.authority, + offices_held, + offices_granted, + redress, }) }) .collect::, KernelError>>()?; @@ -1748,7 +2195,7 @@ fn snapshot(state: &WorldState) -> Result { label: record.label().to_owned(), from, to, - access: record.access(), + access: record.access().clone(), cost: record.cost(), open: record.is_open(), } @@ -2324,7 +2771,9 @@ mod tests { "The Toll Stair", "yard", "gate", - AccessKind::Restricted, + AccessKind::Restricted { + requires: AuthorityKindName(ADMIT_KIND.into()), + }, 4, ), way( @@ -2410,6 +2859,16 @@ mod tests { pub(super) counterparty: SubjectId, } + /// The seed world's authority kinds, offices, and grievance. They are world + /// data: the kernel compares these strings and reads them no other way. + pub(super) const LEVY_KIND: &str = "levy"; + pub(super) const ADMIT_KIND: &str = "admit"; + pub(super) const COMMAND_KIND: &str = "command"; + pub(super) const JUDGE_KIND: &str = "judge"; + pub(super) const WARDEN_OFFICE: &str = "warden"; + pub(super) const BAILIFF_OFFICE: &str = "bailiff"; + pub(super) const SEIZURE_GRIEVANCE: &str = "seizure"; + pub(super) const TITHE_RECEIPT: &str = "receipt:rhythm-tithe-census"; pub(super) const OPENING_BALANCE: u64 = 7; @@ -2708,6 +3167,428 @@ mod tests { (topology, custody, active) } + /// The canonical IDs of the civic fixture: a hall containing a chamber, an + /// institution holding jurisdiction over the hall, a person who holds that + /// jurisdiction only through an office, a person and a resource inside the + /// hall, and a person standing outside it. + pub(super) struct Civic { + pub(super) hall: EntityId, + pub(super) chamber: EntityId, + pub(super) passage: EdgeId, + pub(super) causeway: EdgeId, + pub(super) postern: EdgeId, + pub(super) treasury: SubjectId, + pub(super) reeve: SubjectId, + pub(super) farmer: SubjectId, + pub(super) outsider: SubjectId, + pub(super) grain: EntityId, + } + + pub(super) fn authority_kind(name: &str) -> AuthorityKindName { + AuthorityKindName(name.into()) + } + + pub(super) fn office(name: &str) -> OfficeName { + OfficeName(name.into()) + } + + pub(super) fn grievance(name: &str) -> GrievanceKindName { + GrievanceKindName(name.into()) + } + + pub(super) fn over_place(place: EntityId) -> AuthorityTargetRef { + AuthorityTargetRef::PlaceSubtree(Ref::Existing(place)) + } + + pub(super) fn over_subject(subject_id: SubjectId) -> AuthorityTargetRef { + AuthorityTargetRef::Subject(Ref::Existing(subject_id)) + } + + pub(super) fn grant_of(kind: &str, over: AuthorityTargetRef) -> AuthorityGrantRef { + AuthorityGrantRef { + kind: authority_kind(kind), + over, + } + } + + pub(super) fn grant_to(holder: SubjectId, kind: &str, over: AuthorityTargetRef) -> ComponentOp { + ComponentOp::GrantAuthority { + holder: Ref::Existing(holder), + grant: grant_of(kind, over), + } + } + + /// Six world-authored civic entries. The kernel builds none of them; each + /// is a worked example of what a seed author writes to make the political + /// layer playable rather than administratively imposed. + fn civic_affordances() -> Vec { + let entry = |handle: &str, + kind: &str, + roles: Vec, + preconditions: Vec, + effect_slots: Vec, + outcome_bands: Vec, + carries_speech: bool| { + Declaration::Affordance(AffordanceDeclaration { + handle: DraftHandle::new(handle), + kind: AffordanceKindName(kind.into()), + roles, + preconditions, + effect_slots, + outcome_bands, + carries_speech, + }) + }; + let role = |name: &str, kind: RefKind| RoleSpec { + role: Role(name.into()), + kind, + }; + let slot = |op_kind: ComponentOpKind, roles: Vec<&str>, bounds: Bounds| EffectSlot { + op_kind, + roles: roles.into_iter().map(|name| Role(name.into())).collect(), + bounds, + }; + let authorized = |over: &str, kind: &str| Precondition::Authorized { + over: Role(over.into()), + kind: authority_kind(kind), + }; + vec![ + // The payee is the reserved `actor` role, so an authorized + // collector cannot lawfully take a tax and send it to a friend. + entry( + "levy", + "levy", + vec![ + role("payer", RefKind::Subject(None)), + role("resource", RefKind::Entity(EntityKind::Resource)), + ], + vec![authorized("payer", LEVY_KIND)], + vec![slot( + ComponentOpKind::Transfer, + vec!["payer", "actor", "resource"], + Bounds::Quantity(Quantity(10)), + )], + certain_band(), + false, + ), + entry( + "delegate", + "delegate", + vec![ + role("deputy", RefKind::Subject(None)), + role("ground", RefKind::Entity(EntityKind::Place)), + ], + // The precondition names one kind and the slot grants another, + // so the monotonicity rule is the check that decides whether + // the granted authority is one the granter holds. + vec![authorized("ground", COMMAND_KIND)], + vec![slot( + ComponentOpKind::GrantAuthority { + kind: authority_kind(LEVY_KIND), + }, + vec!["deputy", "ground"], + Bounds::None, + )], + certain_band(), + false, + ), + entry( + "deploy", + "deploy", + vec![ + role("subordinate", RefKind::Subject(None)), + role("via", RefKind::Edge(patch::EdgeKind::Route)), + ], + vec![authorized("subordinate", COMMAND_KIND)], + vec![slot( + ComponentOpKind::Relocate, + vec!["subordinate", "via"], + Bounds::None, + )], + certain_band(), + false, + ), + // The first affordance whose effect changes shared topology under a + // legitimacy check; the second band is the interdiction that fails. + entry( + "sanction", + "sanction", + vec![role("road", RefKind::Edge(patch::EdgeKind::Route))], + vec![authorized("road", COMMAND_KIND)], + vec![slot( + ComponentOpKind::CloseRoute, + vec!["road"], + Bounds::None, + )], + vec![ + OutcomeBand { + weight: 3, + effects: vec![0], + }, + OutcomeBand { + weight: 1, + effects: Vec::new(), + }, + ], + false, + ), + // Succession is an act someone performs under authority, not a + // declared method string. + entry( + "appoint", + "appoint", + vec![ + role("institution", RefKind::Subject(None)), + role("candidate", RefKind::Subject(None)), + ], + vec![authorized("institution", COMMAND_KIND)], + vec![slot( + ComponentOpKind::InstallIncumbent { + office: office(BAILIFF_OFFICE), + }, + vec!["institution", "candidate"], + Bounds::None, + )], + certain_band(), + false, + ), + // The reader that keeps `Redress` from being decorative. + entry( + "petition", + "petition", + Vec::new(), + vec![Precondition::HasStanding { + grievance: grievance(SEIZURE_GRIEVANCE), + }], + Vec::new(), + vec![OutcomeBand { + weight: 1, + effects: Vec::new(), + }], + true, + ), + ] + } + + fn civic_grants(handles: &[&str], speak: &Ref) -> BTreeSet> { + std::iter::once(speak.clone()) + .chain( + handles + .iter() + .map(|handle| Ref::Draft(DraftHandle::new(*handle))), + ) + .collect() + } + + pub(super) fn civic_patch(topology: &Topology, speak: Ref) -> WorldPatch { + let person = |handle: &str, label: &str, place: Ref, handles: &[&str]| { + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new(handle), + label: label.into(), + kind: SubjectKind::Person, + controller: NewController::OperationalAgent, + affordances: civic_grants(handles, &speak), + position: Some(place), + }) + }; + let declarations = civic_affordances() + .into_iter() + .chain([ + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("hall"), + label: "The Tithe Hall".into(), + kind: EntityKind::Place, + container: None, + }), + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new("chamber"), + label: "The Counting Chamber".into(), + kind: EntityKind::Place, + container: Some(Ref::Draft(DraftHandle::new("hall"))), + }), + resource("grain", "The Winter Grain"), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("passage"), + label: "The Hall Passage".into(), + from: Ref::Draft(DraftHandle::new("chamber")), + to: Ref::Draft(DraftHandle::new("hall")), + access: AccessKind::Public, + cost: Cost(3), + }), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("causeway"), + label: "The Hall Causeway".into(), + from: Ref::Draft(DraftHandle::new("hall")), + to: Ref::Existing(topology.road), + access: AccessKind::Public, + cost: Cost(6), + }), + Declaration::Route(RouteDeclaration { + handle: DraftHandle::new("postern"), + label: "The Hall Postern".into(), + from: Ref::Existing(topology.yard), + to: Ref::Draft(DraftHandle::new("chamber")), + access: AccessKind::Restricted { + requires: authority_kind(ADMIT_KIND), + }, + cost: Cost(2), + }), + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new("treasury"), + label: "The Tithe Treasury".into(), + kind: SubjectKind::Institution, + controller: NewController::OperationalAgent, + affordances: civic_grants( + &["levy", "delegate", "deploy", "sanction", "appoint"], + &speak, + ), + position: Some(Ref::Draft(DraftHandle::new("hall"))), + }), + Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new("reeve"), + label: "The Hall Reeve".into(), + kind: SubjectKind::Person, + controller: NewController::NarrativePersona, + affordances: civic_grants( + &["levy", "delegate", "petition", "sanction"], + &speak, + ), + position: Some(Ref::Draft(DraftHandle::new("chamber"))), + }), + person( + "farmer", + "The Winter Farmer", + Ref::Draft(DraftHandle::new("chamber")), + &["levy", "petition"], + ), + person( + "outsider", + "The Road Pedlar", + Ref::Existing(topology.road), + &["levy", "petition"], + ), + ]) + .collect(); + WorldPatch { + declarations, + operations: vec![ + ComponentOp::Admit { + holder: Ref::Draft(DraftHandle::new("farmer")), + resource: Ref::Draft(DraftHandle::new("grain")), + qty: Quantity(OPENING_BALANCE), + evidence: EvidenceRef::new(TITHE_RECEIPT), + }, + ComponentOp::Admit { + holder: Ref::Draft(DraftHandle::new("outsider")), + resource: Ref::Draft(DraftHandle::new("grain")), + qty: Quantity(OPENING_BALANCE), + evidence: EvidenceRef::new(TITHE_RECEIPT), + }, + ComponentOp::GrantAuthority { + holder: Ref::Draft(DraftHandle::new("treasury")), + grant: AuthorityGrantRef { + kind: authority_kind(LEVY_KIND), + over: AuthorityTargetRef::PlaceSubtree(Ref::Draft(DraftHandle::new( + "hall", + ))), + }, + }, + ComponentOp::GrantAuthority { + holder: Ref::Draft(DraftHandle::new("treasury")), + grant: AuthorityGrantRef { + kind: authority_kind(COMMAND_KIND), + over: AuthorityTargetRef::PlaceSubtree(Ref::Draft(DraftHandle::new( + "hall", + ))), + }, + }, + ComponentOp::OpenOffice { + institution: Ref::Draft(DraftHandle::new("treasury")), + office: office(WARDEN_OFFICE), + delegated: BTreeSet::from([authority_kind(LEVY_KIND)]), + }, + ComponentOp::OpenOffice { + institution: Ref::Draft(DraftHandle::new("treasury")), + office: office(BAILIFF_OFFICE), + delegated: BTreeSet::from([authority_kind(JUDGE_KIND)]), + }, + ComponentOp::InstallIncumbent { + institution: Ref::Draft(DraftHandle::new("treasury")), + office: office(WARDEN_OFFICE), + incumbent: Ref::Draft(DraftHandle::new("reeve")), + }, + ComponentOp::OpenForum { + grievance: grievance(SEIZURE_GRIEVANCE), + forum: Ref::Draft(DraftHandle::new("treasury")), + standing: AuthorityTargetRef::PlaceSubtree(Ref::Draft(DraftHandle::new( + "chamber", + ))), + }, + ], + evidence: vec![EvidenceRef::new(TITHE_RECEIPT)], + } + } + + pub(super) fn admit_civic(kernel: &mut WorldKernel, topology: &Topology) -> Civic { + let before = kernel.snapshot().unwrap(); + let receipt = submit_owner( + kernel, + &before, + CommandBody::AdmitPatch { + answers: None, + patch: civic_patch(topology, speak_entry(kernel)), + }, + ); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let entity = |label: &str| { + *kernel + .state + .entities + .iter() + .find(|(_, record)| record.label == label) + .expect("a declared entity") + .0 + }; + let edge = |label: &str| { + *kernel + .state + .edges + .iter() + .find(|(_, record)| record.label() == label) + .expect("a declared route") + .0 + }; + let subject = |label: &str| { + *kernel + .state + .subjects + .iter() + .find(|(_, record)| record.label == label) + .expect("a declared subject") + .0 + }; + Civic { + hall: entity("The Tithe Hall"), + chamber: entity("The Counting Chamber"), + grain: entity("The Winter Grain"), + passage: edge("The Hall Passage"), + causeway: edge("The Hall Causeway"), + postern: edge("The Hall Postern"), + treasury: subject("The Tithe Treasury"), + reeve: subject("The Hall Reeve"), + farmer: subject("The Winter Farmer"), + outsider: subject("The Road Pedlar"), + } + } + + /// Topology, the civic subgraph, then activation. + pub(super) fn civic_world(kernel: &mut WorldKernel) -> (Topology, Civic, WorldSnapshot) { + let topology = admit_topology(kernel); + let civic = admit_civic(kernel, &topology); + let active = activate(kernel); + (topology, civic, active) + } + /// Submits as owner and returns the complete mismatch set. pub(super) fn reject_owner( kernel: &mut WorldKernel, @@ -4097,6 +4978,506 @@ mod tests { .expect("a distant route closing does not rebind this proposal"); assert!(matches!(receipt, SubmitReceipt::Applied(_))); } + + /// The mvp doc's disjointness requirement: an institution with an + /// operational organ and a person-shaped voice is two subjects joined by an + /// office, not one subject with two controllers. + #[test] + fn an_institution_and_its_voice_are_two_subjects_joined_by_an_office() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = crate::world::custody_tests::custody_kernel(directory.path(), "Two"); + let (_, civic, active) = civic_world(&mut kernel); + + let treasury = opportunity_for(&active, civic.treasury); + let reeve = opportunity_for(&active, civic.reeve); + assert_ne!(treasury.controller_id, reeve.controller_id); + assert_eq!( + active + .opportunities + .iter() + .filter(|value| value.scope.subject_id == civic.treasury) + .count(), + 1 + ); + assert_eq!( + active + .opportunities + .iter() + .filter(|value| value.scope.subject_id == civic.reeve) + .count(), + 1 + ); + + // Both are authorized over the hall — one directly, one by delegation — + // and neither appears in the other's components. + let over_hall = AuthorityGrant { + kind: authority_kind(LEVY_KIND), + over: AuthorityTarget::PlaceSubtree(civic.hall), + }; + assert!(subject_authority(&kernel.state, civic.treasury).contains(&over_hall)); + assert!(subject_authority(&kernel.state, civic.reeve).contains(&over_hall)); + assert!(kernel.state.authority.get(&civic.reeve).is_none()); + assert!( + scope_components(&kernel.state, civic.treasury) + .delegated + .is_empty() + ); + assert!( + scope_components(&kernel.state, civic.reeve) + .authority + .is_empty() + ); + } + + /// A levy from catalog to conservation: one lowered `Transfer`, the ledger + /// total unchanged, and no other partition touched. + #[test] + fn a_levy_round_trips_from_catalog_to_conservation() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = crate::world::custody_tests::custody_kernel(directory.path(), "Levy"); + let (_, civic, active) = civic_world(&mut kernel); + let before_total: u64 = kernel + .state + .holdings + .values() + .filter_map(|held| held.get(&civic.grain)) + .map(|quantity| quantity.0) + .sum(); + let positions = kernel.state.positions.clone(); + let edges = kernel.state.edges.clone(); + let entities = kernel.state.entities.clone(); + + let opportunity = opportunity_for(&active, civic.treasury); + let caller = CallerId::Controller(opportunity.controller_id); + let receipt = kernel + .submit( + 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, + }, + }, + ), + &AuthenticatedCaller::fixture(caller), + ) + .unwrap(); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + + let held = |holder: SubjectId| { + kernel + .state + .holdings + .get(&holder) + .and_then(|held| held.get(&civic.grain)) + .map_or(0, |quantity| quantity.0) + }; + assert_eq!(held(civic.farmer), OPENING_BALANCE - 3); + assert_eq!(held(civic.treasury), 3); + let after_total: u64 = kernel + .state + .holdings + .values() + .filter_map(|held| held.get(&civic.grain)) + .map(|quantity| quantity.0) + .sum(); + assert_eq!(before_total, after_total); + assert_eq!( + kernel.state.events.last().unwrap().effects, + vec![ResolvedOp::Transfer { + from: civic.farmer, + to: civic.treasury, + resource: civic.grain, + qty: Quantity(3), + }] + ); + assert_eq!(kernel.state.positions, positions); + assert_eq!(kernel.state.edges, edges); + assert_eq!(kernel.state.entities, entities); + } + + /// Representation is person-and-institution shaped, and that is the check + /// that stops the two-subject shape from collapsing back into one. + #[test] + fn office_admission_is_person_and_institution_shaped() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = crate::world::custody_tests::custody_kernel(directory.path(), "Offices"); + let (_, civic, _) = civic_world(&mut kernel); + let refuse = |kernel: &mut WorldKernel, ops: Vec| { + let before = kernel.snapshot().unwrap(); + reject_owner(kernel, &before, operations(ops)) + }; + + // An institution cannot occupy its own office. + assert_eq!( + refuse( + &mut kernel, + vec![ComponentOp::InstallIncumbent { + institution: Ref::Existing(civic.treasury), + office: office(BAILIFF_OFFICE), + incumbent: Ref::Existing(civic.treasury), + }] + ), + vec![Mismatch::OfficeHolderNotPerson { operation: 0 }] + ); + // An office cannot be opened on a person. + assert_eq!( + refuse( + &mut kernel, + vec![ComponentOp::OpenOffice { + institution: Ref::Existing(civic.reeve), + office: office(WARDEN_OFFICE), + delegated: BTreeSet::from([authority_kind(LEVY_KIND)]), + }] + ), + vec![Mismatch::OfficeOnNonInstitution { operation: 0 }] + ); + // One person, at most one office per institution. + assert_eq!( + refuse( + &mut kernel, + vec![ComponentOp::InstallIncumbent { + institution: Ref::Existing(civic.treasury), + office: office(BAILIFF_OFFICE), + incumbent: Ref::Existing(civic.reeve), + }] + ), + vec![Mismatch::DuplicateIncumbency { operation: 0 }] + ); + } + + /// Disjointness in both directions: overlap inside one subject's effective + /// authority is refused, overlap between subjects is layered government. + #[test] + fn overlapping_jurisdiction_within_one_subject_is_rejected() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = crate::world::custody_tests::custody_kernel(directory.path(), "Overlap"); + let (_, civic, _) = civic_world(&mut kernel); + let refuse = |kernel: &mut WorldKernel, ops: Vec| { + let before = kernel.snapshot().unwrap(); + reject_owner(kernel, &before, operations(ops)) + }; + let admit = |kernel: &mut WorldKernel, ops: Vec| { + let before = kernel.snapshot().unwrap(); + submit_owner(kernel, &before, operations(ops)) + }; + + // A direct grant under what the reeve's office already lends. + assert_eq!( + refuse( + &mut kernel, + vec![grant_to(civic.reeve, LEVY_KIND, over_place(civic.chamber))] + ), + vec![Mismatch::OverlappingJurisdiction { operation: 0 }] + ); + // Two direct grants of one kind, nested, in one patch. + assert_eq!( + refuse( + &mut kernel, + vec![ + grant_to(civic.farmer, LEVY_KIND, over_place(civic.hall)), + grant_to(civic.farmer, LEVY_KIND, over_place(civic.chamber)), + ] + ), + vec![Mismatch::OverlappingJurisdiction { operation: 1 }] + ); + // An incumbency that would lend the farmer ground it already holds. + admit( + &mut kernel, + vec![grant_to(civic.farmer, LEVY_KIND, over_place(civic.chamber))], + ); + assert_eq!( + refuse( + &mut kernel, + vec![ComponentOp::InstallIncumbent { + institution: Ref::Existing(civic.treasury), + office: office(WARDEN_OFFICE), + incumbent: Ref::Existing(civic.farmer), + }] + ), + vec![Mismatch::OverlappingJurisdiction { operation: 0 }] + ); + + // A different kind over the same ground commits, and so does the same + // kind held by a different subject: two holders authorized over one + // target is not a contradiction, because nothing arbitrates. + assert!(matches!( + admit( + &mut kernel, + vec![ + grant_to(civic.reeve, JUDGE_KIND, over_place(civic.chamber)), + grant_to(civic.outsider, LEVY_KIND, over_place(civic.chamber)), + ] + ), + SubmitReceipt::Applied(_) + )); + } + + /// Contract 6 on the civic surface: one patch, the complete sorted set, no + /// allocation, and repairing exactly those failures commits. + #[test] + fn civic_operations_are_rejected_with_the_complete_set() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = crate::world::custody_tests::custody_kernel(directory.path(), "CivicSet"); + let (_, civic, _) = civic_world(&mut kernel); + let before = kernel.snapshot().unwrap(); + let broken = vec![ + ComponentOp::OpenOffice { + institution: Ref::Existing(civic.treasury), + office: office("steward"), + delegated: BTreeSet::new(), + }, + ComponentOp::InstallIncumbent { + institution: Ref::Existing(civic.treasury), + office: office("provost"), + incumbent: Ref::Existing(civic.farmer), + }, + ComponentOp::CloseForum { + grievance: grievance("eviction"), + }, + ComponentOp::OpenOffice { + institution: Ref::Existing(civic.treasury), + office: office("Chief Steward"), + delegated: BTreeSet::from([authority_kind(JUDGE_KIND)]), + }, + ComponentOp::InstallIncumbent { + institution: Ref::Existing(civic.treasury), + office: office(BAILIFF_OFFICE), + incumbent: Ref::Existing(civic.treasury), + }, + grant_to(civic.reeve, LEVY_KIND, over_place(civic.chamber)), + ]; + let mut expected = vec![ + Mismatch::EmptyDelegation { operation: 0 }, + Mismatch::UnknownOffice { operation: 1 }, + Mismatch::UnknownForum { operation: 2 }, + Mismatch::InvalidCivicName { + site: patch::Site::Operation(3), + }, + Mismatch::OfficeHolderNotPerson { operation: 4 }, + Mismatch::OverlappingJurisdiction { operation: 5 }, + ]; + expected.sort(); + assert_eq!( + reject_owner(&mut kernel, &before, operations(broken)), + expected + ); + assert_eq!(kernel.snapshot().unwrap(), before); + + let repaired = vec![ + ComponentOp::OpenOffice { + institution: Ref::Existing(civic.treasury), + office: office("steward"), + delegated: BTreeSet::from([authority_kind(JUDGE_KIND)]), + }, + ComponentOp::InstallIncumbent { + institution: Ref::Existing(civic.treasury), + office: office("steward"), + incumbent: Ref::Existing(civic.farmer), + }, + ComponentOp::OpenForum { + grievance: grievance("eviction"), + forum: Ref::Existing(civic.treasury), + standing: over_place(civic.hall), + }, + ]; + assert!(matches!( + submit_owner(&mut kernel, &before, operations(repaired)), + SubmitReceipt::Applied(_) + )); + } + + /// A civic operation that changes nothing is not a canonical change. + #[test] + fn an_idempotent_civic_operation_is_no_change() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = + crate::world::custody_tests::custody_kernel(directory.path(), "Idempotent"); + let (_, civic, _) = civic_world(&mut kernel); + for operation in [ + grant_to(civic.treasury, LEVY_KIND, over_place(civic.hall)), + ComponentOp::RevokeAuthority { + holder: Ref::Existing(civic.treasury), + grant: grant_of(JUDGE_KIND, over_place(civic.hall)), + }, + ComponentOp::VacateOffice { + institution: Ref::Existing(civic.treasury), + office: office(BAILIFF_OFFICE), + }, + ComponentOp::OpenForum { + grievance: grievance(SEIZURE_GRIEVANCE), + forum: Ref::Existing(civic.treasury), + standing: over_place(civic.chamber), + }, + ComponentOp::OpenOffice { + institution: Ref::Existing(civic.treasury), + office: office(WARDEN_OFFICE), + delegated: BTreeSet::from([authority_kind(LEVY_KIND)]), + }, + ] { + let before = kernel.snapshot().unwrap(); + assert_eq!( + reject_owner(&mut kernel, &before, operations(vec![operation])), + vec![Mismatch::NoOperationEffect { operation: 0 }] + ); + } + } + + /// The digest reads what the actor is authorized over and nothing about the + /// world the actor reaches. + #[test] + fn scope_digest_reads_authority_and_delegation_but_not_occupancy() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = crate::world::custody_tests::custody_kernel(directory.path(), "Digest"); + let (topology, civic, _) = civic_world(&mut kernel); + let scope = |subject_id| DecisionScope { subject_id }; + let digest_of = |kernel: &WorldKernel, subject_id| { + scope_digest(&kernel.state, scope(subject_id)).unwrap() + }; + let admit = |kernel: &mut WorldKernel, ops: Vec| { + let before = kernel.snapshot().unwrap(); + submit_owner(kernel, &before, operations(ops)); + }; + + // Moving an unrelated subject into and out of the jurisdiction, and + // opening a forum, leave every digest alone. + let reeve_before = digest_of(&kernel, civic.reeve); + let treasury_before = digest_of(&kernel, civic.treasury); + admit( + &mut kernel, + vec![ + ComponentOp::Relocate { + subject: Ref::Existing(topology.walker), + via: Ref::Existing(topology.ramp), + }, + ComponentOp::OpenForum { + grievance: grievance("eviction"), + forum: Ref::Existing(civic.treasury), + standing: over_place(civic.hall), + }, + ], + ); + assert_eq!(digest_of(&kernel, civic.reeve), reeve_before); + assert_eq!(digest_of(&kernel, civic.treasury), treasury_before); + + // Installing an office the subject does not hold leaves it alone too. + admit( + &mut kernel, + vec![ComponentOp::InstallIncumbent { + institution: Ref::Existing(civic.treasury), + office: office(BAILIFF_OFFICE), + incumbent: Ref::Existing(civic.farmer), + }], + ); + assert_eq!(digest_of(&kernel, civic.reeve), reeve_before); + assert_eq!(digest_of(&kernel, civic.treasury), treasury_before); + + // Revoking the institution's grant for a lent kind moves the + // incumbent's digest, because the office copies it in. + admit( + &mut kernel, + vec![ComponentOp::RevokeAuthority { + holder: Ref::Existing(civic.treasury), + grant: grant_of(LEVY_KIND, over_place(civic.hall)), + }], + ); + assert_ne!(digest_of(&kernel, civic.reeve), reeve_before); + assert_ne!(digest_of(&kernel, civic.treasury), treasury_before); + + // Granting the actor's own, and narrowing a held office, move it too. + let reeve_before = digest_of(&kernel, civic.reeve); + admit( + &mut kernel, + vec![grant_to(civic.reeve, LEVY_KIND, over_place(civic.chamber))], + ); + assert_ne!(digest_of(&kernel, civic.reeve), reeve_before); + let reeve_before = digest_of(&kernel, civic.reeve); + admit( + &mut kernel, + vec![ComponentOp::OpenOffice { + institution: Ref::Existing(civic.treasury), + office: office(WARDEN_OFFICE), + delegated: BTreeSet::from([authority_kind(COMMAND_KIND)]), + }], + ); + assert_ne!(digest_of(&kernel, civic.reeve), reeve_before); + let reeve_before = digest_of(&kernel, civic.reeve); + admit( + &mut kernel, + vec![ComponentOp::VacateOffice { + institution: Ref::Existing(civic.treasury), + office: office(WARDEN_OFFICE), + }], + ); + assert_ne!(digest_of(&kernel, civic.reeve), reeve_before); + } + + /// The snapshot carries a subject's own civic state and no gazetteer: a + /// forum is visible only to its standing, and no subject sees another's + /// grants. + #[test] + fn a_forum_is_visible_only_to_its_standing() { + let directory = tempfile::tempdir().unwrap(); + let mut kernel = crate::world::custody_tests::custody_kernel(directory.path(), "Redress"); + let (_, civic, active) = civic_world(&mut kernel); + let of = |subject_id: SubjectId| { + active + .subjects + .iter() + .find(|subject| subject.id == subject_id) + .expect("a declared subject") + }; + + // The farmer stands in the chamber, which is the forum's standing. + assert_eq!( + of(civic.farmer).redress, + vec![ForumSnapshot { + grievance: grievance(SEIZURE_GRIEVANCE), + forum: civic.treasury, + }] + ); + assert!(of(civic.outsider).redress.is_empty()); + + // Grants are the subject's own; offices held and offices granted are + // the two ends of one link and never merge. + assert!(of(civic.reeve).authority.is_empty()); + assert_eq!(of(civic.reeve).offices_held.len(), 1); + assert!(of(civic.reeve).offices_granted.is_empty()); + assert_eq!(of(civic.treasury).offices_granted.len(), 2); + assert!(of(civic.treasury).offices_held.is_empty()); + assert!(of(civic.farmer).authority.is_empty()); + assert_eq!( + of(civic.treasury).authority, + BTreeSet::from([ + AuthorityGrant { + kind: authority_kind(LEVY_KIND), + over: AuthorityTarget::PlaceSubtree(civic.hall), + }, + AuthorityGrant { + kind: authority_kind(COMMAND_KIND), + over: AuthorityTarget::PlaceSubtree(civic.hall), + }, + ]) + ); + } } #[cfg(test)] diff --git a/crates/ghostlight-dungeon/src/world/patch.rs b/crates/ghostlight-dungeon/src/world/patch.rs index e901d55..f2dd558 100644 --- a/crates/ghostlight-dungeon/src/world/patch.rs +++ b/crates/ghostlight-dungeon/src/world/patch.rs @@ -31,14 +31,112 @@ pub(crate) enum EdgeKind { Route, } -/// Traversal rule. `Restricted` names a route whose traversal requires authority -/// over its destination; the `Authority` component does not exist yet, so a -/// `Restricted` route currently admits no one. -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "snake_case")] +/// Traversal rule. A `Restricted` route names the authority kind that opens it: +/// the door names its own key, and the kernel reads neither the door nor the key +/// beyond comparing the name. [`route_admits`] is the sole statement of who gets +/// through, reached identically by `resolve_patch`'s `Relocate` arm and by +/// `Precondition::Reachable`'s edge admission. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(tag = "access", rename_all = "snake_case")] pub(crate) enum AccessKind { Public, - Restricted, + Restricted { requires: AuthorityKindName }, +} + +/// A world-declared authority name: canonical text, `[a-z][a-z0-9_]{0,47}`. The +/// kernel carries it, compares it for equality, and reads it no other way. A +/// closed enum here would make "conscript" or "audit" a kernel change and put a +/// world's political vocabulary in the reducer. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(transparent)] +pub(crate) struct AuthorityKindName(pub(crate) String); + +/// What an authority covers. Distinct from `DecisionScope`, which says *whose* +/// authority it is and stays one field. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(tag = "over", content = "id", rename_all = "snake_case")] +pub(crate) enum AuthorityTarget { + Subject(SubjectId), + /// That place and everything contained in it, by `EntityRecord.container`. + PlaceSubtree(EntityId), +} + +impl AuthorityTarget { + /// The referent this target names, so "does grant A cover grant B's ground" + /// is the one covering predicate rather than a second one. + pub(super) fn as_referent(self) -> super::Target { + match self { + Self::Subject(subject_id) => super::Target::Subject(subject_id), + Self::PlaceSubtree(entity_id) => super::Target::Entity(entity_id), + } + } +} + +/// The proposal-time twin of [`AuthorityTarget`], carrying references. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(tag = "over", content = "ref", rename_all = "snake_case")] +pub(crate) enum AuthorityTargetRef { + Subject(Ref), + PlaceSubtree(Ref), +} + +/// One jurisdiction: a kind and the ground it runs over. Ordered `(kind, over)` +/// so a subject's grant set has one canonical order. A jurisdiction is a set of +/// these, so revoking one place is not a restatement of the whole scope. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub(crate) struct AuthorityGrant { + pub(crate) kind: AuthorityKindName, + pub(crate) over: AuthorityTarget, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub(crate) struct AuthorityGrantRef { + pub(crate) kind: AuthorityKindName, + pub(crate) over: AuthorityTargetRef, +} + +/// Canonical text, scoped to its institution: the pair (institution, name) is +/// the identity, the same discipline a `Role` gets inside one affordance. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(transparent)] +pub(crate) struct OfficeName(pub(crate) String); + +/// A seat inside one institution. It carries no term and no selection method: +/// there is no clock, so nothing can expire, and a field nothing reads is a +/// decoration the next pass has to delete. `delegated` is the field that earns +/// its keep — `scope_components` reads it, `Authorized` resolves through it, and +/// the scope digest binds it. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Office { + /// A person subject, or vacant. A vacancy is an ordinary state. + pub(crate) incumbent: Option, + /// Which of the institution's authority kinds this office lends its + /// incumbent. Non-empty: an office lending nothing is inert. + pub(crate) delegated: BTreeSet, +} + +/// World-declared canonical text, kernel-opaque, `[a-z][a-z0-9_]{0,47}`. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(transparent)] +pub(crate) struct GrievanceKindName(pub(crate) String); + +/// Where one kind of grievance goes, and who may bring it. One forum per +/// grievance kind, world-wide; per-jurisdiction forums would key this on +/// `(GrievanceKindName, AuthorityTarget)`, and that key has no consumer yet. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Forum { + /// Any subject. A one-person magistrate is a legitimate forum, so the + /// kernel branches on `SubjectKind` here as it does everywhere else: not at + /// all. + pub(crate) forum: SubjectId, + /// Who may bring this grievance. It reuses `AuthorityTarget`'s type and its + /// covering predicate and is emphatically not stored in the `authority` + /// partition, so `RevokeAuthority` cannot strip standing. + pub(crate) standing: AuthorityTarget, } /// Whole minutes: the kernel's only time unit, so route cost adds and compares @@ -162,29 +260,42 @@ pub(crate) struct RoleSpec { } /// What must already be true of committed state before an invocation is -/// admitted. Only the three whose components exist: `Authorized`, `Knows`, -/// `CanReach`, and `Committed` land in the pass that adds `Authority`, -/// `Knowledge`, `Channel`, and `Commitment`. A variant whose only behaviour -/// would be to be refused is a placeholder wearing a check. +/// admitted. Only the ones whose components exist: `Knows`, `CanReach`, and +/// `Committed` land in the pass that adds `Knowledge`, `Channel`, and +/// `Commitment`. A variant whose only behaviour would be to be refused is a +/// placeholder wearing a check. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "precondition", rename_all = "snake_case")] pub(crate) enum Precondition { /// The acting subject's Position names the place bound to `at`. Present { at: Role }, /// A path exists from the actor's place to the place bound to `to`, over - /// open public routes, with summed cost at most `within`. + /// open routes this subject may traverse, with summed cost at most `within`. Reachable { to: Role, within: Cost }, /// The acting subject's own holding of the resource bound to `resource` is /// at least `at_least`. Holds { resource: Role, at_least: Quantity }, + /// The acting subject's effective authority — its own grants plus what + /// every office it occupies lends — holds a grant of `kind` whose target + /// covers the referent bound to `over`. + Authorized { over: Role, kind: AuthorityKindName }, + /// A forum takes `grievance` and the acting subject is inside its standing. + HasStanding { grievance: GrievanceKindName }, } -/// Exactly the operations an affordance may propose. Nine, not the resolver's -/// ten: `Admit` is absent because minting quantity requires an `EvidenceRef` and -/// an invocation carries no evidence list, so admitting it here would be a -/// second creation path beside the single evidenced one. -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "snake_case")] +/// Exactly the operations an affordance may propose. `Admit` is absent because +/// minting quantity requires an `EvidenceRef` and an invocation carries no +/// evidence list, so admitting it here would be a second creation path beside +/// the single evidenced one. `OpenOffice`, `CloseOffice`, `OpenForum`, and +/// `CloseForum` are absent because constituting an office or a forum has no +/// live in-play reader an affordance would serve; they stay patch-lane only. +/// +/// The four civic variants carry their payload on the variant rather than on +/// the slot or the invocation: an authority kind and an office name are not +/// referents, so they cannot be roles, and a proposer's only degree of freedom +/// stays magnitude. The world fixes the kind when it authors the entry. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(tag = "op", rename_all = "snake_case")] pub(crate) enum ComponentOpKind { Relocate, OpenRoute, @@ -195,6 +306,10 @@ pub(crate) enum ComponentOpKind { Consume, Bind, Release, + GrantAuthority { kind: AuthorityKindName }, + RevokeAuthority { kind: AuthorityKindName }, + InstallIncumbent { office: OfficeName }, + VacateOffice { office: OfficeName }, } /// The referent shape of one operation: the single source of both the @@ -202,6 +317,7 @@ pub(crate) enum ComponentOpKind { pub(super) enum RoleKindRule { Exact(RefKind), AnyDependencyTarget, + AnyAuthorityTarget, } /// Which magnitude, if any, an operation carries. @@ -213,27 +329,39 @@ pub(super) enum BoundsDimension { } impl ComponentOpKind { - pub(super) fn arity(self) -> Vec { - let subject = RoleKindRule::Exact(ANY_SUBJECT); + pub(super) fn arity(&self) -> Vec { + let subject = || RoleKindRule::Exact(ANY_SUBJECT); let route = || RoleKindRule::Exact(ROUTE); let resource = || RoleKindRule::Exact(RefKind::Entity(EntityKind::Resource)); match self { - Self::Relocate => vec![subject, route()], + Self::Relocate => vec![subject(), route()], Self::OpenRoute | Self::CloseRoute | Self::AlterCost => vec![route()], - Self::Transfer => vec![subject, RoleKindRule::Exact(ANY_SUBJECT), resource()], - Self::Transform => vec![subject, resource(), resource()], - Self::Consume => vec![subject, resource()], - Self::Bind | Self::Release => vec![subject, RoleKindRule::AnyDependencyTarget], + Self::Transfer => vec![subject(), subject(), resource()], + Self::Transform => vec![subject(), resource(), resource()], + Self::Consume => vec![subject(), resource()], + Self::Bind | Self::Release => vec![subject(), RoleKindRule::AnyDependencyTarget], + Self::GrantAuthority { .. } | Self::RevokeAuthority { .. } => { + vec![subject(), RoleKindRule::AnyAuthorityTarget] + } + Self::InstallIncumbent { .. } => vec![subject(), subject()], + Self::VacateOffice { .. } => vec![subject()], } } - pub(super) fn dimension(self) -> BoundsDimension { + pub(super) fn dimension(&self) -> BoundsDimension { match self { - Self::Relocate | Self::OpenRoute | Self::CloseRoute | Self::Bind | Self::Release => { - BoundsDimension::None - } Self::Transfer | Self::Transform | Self::Consume => BoundsDimension::Quantity, Self::AlterCost => BoundsDimension::Cost, + _ => BoundsDimension::None, + } + } + + /// The civic names this variant carries, so one validator checks them all. + fn payload_names(&self) -> Vec<&str> { + match self { + Self::GrantAuthority { kind } | Self::RevokeAuthority { kind } => vec![&kind.0], + Self::InstallIncumbent { office } | Self::VacateOffice { office } => vec![&office.0], + _ => Vec::new(), } } } @@ -247,6 +375,9 @@ pub(super) fn role_kind_fits(rule: &RoleKindRule, declared: RefKind) -> bool { declared, RefKind::Subject(_) | RefKind::Entity(EntityKind::Resource) | RefKind::Edge(_) ), + RoleKindRule::AnyAuthorityTarget => { + matches!(declared, RefKind::Subject(_) | PLACE) + } } } @@ -438,6 +569,45 @@ pub(crate) enum ComponentOp { subject: Ref, target: DependencyRef, }, + /// Adds one grant. An identical grant is `NoOperationEffect`; one that + /// overlaps another the holder already has is `OverlappingJurisdiction`. + GrantAuthority { + holder: Ref, + grant: AuthorityGrantRef, + }, + RevokeAuthority { + holder: Ref, + grant: AuthorityGrantRef, + }, + /// Creates or reconstitutes an office, preserving any sitting incumbent. + /// Clipping an office's powers under a sitting incumbent is a political + /// act, and this is how it is written. + OpenOffice { + institution: Ref, + office: OfficeName, + delegated: BTreeSet, + }, + CloseOffice { + institution: Ref, + office: OfficeName, + }, + InstallIncumbent { + institution: Ref, + office: OfficeName, + incumbent: Ref, + }, + VacateOffice { + institution: Ref, + office: OfficeName, + }, + OpenForum { + grievance: GrievanceKindName, + forum: Ref, + standing: AuthorityTargetRef, + }, + CloseForum { + grievance: GrievanceKindName, + }, } /// Empty on purpose: an `Option` can only be `None`, so the @@ -608,6 +778,51 @@ pub(crate) enum Mismatch { InertAffordance { handle: DraftHandle, }, + /// An `OfficeName`, `AuthorityKindName`, or `GrievanceKindName` that is not + /// `[a-z][a-z0-9_]{0,47}`. + InvalidCivicName { + site: Site, + }, + /// `OpenOffice` lending no authority kind. + EmptyDelegation { + operation: usize, + }, + /// The institution named by an office operation is not + /// `SubjectKind::Institution`. + OfficeOnNonInstitution { + operation: usize, + }, + /// An incumbent that is not `SubjectKind::Person`. This is the check that + /// keeps an institution's operational organ and its person-shaped voice two + /// subjects joined by an office. + OfficeHolderNotPerson { + operation: usize, + }, + /// This person already occupies another office of this institution. + DuplicateIncumbency { + operation: usize, + }, + /// `InstallIncumbent`, `VacateOffice`, or `CloseOffice` naming an office the + /// institution does not have. + UnknownOffice { + operation: usize, + }, + /// `CloseForum` naming a grievance no forum takes. + UnknownForum { + operation: usize, + }, + /// One subject would hold two grants of the same kind over overlapping + /// targets, from any combination of direct grant and office delegation. + /// Overlap between *different* subjects is layered government and legal: + /// `Authorized` is a permission predicate, not an exclusivity claim, and + /// this rule exists so nothing ever has to arbitrate between two sources. + OverlappingJurisdiction { + operation: usize, + }, + /// A catalog entry declares a role named `actor`, which the kernel binds. + ReservedRole { + handle: DraftHandle, + }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -652,9 +867,9 @@ impl EdgeRecord { } } - pub(super) fn access(&self) -> AccessKind { + pub(super) fn access(&self) -> &AccessKind { match self { - Self::Route { access, .. } => *access, + Self::Route { access, .. } => access, } } @@ -762,6 +977,40 @@ pub(crate) enum ResolvedOp { subject: SubjectId, target: DependencyTarget, }, + GrantAuthority { + holder: SubjectId, + grant: AuthorityGrant, + }, + RevokeAuthority { + holder: SubjectId, + grant: AuthorityGrant, + }, + OpenOffice { + institution: SubjectId, + office: OfficeName, + delegated: BTreeSet, + }, + CloseOffice { + institution: SubjectId, + office: OfficeName, + }, + InstallIncumbent { + institution: SubjectId, + office: OfficeName, + incumbent: SubjectId, + }, + VacateOffice { + institution: SubjectId, + office: OfficeName, + }, + OpenForum { + grievance: GrievanceKindName, + forum: SubjectId, + standing: AuthorityTarget, + }, + CloseForum { + grievance: GrievanceKindName, + }, } /// One resource's movement across a whole patch. Accumulators are `u128` so an @@ -858,6 +1107,110 @@ enum TargetKey { Subject(Key), } +/// [`AuthorityTarget`] over candidate keys, so a grant over a place this patch +/// declares is checked before any ID is minted. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum AuthorityTargetKey { + Subject(Key), + PlaceSubtree(Key), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct GrantKey { + kind: AuthorityKindName, + over: AuthorityTargetKey, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct OfficeCandidate { + incumbent: Option>, + delegated: BTreeSet, +} + +/// Whether two jurisdictions of one kind cover common ground. Structural on +/// purpose: identity for named subordinates, containment for territory, and +/// never across the two, so the answer cannot decay when a subject walks +/// somewhere. Containment is fixed at declaration, so every way of creating an +/// overlap is a checked operation. +fn candidate_targets_overlap( + left: &AuthorityTargetKey, + right: &AuthorityTargetKey, + containers: &BTreeMap, Key>, +) -> bool { + match (left, right) { + (AuthorityTargetKey::Subject(one), AuthorityTargetKey::Subject(other)) => one == other, + (AuthorityTargetKey::PlaceSubtree(one), AuthorityTargetKey::PlaceSubtree(other)) => { + key_covers_place(one, other, containers) || key_covers_place(other, one, containers) + } + _ => false, + } +} + +fn target_key_of(target: AuthorityTarget) -> AuthorityTargetKey { + match target { + AuthorityTarget::Subject(subject_id) => { + AuthorityTargetKey::Subject(Key::Existing(subject_id)) + } + AuthorityTarget::PlaceSubtree(entity_id) => { + AuthorityTargetKey::PlaceSubtree(Key::Existing(entity_id)) + } + } +} + +fn grant_key_of(grant: &AuthorityGrant) -> GrantKey { + GrantKey { + kind: grant.kind.clone(), + over: target_key_of(grant.over), + } +} + +/// The candidate authority of one subject, projected to the canonical referents +/// [`covers`](super::covers) reads. A grant naming structure this patch declares +/// cannot cover a place an already-canonical route reaches, so the projection +/// loses nothing the `Restricted` rule asks about. +fn canonical_grants(grants: &BTreeSet) -> BTreeSet { + grants + .iter() + .filter_map(|grant| { + let over = match &grant.over { + AuthorityTargetKey::Subject(Key::Existing(subject_id)) => { + AuthorityTarget::Subject(*subject_id) + } + AuthorityTargetKey::PlaceSubtree(Key::Existing(entity_id)) => { + AuthorityTarget::PlaceSubtree(*entity_id) + } + _ => return None, + }; + Some(AuthorityGrant { + kind: grant.kind.clone(), + over, + }) + }) + .collect() +} + +/// Own grants plus what every held office lends, over the candidate graph. The +/// canonical twin is `scope_components` plus `effective_authority`; this one +/// answers the same question before any ID is minted. +fn candidate_effective_authority( + holder: &Key, + authority: &BTreeMap, BTreeSet>, + selection: &BTreeMap<(Key, OfficeName), OfficeCandidate>, +) -> BTreeSet { + let mut effective = authority.get(holder).cloned().unwrap_or_default(); + for ((institution, _), office) in selection { + if office.incumbent.as_ref() != Some(holder) { + continue; + } + for grant in authority.get(institution).into_iter().flatten() { + if office.delegated.contains(&grant.kind) { + effective.insert(grant.clone()); + } + } + } + effective +} + #[derive(Clone, Debug)] struct RouteCandidate { from: Key, @@ -900,6 +1253,19 @@ pub(super) fn is_valid_cost(cost: Cost) -> bool { (1..=MAX_ROUTE_COST).contains(&cost.0) } +/// The one role name the kernel owns. A catalog entry may not declare it and an +/// invocation may not bind it: stage 2 binds it to the acting subject, so a slot +/// that must land on the actor — the payee of a levy, say — says so instead of +/// letting the proposer point it at a friend. +pub(super) const ACTOR_ROLE: &str = "actor"; + +/// Authority kinds, office names, and grievance kinds share the affordance +/// alphabet: world-declared, kernel-opaque, and safe to surface in a generated +/// tool description. +pub(super) fn is_civic_name(value: &str) -> bool { + is_tool_name(value, 48) +} + /// `[a-z][a-z0-9_]{0,max-1}`. Affordance kinds and roles become generated tool /// and parameter names, so the alphabet is the safe one and the bound is stated /// rather than assumed. @@ -1026,6 +1392,11 @@ fn validate_affordance( } let mut declared: BTreeMap = BTreeMap::new(); for spec in roles { + if spec.role.0 == ACTOR_ROLE { + mismatches.push(Mismatch::ReservedRole { + handle: handle.clone(), + }); + } match declared.entry(spec.role.clone()) { Entry::Vacant(slot) => { slot.insert(spec.kind); @@ -1044,6 +1415,9 @@ fn validate_affordance( }); } } + // The kernel binds `actor` to the acting subject, so preconditions and + // slots may name it like any role. + declared.insert(Role(ACTOR_ROLE.into()), ANY_SUBJECT); let require = |role: &Role, expected: RefKind, mismatches: &mut Vec| match declared.get(role) { None => mismatches.push(Mismatch::UnknownRole { @@ -1070,10 +1444,43 @@ fn validate_affordance( Precondition::Holds { resource, .. } => { require(resource, RefKind::Entity(EntityKind::Resource), mismatches); } + Precondition::Authorized { over, kind } => { + // Jurisdiction runs over subjects, places, and routes. A + // resource, fact, or channel is covered by nothing, so a role + // of that kind is refused at declaration rather than always + // failing at invocation. + match declared.get(over) { + None => mismatches.push(Mismatch::UnknownRole { + handle: handle.clone(), + role: over.clone(), + }), + Some(RefKind::Subject(_)) | Some(&PLACE) | Some(&ROUTE) => {} + Some(_) => mismatches.push(Mismatch::RoleKindUnfit { + handle: handle.clone(), + role: over.clone(), + }), + } + if !is_civic_name(&kind.0) { + mismatches.push(Mismatch::InvalidCivicName { site: site() }); + } + } + Precondition::HasStanding { grievance } => { + if !is_civic_name(&grievance.0) { + mismatches.push(Mismatch::InvalidCivicName { site: site() }); + } + } } } for (index, slot) in effect_slots.iter().enumerate() { let arity = slot.op_kind.arity(); + if slot + .op_kind + .payload_names() + .iter() + .any(|name| !is_civic_name(name)) + { + mismatches.push(Mismatch::InvalidCivicName { site: site() }); + } if slot.roles.len() != arity.len() { mismatches.push(Mismatch::SlotRoleArity { handle: handle.clone(), @@ -1302,6 +1709,108 @@ fn resolve_dependency_target( } } +/// Whether any subject in the candidate graph holds two grants of one kind over +/// overlapping ground, from any combination of direct grant and office +/// delegation. +fn graph_overlaps( + authority: &BTreeMap, BTreeSet>, + selection: &BTreeMap<(Key, OfficeName), OfficeCandidate>, + containers: &BTreeMap, Key>, +) -> bool { + let holders: BTreeSet<&Key> = authority + .keys() + .chain( + selection + .values() + .filter_map(|office| office.incumbent.as_ref()), + ) + .collect(); + holders.into_iter().any(|holder| { + let effective: Vec = candidate_effective_authority(holder, authority, selection) + .into_iter() + .collect(); + effective.iter().enumerate().any(|(index, one)| { + effective[index + 1..].iter().any(|other| { + one.kind == other.kind + && candidate_targets_overlap(&one.over, &other.over, containers) + }) + }) + }) +} + +/// The candidate kind of a subject reference: the declared kind for a handle +/// this patch introduces, the committed kind otherwise. +fn subject_kind_of( + key: &Key, + index: &BTreeMap, + state: &super::WorldState, +) -> Option { + match key { + Key::Existing(subject_id) => state.subjects.get(subject_id).map(|subject| subject.kind), + Key::Draft(handle) => match index.get(handle) { + Some(RefKind::Subject(kind)) => *kind, + _ => None, + }, + } +} + +/// The institution half of every office operation: one subject reference, one +/// canonical office name, and the kind check that keeps an institution's +/// operational organ and its person-shaped voice two subjects. +fn resolve_office_institution( + position: usize, + institution: &Ref, + office: &OfficeName, + index: &BTreeMap, + state: &super::WorldState, + mismatches: &mut Vec, +) -> Option> { + let named = is_civic_name(&office.0); + if !named { + mismatches.push(Mismatch::InvalidCivicName { + site: Site::Operation(position), + }); + } + let key = resolve_subject( + Site::Operation(position), + institution, + index, + &state.subjects, + mismatches, + )?; + if subject_kind_of(&key, index, state) != Some(SubjectKind::Institution) { + mismatches.push(Mismatch::OfficeOnNonInstitution { + operation: position, + }); + return None; + } + named.then_some(key) +} + +fn resolve_authority_target( + site: Site, + target: &AuthorityTargetRef, + index: &BTreeMap, + state: &super::WorldState, + mismatches: &mut Vec, +) -> Option { + match target { + AuthorityTargetRef::Subject(reference) => { + resolve_subject(site, reference, index, &state.subjects, mismatches) + .map(AuthorityTargetKey::Subject) + } + AuthorityTargetRef::PlaceSubtree(reference) => resolve_entity( + site, + EntityKind::Place, + reference, + index, + &state.entities, + mismatches, + ) + .map(AuthorityTargetKey::PlaceSubtree), + } +} + /// What a holder holds in the candidate map. Absence is zero, at both levels. fn candidate_held( holdings: &BTreeMap<(Key, Key), Quantity>, @@ -1455,7 +1964,7 @@ pub(super) fn resolve_patch( RouteCandidate { from: Key::Existing(from), to: Key::Existing(to), - access: record.access(), + access: record.access().clone(), cost: record.cost(), open: record.is_open(), }, @@ -1496,6 +2005,41 @@ pub(super) fn resolve_patch( }) }) .collect(); + let mut authority: BTreeMap, BTreeSet> = state + .authority + .iter() + .map(|(subject_id, grants)| { + ( + Key::Existing(*subject_id), + grants.iter().map(grant_key_of).collect(), + ) + }) + .collect(); + let mut selection: BTreeMap<(Key, OfficeName), OfficeCandidate> = state + .selection + .iter() + .flat_map(|(institution, offices)| { + offices.iter().map(move |(name, office)| { + ( + (Key::Existing(*institution), name.clone()), + OfficeCandidate { + incumbent: office.incumbent.map(Key::Existing), + delegated: office.delegated.clone(), + }, + ) + }) + }) + .collect(); + let mut redress: BTreeMap, AuthorityTargetKey)> = state + .redress + .iter() + .map(|(grievance, forum)| { + ( + grievance.clone(), + (Key::Existing(forum.forum), target_key_of(forum.standing)), + ) + }) + .collect(); let mut deltas: BTreeMap, LedgerDelta> = BTreeMap::new(); let mut declared_places: Vec<(DraftHandle, Key)> = Vec::new(); @@ -1583,7 +2127,7 @@ pub(super) fn resolve_patch( RouteCandidate { from, to, - access: route.access, + access: route.access.clone(), cost: route.cost, open: true, }, @@ -1649,7 +2193,26 @@ pub(super) fn resolve_patch( }); admitted = false; } - if route.access != AccessKind::Public { + // The resolver reads authority for the subject being moved, not + // for whoever proposed the patch, which is what keeps it + // actor-blind: the owner-admitted lane and the action lane get + // the same answer for the same move. A destination this patch + // declares is canonically unknown, so only a `Public` route + // reaches it. + let opened = match &route.to { + Key::Existing(destination) => route_admits( + state, + &canonical_grants(&candidate_effective_authority( + &subject_key, + &authority, + &selection, + )), + &route.access, + *destination, + ), + Key::Draft(_) => route.access == AccessKind::Public, + }; + if !opened { mismatches.push(Mismatch::RouteAccessRestricted { operation: position, }); @@ -1972,6 +2535,269 @@ pub(super) fn resolve_patch( dependencies.remove(&slot); } } + ComponentOp::GrantAuthority { holder, grant } + | ComponentOp::RevokeAuthority { holder, grant } => { + let granting = matches!(operation, ComponentOp::GrantAuthority { .. }); + let holder_key = resolve_subject( + Site::Operation(position), + holder, + &index, + &state.subjects, + &mut mismatches, + ); + if !is_civic_name(&grant.kind.0) { + mismatches.push(Mismatch::InvalidCivicName { + site: Site::Operation(position), + }); + } + let over = resolve_authority_target( + Site::Operation(position), + &grant.over, + &index, + state, + &mut mismatches, + ); + let (Some(holder_key), Some(over)) = (holder_key, over) else { + continue; + }; + let entry = GrantKey { + kind: grant.kind.clone(), + over, + }; + let held = authority + .get(&holder_key) + .is_some_and(|grants| grants.contains(&entry)); + if held == granting { + mismatches.push(Mismatch::NoOperationEffect { + operation: position, + }); + } else if granting { + authority.entry(holder_key).or_default().insert(entry); + } else { + let empty = if let Some(grants) = authority.get_mut(&holder_key) { + grants.remove(&entry); + grants.is_empty() + } else { + false + }; + if empty { + authority.remove(&holder_key); + } + } + } + ComponentOp::OpenOffice { + institution, + office, + delegated, + } => { + let institution_key = resolve_office_institution( + position, + institution, + office, + &index, + state, + &mut mismatches, + ); + if delegated.is_empty() { + mismatches.push(Mismatch::EmptyDelegation { + operation: position, + }); + } + if delegated.iter().any(|kind| !is_civic_name(&kind.0)) { + mismatches.push(Mismatch::InvalidCivicName { + site: Site::Operation(position), + }); + } + let Some(institution_key) = institution_key else { + continue; + }; + if delegated.is_empty() { + continue; + } + let slot = (institution_key, office.clone()); + let incumbent = selection + .get(&slot) + .and_then(|current| current.incumbent.clone()); + if selection + .get(&slot) + .is_some_and(|current| ¤t.delegated == delegated) + { + mismatches.push(Mismatch::NoOperationEffect { + operation: position, + }); + } else { + selection.insert( + slot, + OfficeCandidate { + incumbent, + delegated: delegated.clone(), + }, + ); + } + } + ComponentOp::CloseOffice { + institution, + office, + } + | ComponentOp::VacateOffice { + institution, + office, + } => { + let closing = matches!(operation, ComponentOp::CloseOffice { .. }); + let Some(institution_key) = resolve_office_institution( + position, + institution, + office, + &index, + state, + &mut mismatches, + ) else { + continue; + }; + let slot = (institution_key, office.clone()); + let Some(current) = selection.get(&slot).cloned() else { + mismatches.push(Mismatch::UnknownOffice { + operation: position, + }); + continue; + }; + if closing { + selection.remove(&slot); + } else if current.incumbent.is_none() { + mismatches.push(Mismatch::NoOperationEffect { + operation: position, + }); + } else { + selection.insert( + slot, + OfficeCandidate { + incumbent: None, + ..current + }, + ); + } + } + ComponentOp::InstallIncumbent { + institution, + office, + incumbent, + } => { + let institution_key = resolve_office_institution( + position, + institution, + office, + &index, + state, + &mut mismatches, + ); + let incumbent_key = resolve_subject( + Site::Operation(position), + incumbent, + &index, + &state.subjects, + &mut mismatches, + ); + if let Some(incumbent_key) = &incumbent_key + && subject_kind_of(incumbent_key, &index, state) != Some(SubjectKind::Person) + { + mismatches.push(Mismatch::OfficeHolderNotPerson { + operation: position, + }); + continue; + } + let (Some(institution_key), Some(incumbent_key)) = (institution_key, incumbent_key) + else { + continue; + }; + let slot = (institution_key.clone(), office.clone()); + let Some(current) = selection.get(&slot).cloned() else { + mismatches.push(Mismatch::UnknownOffice { + operation: position, + }); + continue; + }; + if selection.iter().any(|((held_by, name), other)| { + *held_by == institution_key + && name != office + && other.incumbent.as_ref() == Some(&incumbent_key) + }) { + mismatches.push(Mismatch::DuplicateIncumbency { + operation: position, + }); + continue; + } + if current.incumbent.as_ref() == Some(&incumbent_key) { + mismatches.push(Mismatch::NoOperationEffect { + operation: position, + }); + } else { + selection.insert( + slot, + OfficeCandidate { + incumbent: Some(incumbent_key), + ..current + }, + ); + } + } + ComponentOp::OpenForum { + grievance, + forum, + standing, + } => { + if !is_civic_name(&grievance.0) { + mismatches.push(Mismatch::InvalidCivicName { + site: Site::Operation(position), + }); + } + let forum_key = resolve_subject( + Site::Operation(position), + forum, + &index, + &state.subjects, + &mut mismatches, + ); + let standing_key = resolve_authority_target( + Site::Operation(position), + standing, + &index, + state, + &mut mismatches, + ); + let (Some(forum_key), Some(standing_key)) = (forum_key, standing_key) else { + continue; + }; + if redress.get(grievance) == Some(&(forum_key.clone(), standing_key.clone())) { + mismatches.push(Mismatch::NoOperationEffect { + operation: position, + }); + } else { + redress.insert(grievance.clone(), (forum_key, standing_key)); + } + } + ComponentOp::CloseForum { grievance } => { + if redress.remove(grievance).is_none() { + mismatches.push(Mismatch::UnknownForum { + operation: position, + }); + } + } + } + // Disjointness, checked against the complete candidate graph after each + // operation that could widen someone's jurisdiction, so two operations + // in one patch collide with each other and the failure names the one + // that created the collision. Only three operations can: a new grant, a + // widened office, and a new incumbency. + if matches!( + operation, + ComponentOp::GrantAuthority { .. } + | ComponentOp::OpenOffice { .. } + | ComponentOp::InstallIncumbent { .. } + ) && graph_overlaps(&authority, &selection, &containers) + { + mismatches.push(Mismatch::OverlappingJurisdiction { + operation: position, + }); } } @@ -2101,7 +2927,7 @@ pub(super) fn resolve_patch( label: route.label.clone(), from: entity_id_of(&key_of(&route.from)), to: entity_id_of(&key_of(&route.to)), - access: route.access, + access: route.access.clone(), cost: route.cost, open: true, }, @@ -2258,6 +3084,22 @@ pub(super) fn resolve_patch( } } }; + let authority_target_of = |target: &AuthorityTargetRef| -> AuthorityTarget { + match target { + AuthorityTargetRef::Subject(reference) => { + AuthorityTarget::Subject(subject_id_of(&key_of(reference))) + } + AuthorityTargetRef::PlaceSubtree(reference) => { + AuthorityTarget::PlaceSubtree(entity_id_of(&key_of(reference))) + } + } + }; + let grant_of = |grant: &AuthorityGrantRef| -> AuthorityGrant { + AuthorityGrant { + kind: grant.kind.clone(), + over: authority_target_of(&grant.over), + } + }; let operations = patch .operations .iter() @@ -2326,6 +3168,58 @@ pub(super) fn resolve_patch( subject: subject_id_of(&key_of(subject)), target: target_of(target), }, + ComponentOp::GrantAuthority { holder, grant } => ResolvedOp::GrantAuthority { + holder: subject_id_of(&key_of(holder)), + grant: grant_of(grant), + }, + ComponentOp::RevokeAuthority { holder, grant } => ResolvedOp::RevokeAuthority { + holder: subject_id_of(&key_of(holder)), + grant: grant_of(grant), + }, + ComponentOp::OpenOffice { + institution, + office, + delegated, + } => ResolvedOp::OpenOffice { + institution: subject_id_of(&key_of(institution)), + office: office.clone(), + delegated: delegated.clone(), + }, + ComponentOp::CloseOffice { + institution, + office, + } => ResolvedOp::CloseOffice { + institution: subject_id_of(&key_of(institution)), + office: office.clone(), + }, + ComponentOp::InstallIncumbent { + institution, + office, + incumbent, + } => ResolvedOp::InstallIncumbent { + institution: subject_id_of(&key_of(institution)), + office: office.clone(), + incumbent: subject_id_of(&key_of(incumbent)), + }, + ComponentOp::VacateOffice { + institution, + office, + } => ResolvedOp::VacateOffice { + institution: subject_id_of(&key_of(institution)), + office: office.clone(), + }, + ComponentOp::OpenForum { + grievance, + forum, + standing, + } => ResolvedOp::OpenForum { + grievance: grievance.clone(), + forum: subject_id_of(&key_of(forum)), + standing: authority_target_of(standing), + }, + ComponentOp::CloseForum { grievance } => ResolvedOp::CloseForum { + grievance: grievance.clone(), + }, }) .collect(); @@ -2344,24 +3238,95 @@ pub(super) fn resolve_patch( }) } -/// Whether a place reaches itself through its container chain. The walk is -/// bounded by the graph size, so a cycle that does not include the start still -/// terminates. -fn contains_itself( - start: &Key, +/// Whether `place` is `root`, or reaches it through the candidate container +/// chain. The walk is bounded by the graph size, so a cycle that does not +/// include either end still terminates. +fn key_covers_place( + root: &Key, + place: &Key, containers: &BTreeMap, Key>, ) -> bool { - let mut current = containers.get(start); + let mut current = Some(place); for _ in 0..=containers.len() { match current { None => return false, - Some(node) if node == start => return true, + Some(node) if node == root => return true, Some(node) => current = containers.get(node), } } false } +/// Whether a place reaches itself through its container chain: the same walk, +/// started one link up so a place is not its own ancestor by definition. +fn contains_itself( + start: &Key, + containers: &BTreeMap, Key>, +) -> bool { + containers + .get(start) + .is_some_and(|parent| key_covers_place(start, parent, containers)) +} + +/// Whether `place` is `root` or is contained in it, over canonical state: one +/// upward walk in the shape of [`containment_terminates`], bounded by the graph +/// size. It is total because `admit_resolved` refuses a non-terminating chain, +/// so the bound is belt-and-braces rather than a repair loop. There is no +/// downward enumeration and no child index: every jurisdictional question this +/// kernel asks is "is this target inside my ground". +pub(super) fn covers_place( + entities: &BTreeMap, + root: EntityId, + place: EntityId, +) -> bool { + let mut current = Some(place); + for _ in 0..=entities.len() { + match current { + None => return false, + Some(node) if node == root => return true, + Some(node) => current = entities.get(&node).and_then(|record| record.container), + } + } + false +} + +/// The canonical twin of the candidate graph's `candidate_targets_overlap`: whether two +/// jurisdictions of one kind cover common ground. +pub(super) fn targets_overlap( + entities: &BTreeMap, + left: AuthorityTarget, + right: AuthorityTarget, +) -> bool { + match (left, right) { + (AuthorityTarget::Subject(one), AuthorityTarget::Subject(other)) => one == other, + (AuthorityTarget::PlaceSubtree(one), AuthorityTarget::PlaceSubtree(other)) => { + covers_place(entities, one, other) || covers_place(entities, other, one) + } + _ => false, + } +} + +/// Whether a subject holding `grants` may traverse a route with this access +/// rule toward `destination`. The sole statement of the `Restricted` rule, +/// called by `resolve_patch`'s `Relocate` arm, by `apply_operation`'s, and by +/// `Precondition::Reachable`'s edge admission — one rule, three callers, no +/// drift. Openness is a separate claim with its own name, so it is not folded +/// in here. +pub(super) fn route_admits( + state: &super::WorldState, + grants: &BTreeSet, + access: &AccessKind, + destination: EntityId, +) -> bool { + match access { + AccessKind::Public => true, + AccessKind::Restricted { requires } => grants.iter().any(|grant| { + &grant.kind == requires + && super::covers(state, grant.over, super::Target::Entity(destination)) + }), + } +} + /// The same containment walk over canonical state, for the admission and replay /// invariants. `admit_resolved` runs it after inserting a place, so a container /// chain that never terminates dies before the partition keeps it.