diff --git a/crates/ghostlight-dungeon/src/world/action.rs b/crates/ghostlight-dungeon/src/world/action.rs index 3837d11..c8f8139 100644 --- a/crates/ghostlight-dungeon/src/world/action.rs +++ b/crates/ghostlight-dungeon/src/world/action.rs @@ -332,15 +332,19 @@ fn check_preconditions( } } -/// 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. +/// Stage 4, beside the ceilings: an authority a slot would grant or revoke +/// must not exceed the actor's own. For a grant this is the monotonicity +/// envelope — no holder mints authority it does not itself hold. For a +/// revocation it is the same envelope read the other way: an actor may not +/// strip ground it holds no jurisdiction over, so revocation is not a lever +/// for reaching past one's own authority. 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. +/// `GrantAuthority`/`RevokeAuthority` 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, @@ -349,8 +353,10 @@ fn check_delegation( rejections: &mut Vec, ) { for (index, slot) in entry.effect_slots.iter().enumerate() { - let ComponentOpKind::GrantAuthority { kind } = &slot.op_kind else { - continue; + let kind = match &slot.op_kind { + ComponentOpKind::GrantAuthority { kind } + | ComponentOpKind::RevokeAuthority { kind } => kind, + _ => continue, }; let Some(over) = slot .roles @@ -712,9 +718,9 @@ mod tests { }; use crate::world::tests::{ 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, + WARDEN_OFFICE, activate, affordance_named, auth_principal, authority_kind, civic_world, + command, custody_world, grant_to, grievance, office, operations, opportunity_for, + over_place, over_subject, player, reject_owner, submit_owner, }; use crate::world::{ AffordanceKindName, AuthenticatedCaller, CallerId, CommandBody, Declaration, EntityKind, @@ -1519,6 +1525,43 @@ mod tests { ) } + /// Commands a subordinate to traverse a route: the fixture's one + /// `Relocate`-lowering affordance, gated by `command` authority over + /// the subordinate rather than by the subordinate's own consent. + fn deploy(&self, subordinate: SubjectId, via: EdgeId) -> DecisionInvocation { + self.call( + "deploy", + vec![ + binding("subordinate", Target::Subject(subordinate)), + binding("via", Target::Edge(via)), + ], + vec![ProposedEffect { + slot: 0, + magnitude: Magnitude::None, + }], + None, + ) + } + + /// Strips `holder`'s `levy` grant over `ground`: the fixture's one + /// `RevokeAuthority`-lowering affordance, carrying no precondition of + /// its own so a rejection can only be the action lane's revocation + /// envelope. + fn revoke(&self, holder: SubjectId, ground: EntityId) -> DecisionInvocation { + self.call( + "revoke", + vec![ + binding("holder", Target::Subject(holder)), + binding("ground", Target::Entity(ground)), + ], + vec![ProposedEffect { + slot: 0, + magnitude: Magnitude::None, + }], + None, + ) + } + fn try_as( &self, actor: SubjectId, @@ -1821,6 +1864,94 @@ mod tests { ); } + /// The revocation envelope is the same predicate as the grant one, read + /// the other way: an actor may strip a `levy` grant only over ground its + /// own authority covers, so a bystander cannot use `revoke` to reach past + /// what it may otherwise touch. + #[test] + fn revoking_a_grant_requires_covering_authority_over_its_ground() { + let directory = tempfile::tempdir().unwrap(); + let mut bench = civics(directory.path(), "RevokeEnvelope"); + let treasury = bench.civic.treasury; + let farmer = bench.civic.farmer; + let hall = bench.civic.hall; + + // The farmer holds no authority at all, so stripping the treasury's + // own `levy` grant over the hall fails the envelope before the patch + // lane ever sees the operation. + assert_eq!( + bench.rejected_as(farmer, &bench.revoke(treasury, hall)), + vec![ActionMismatch::DelegationNotMonotone { slot: 0 }] + ); + + // The treasury's own `levy` grant over the hall covers the hall, so + // the treasury may strip its own grant, and the ledger loses it. + assert!(matches!( + bench.commit_as(treasury, bench.revoke(treasury, hall)), + SubmitReceipt::Applied(_) + )); + assert!( + !bench + .kernel + .state + .authority + .get(&treasury) + .is_some_and(|grants| grants.contains(&crate::world::AuthorityGrant { + kind: authority_kind(LEVY_KIND), + over: crate::world::AuthorityTarget::PlaceSubtree(hall), + })), + "the revoked grant is gone from the ledger" + ); + } + + /// The fixture's one `Relocate`-lowering affordance: a commander may move + /// a subordinate standing under its ground, and one standing outside it + /// fails the same `Authorized` precondition every other civic act reads. + #[test] + fn deploy_relocates_a_subordinate_under_command_and_refuses_one_outside_it() { + let directory = tempfile::tempdir().unwrap(); + let mut bench = civics(directory.path(), "Deploy"); + let treasury = bench.civic.treasury; + let farmer = bench.civic.farmer; + let outsider = bench.civic.outsider; + let chamber = bench.civic.chamber; + let hall = bench.civic.hall; + let passage = bench.civic.passage; + + // The pedlar stands on the road, outside the hall the treasury + // commands, so the precondition alone refuses the attempt. + assert_eq!( + bench.rejected_as(treasury, &bench.deploy(outsider, passage)), + vec![ActionMismatch::NotAuthorized { precondition: 0 }] + ); + + // The farmer stands in the chamber, under the treasury's command, and + // the passage carries it into the hall the same way a direct + // `Relocate` would. + assert_eq!( + bench + .kernel + .state + .positions + .get(&farmer) + .map(|position| position.place), + Some(chamber) + ); + assert!(matches!( + bench.commit_as(treasury, bench.deploy(farmer, passage)), + SubmitReceipt::Applied(_) + )); + assert_eq!( + bench + .kernel + .state + .positions + .get(&farmer) + .map(|position| position.place), + Some(hall) + ); + } + /// `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] @@ -2143,12 +2274,14 @@ mod tests { ); } - /// Structural overlap does not see a `Subject` grant sitting inside a - /// `PlaceSubtree` grant of the same kind, so one subject can hold two - /// sources of one permission and revoking the wider one leaves the act - /// standing through the narrower. Nothing arbitrates between them, so this - /// is a gap in the disjointness rule rather than the precedence hazard the - /// rule was written to prevent. + /// Structural overlap alone does not see a `Subject` grant sitting inside + /// a `PlaceSubtree` grant of the same kind: `targets_overlap` compares + /// shapes, not position, and stays that way so the answer cannot decay + /// when a subject walks somewhere. Admission catches the cross-shape case + /// live instead, in the resolver where the second grant lands, using the + /// same covering predicate `Authorized` reads. Both directions fire the + /// identical check, so whichever grant is minted second is the one that + /// is refused. #[test] fn soul_a_subject_grant_inside_a_place_grant_is_a_second_source() { let directory = tempfile::tempdir().unwrap(); @@ -2158,23 +2291,26 @@ mod tests { let hall = bench.civic.hall; // The farmer stands under the hall the treasury already levies, so the - // covering predicate says both grants answer for the same target. + // covering predicate says both grants would answer for the same + // target, and admission refuses the narrower grant landing second. assert!(crate::world::covers( &bench.kernel.state, crate::world::AuthorityTarget::PlaceSubtree(hall), Target::Subject(farmer) )); - bench.admit(vec![grant_to(treasury, LEVY_KIND, over_subject(farmer))]); + assert_eq!( + bench.refuse(vec![grant_to(treasury, LEVY_KIND, over_subject(farmer))]), + vec![crate::world::Mismatch::OverlappingJurisdiction { operation: 0 }] + ); - // Revoking the wider grant leaves the levy authorized through the - // narrower one. - bench.admit(vec![ComponentOp::RevokeAuthority { - holder: PatchRef::Existing(treasury), - grant: crate::world::tests::grant_of(LEVY_KIND, over_place(hall)), - }]); - assert!( - bench.try_as(treasury, &bench.levy(farmer, 1)).is_ok(), - "the narrower grant still authorizes the act" + // The symmetric order: a direct grant over the farmer, minted first + // under a fresh kind, blocks a later place grant that would cover + // him too. + const AUDIT_KIND: &str = "audit"; + bench.admit(vec![grant_to(treasury, AUDIT_KIND, over_subject(farmer))]); + assert_eq!( + bench.refuse(vec![grant_to(treasury, AUDIT_KIND, over_place(hall))]), + vec![crate::world::Mismatch::OverlappingJurisdiction { operation: 0 }] ); } diff --git a/crates/ghostlight-dungeon/src/world/journal.rs b/crates/ghostlight-dungeon/src/world/journal.rs index 0b2dda2..1d39400 100644 --- a/crates/ghostlight-dungeon/src/world/journal.rs +++ b/crates/ghostlight-dungeon/src/world/journal.rs @@ -1551,18 +1551,20 @@ mod tests { ); } - /// Soul falsification of the structural-overlap decision: disjointness is - /// decided by target shape, not by where anybody is standing, so no - /// `Relocate` out of the jurisdiction can produce a - /// store the shape checks call corrupt. The grant pair used here is exactly - /// the one a covering-predicate overlap rule would have had to reject on - /// arrival and could not have kept rejected afterwards. + /// Soul falsification of the structural-overlap decision, updated for the + /// admission-time covering check: a `Subject` grant naming ground a + /// `PlaceSubtree` grant already covers is refused on arrival, but nothing + /// re-derives that check off of live position. Once the farmer walks out + /// from under the hall, the identical grant is unremarkable, and no + /// `Relocate` — in either direction — is ever asked to keep a shape + /// decision current. #[test] fn a_relocation_never_corrupts_a_civic_store() { use crate::world::tests::{ - LEVY_KIND, auth_principal, civic_world, operations, over_subject, owner, submit_owner, + LEVY_KIND, auth_principal, civic_world, operations, over_subject, owner, reject_owner, + submit_owner, }; - use crate::world::{ComponentOp, SubmitReceipt}; + use crate::world::{ComponentOp, Mismatch, SubmitReceipt}; let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("world.cc"); @@ -1586,18 +1588,6 @@ mod tests { "a committed store never holds two overlapping jurisdictions" ); }; - - // The treasury levies the hall the farmer stands in, and now also the - // farmer by name. - admit( - &mut kernel, - vec![crate::world::tests::grant_to( - civic.treasury, - LEVY_KIND, - over_subject(civic.farmer), - )], - ); - // Both grants answer for the same subject, and the store is admitted. let place_covers = |kernel: &WorldKernel| { crate::world::covers( &kernel.state, @@ -1605,7 +1595,24 @@ mod tests { crate::world::Target::Subject(civic.farmer), ) }; - assert!(place_covers(&kernel) && kernel.state.authority[&civic.treasury].len() >= 3); + let name_farmer = || { + vec![crate::world::tests::grant_to( + civic.treasury, + LEVY_KIND, + over_subject(civic.farmer), + )] + }; + + // The treasury's hall grant already covers the farmer standing in the + // chamber underneath it, so naming him directly is a second source + // for the same kind of act, and admission refuses it while he stands + // there. + assert!(place_covers(&kernel)); + let before = kernel.snapshot().unwrap(); + assert_eq!( + reject_owner(&mut kernel, &before, operations(name_farmer())), + vec![Mismatch::OverlappingJurisdiction { operation: 0 }] + ); // Out of the subtree, one route at a time. for edge in [civic.passage, civic.causeway] { @@ -1617,10 +1624,15 @@ mod tests { }], ); } - // The move dissolved the double cover without any operation naming it, - // which is why the disjointness rule cannot be occupancy-sensitive. + // The move dissolved the cover without any operation naming it, which + // is why the disjointness rule cannot be occupancy-sensitive: nothing + // reruns the covering check on a `Relocate`, in or out. assert!(!place_covers(&kernel)); + // Now that he stands outside it, the identical grant is unremarkable. + admit(&mut kernel, name_farmer()); + assert!(kernel.state.authority[&civic.treasury].len() >= 3); + // The whole history replays, and the reopened world is identical. let committed = kernel.snapshot().unwrap(); drop(kernel); diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index 30e8da2..63d33e2 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -3218,9 +3218,9 @@ mod tests { } } - /// 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. + /// Seven 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, @@ -3292,6 +3292,27 @@ mod tests { certain_band(), false, ), + // Isolates the action-lane's revocation envelope: no separate + // precondition, so a rejection here can only be + // `DelegationNotMonotone`. + entry( + "revoke", + "revoke", + vec![ + role("holder", RefKind::Subject(None)), + role("ground", RefKind::Entity(EntityKind::Place)), + ], + Vec::new(), + vec![slot( + ComponentOpKind::RevokeAuthority { + kind: authority_kind(LEVY_KIND), + }, + vec!["holder", "ground"], + Bounds::None, + )], + certain_band(), + false, + ), entry( "deploy", "deploy", @@ -3439,7 +3460,9 @@ mod tests { kind: SubjectKind::Institution, controller: NewController::OperationalAgent, affordances: civic_grants( - &["levy", "delegate", "deploy", "sanction", "appoint"], + &[ + "levy", "delegate", "deploy", "sanction", "appoint", "revoke", + ], &speak, ), position: Some(Ref::Draft(DraftHandle::new("hall"))), @@ -3459,7 +3482,7 @@ mod tests { "farmer", "The Winter Farmer", Ref::Draft(DraftHandle::new("chamber")), - &["levy", "petition"], + &["levy", "petition", "revoke"], ), person( "outsider", diff --git a/crates/ghostlight-dungeon/src/world/patch.rs b/crates/ghostlight-dungeon/src/world/patch.rs index f2dd558..793eb0f 100644 --- a/crates/ghostlight-dungeon/src/world/patch.rs +++ b/crates/ghostlight-dungeon/src/world/patch.rs @@ -1146,6 +1146,38 @@ fn candidate_targets_overlap( } } +/// Whether a candidate `Subject` grant target and a candidate `PlaceSubtree` +/// grant target of one kind name overlapping ground: a `PlaceSubtree` grant +/// covers a `Subject` grant's target when the subject's committed position +/// sits under that subtree. Checked live against `state` rather than the +/// candidate graph, because `covers` reads committed position and +/// containment, not anything a patch declares; a target still in this +/// patch's drafts has no committed position and so cannot overlap here. This +/// is the one cross-shape case `candidate_targets_overlap` leaves alone by +/// construction — same-shape overlap is its job, not this one's. +fn grant_targets_nest( + state: &super::WorldState, + left: &AuthorityTargetKey, + right: &AuthorityTargetKey, +) -> bool { + let place_and_subject = |a: &AuthorityTargetKey, b: &AuthorityTargetKey| match (a, b) { + ( + AuthorityTargetKey::PlaceSubtree(Key::Existing(place)), + AuthorityTargetKey::Subject(Key::Existing(subject_id)), + ) => Some((*place, *subject_id)), + _ => None, + }; + place_and_subject(left, right) + .or_else(|| place_and_subject(right, left)) + .is_some_and(|(place, subject_id)| { + super::covers( + state, + AuthorityTarget::PlaceSubtree(place), + super::Target::Subject(subject_id), + ) + }) +} + fn target_key_of(target: AuthorityTarget) -> AuthorityTargetKey { match target { AuthorityTarget::Subject(subject_id) => { @@ -2564,6 +2596,25 @@ pub(super) fn resolve_patch( kind: grant.kind.clone(), over, }; + // Admission-time only: a `Subject` grant landing inside a + // `PlaceSubtree` grant the holder already has, or the reverse, + // is a second source for the same kind of act. This does not + // reach `targets_overlap`/`verify_state_shape`, which stay + // structural and position-independent; a later `Relocate` + // that creates this shape is not re-derived as a violation. + if granting + && authority.get(&holder_key).is_some_and(|grants| { + grants.iter().any(|other| { + other.kind == entry.kind + && grant_targets_nest(state, &entry.over, &other.over) + }) + }) + { + mismatches.push(Mismatch::OverlappingJurisdiction { + operation: position, + }); + continue; + } let held = authority .get(&holder_key) .is_some_and(|grants| grants.contains(&entry));