From 40e4efcc1850dc989ece91ffdd3c5dc3c365d0f5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 21:08:25 +0200 Subject: [PATCH] Make the affordance grant structural in exercise `action::exercise` read `invocation.affordance` and looked the entry up itself, with the grant check standing beside it in each caller. Pass 5 adds a third caller, so the check becomes the type: `require_granted` now returns a `GrantedAffordance` carrying the verified id and its catalog entry, and `exercise` takes that instead of the raw id. Nothing in the pipeline reads `invocation.affordance`, and no caller can reach it ungated. Co-Authored-By: Claude Fable 5.1 --- crates/ghostlight-dungeon/src/world/action.rs | 48 +++++++++++++------ crates/ghostlight-dungeon/src/world/mod.rs | 36 ++++++++++---- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/crates/ghostlight-dungeon/src/world/action.rs b/crates/ghostlight-dungeon/src/world/action.rs index 0a057cc..64b06a8 100644 --- a/crates/ghostlight-dungeon/src/world/action.rs +++ b/crates/ghostlight-dungeon/src/world/action.rs @@ -12,8 +12,8 @@ use super::patch::{ }; use super::{ AccessKind, AffordanceId, CommandId, DecisionEvent, DecisionInvocation, DecisionOpportunity, - EdgeId, EntityId, EventId, KernelError, Magnitude, SubjectId, Target, WorldId, WorldState, - digest, + EdgeId, EntityId, EventId, GrantedAffordance, KernelError, Magnitude, SubjectId, Target, + WorldId, WorldState, digest, }; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; @@ -74,8 +74,8 @@ pub(crate) enum ActionMismatch { /// The band draw's whole preimage. Five fields, none of them from the /// invocation's payload: the proposer varies the attempt and cannot vary the -/// draw. `affordance` is the kernel-resolved granted entry, checked against the -/// grants before this value is built. +/// draw. `affordance` is the id carried by the kernel-verified grant, so the +/// draw cannot be steered by an affordance the scope does not hold. #[derive(Serialize)] struct BandPreimage { world_id: WorldId, @@ -88,16 +88,18 @@ struct BandPreimage { /// Catalog lookup, binding, admission, draw, lowering, event. Stages 1 through 4 /// accumulate one complete rejection set and return it sorted before anything is /// drawn or lowered, so nothing past the gate can fail on caller input. +/// +/// The entry arrives as a [`GrantedAffordance`], which only `require_granted` +/// can build: the grant check is the type, not a call a third caller might +/// forget. Nothing here reads `invocation.affordance`. pub(super) fn exercise( state: &WorldState, command_id: CommandId, current: &DecisionOpportunity, + granted: &GrantedAffordance<'_>, invocation: &DecisionInvocation, ) -> Result { - let entry = state - .affordance_catalog - .get(&invocation.affordance) - .ok_or_else(|| KernelError::Invariant("granted affordance has no entry".into()))?; + let entry = granted.entry; let actor = current.scope.subject_id; let mut rejections = Vec::new(); @@ -110,7 +112,7 @@ pub(super) fn exercise( return Err(KernelError::ActionRejected(rejections)); } - let band = select_band(state, command_id, invocation.affordance, entry)?; + let band = select_band(state, command_id, granted.id, entry)?; let operations = lower(entry, band, invocation, &bindings)?; let effects = if operations.is_empty() { Vec::new() @@ -597,6 +599,18 @@ mod tests { } } + /// `exercise` only accepts a grant the kernel resolved, so a test walks the + /// same gate `reduce` does rather than reaching into the catalog. + fn draw_once( + state: &WorldState, + command_id: CommandId, + opportunity: &DecisionOpportunity, + invocation: &DecisionInvocation, + ) -> Result { + let granted = crate::world::require_granted(state, opportunity, invocation.affordance)?; + exercise(state, command_id, opportunity, &granted, invocation) + } + fn binding(role: &str, target: Target) -> RoleBinding { RoleBinding { role: Role(role.into()), @@ -636,10 +650,11 @@ mod tests { &self, invocation: &DecisionInvocation, ) -> Result { - exercise( + let opportunity = self.clerk_opportunity(); + draw_once( &self.kernel.state, CommandId::issue(), - &self.clerk_opportunity(), + &opportunity, invocation, ) } @@ -942,7 +957,7 @@ mod tests { let opportunity = bench.clerk_opportunity(); let invocation = bench.variant(&bench.carry(1, bench.yard), "carry_chance"); let draw = |command_id: CommandId, invocation: &DecisionInvocation| { - exercise(&bench.kernel.state, command_id, &opportunity, invocation) + draw_once(&bench.kernel.state, command_id, &opportunity, invocation) .expect("the invocation is admissible") .band }; @@ -1066,7 +1081,7 @@ mod tests { let command_id = (0..256) .map(|_| CommandId::issue()) .find(|candidate| { - exercise(&bench.kernel.state, *candidate, &opportunity, &split) + draw_once(&bench.kernel.state, *candidate, &opportunity, &split) .expect("the invocation is admissible") .band == wanted @@ -1165,7 +1180,10 @@ mod tests { // path the store writes through. let encoded = rmp_serde::to_vec_named(&bench.kernel.state).expect("the state encodes"); let decoded: WorldState = rmp_serde::from_slice(&encoded).expect("the state decodes"); - assert_eq!(decoded.affordance_catalog, bench.kernel.state.affordance_catalog); + assert_eq!( + decoded.affordance_catalog, + bench.kernel.state.affordance_catalog + ); assert!(digest(&bench.kernel.state).is_ok()); } @@ -1175,7 +1193,7 @@ mod tests { let bench = bench(directory.path(), "ForgedAction"); let opportunity = bench.clerk_opportunity(); let command_id = CommandId::issue(); - let honest = exercise( + let honest = draw_once( &bench.kernel.state, command_id, &opportunity, diff --git a/crates/ghostlight-dungeon/src/world/mod.rs b/crates/ghostlight-dungeon/src/world/mod.rs index b5193a4..4a07652 100644 --- a/crates/ghostlight-dungeon/src/world/mod.rs +++ b/crates/ghostlight-dungeon/src/world/mod.rs @@ -1056,8 +1056,8 @@ fn reduce(state: &WorldState, command: &CommandEnvelope) -> Result { + pub(super) id: AffordanceId, + pub(super) entry: &'a Affordance, +} + /// The membership half of the affordance check, shared by `reduce` and -/// `apply_effect`: the opportunity offers the entry and the scope holds it. -fn require_granted( - state: &WorldState, +/// `apply_effect`: the opportunity offers the entry and the scope holds it. It +/// returns the entry rather than a unit, so the gate and the lookup are one act. +fn require_granted<'a>( + state: &'a WorldState, current: &DecisionOpportunity, affordance: AffordanceId, -) -> Result<(), KernelError> { +) -> Result, KernelError> { if current.affordance_ids.contains(&affordance) && state .affordance_grants .get(¤t.scope) .is_some_and(|granted| granted.contains(&affordance)) { - Ok(()) + let entry = state + .affordance_catalog + .get(&affordance) + .ok_or_else(|| KernelError::Invariant("granted affordance has no entry".into()))?; + Ok(GrantedAffordance { + id: affordance, + entry, + }) } else { Err(KernelError::AffordanceDenied) } @@ -2019,11 +2036,12 @@ fn apply_effect( "decision effect does not match exact opportunity authority".into(), )); } - require_granted(state, ¤t, event.invocation.affordance)?; + let granted = require_granted(state, ¤t, event.invocation.affordance)?; // The whole event is re-derived by the function that produced the // honest one, so a forged band, operation, magnitude, or utterance // is one comparison rather than a clause apiece. - let derived = action::exercise(state, command_id, ¤t, &event.invocation)?; + let derived = + action::exercise(state, command_id, ¤t, &granted, &event.invocation)?; if derived != *event { return Err(KernelError::Invariant( "decision effect does not derive from its opportunity".into(),