From 65ee2be41a25a198b892931481035d8d77d2e473 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 22:21:22 +0200 Subject: [PATCH] Soul pass-5 follow-ups: overlap admission, revoke envelope, deploy coverage Adds an admission-time check in GrantAuthority resolution that rejects a Subject grant nested inside a same-kind PlaceSubtree grant the holder already has, and the reverse, using the live covering predicate. This is admission-only: targets_overlap and verify_state_shape stay structural and position-independent, and a later Relocate that creates the same nesting is never re-derived as a violation. Extends the action lane's check_delegation to gate RevokeAuthority slots by the same monotonicity envelope it already applies to GrantAuthority: an actor may not strip authority it does not itself hold. Adds a fixture "revoke" affordance to exercise both the covering and non-covering cases. Adds an end-to-end test for the fixture's "deploy" affordance (Relocate lowering) plus its precondition's negative case. Updates the two existing tests whose assumptions depended on the old gap (a Subject grant silently nesting inside a PlaceSubtree grant) to assert the new rejection instead. Co-Authored-By: Claude Fable 5.1 --- crates/ghostlight-dungeon/src/world/action.rs | 194 +++++++++++++++--- .../ghostlight-dungeon/src/world/journal.rs | 58 +++--- crates/ghostlight-dungeon/src/world/mod.rs | 33 ++- crates/ghostlight-dungeon/src/world/patch.rs | 51 +++++ 4 files changed, 279 insertions(+), 57 deletions(-) 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));