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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-04 21:08:25 +02:00
commit 40e4efcc18
2 changed files with 60 additions and 24 deletions

View file

@ -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<DecisionEvent, KernelError> {
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<DecisionEvent, KernelError> {
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<DecisionEvent, KernelError> {
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,

View file

@ -1056,8 +1056,8 @@ fn reduce(state: &WorldState, command: &CommandEnvelope) -> Result<WorldEffect,
if assignment.expected_caller() != command.caller {
return Err(KernelError::ControllerMismatch);
}
require_granted(state, &current, invocation.affordance)?;
let event = action::exercise(state, command.id, &current, invocation)?;
let granted = require_granted(state, &current, invocation.affordance)?;
let event = action::exercise(state, command.id, &current, &granted, invocation)?;
Ok(WorldEffect::DecisionExercised {
opportunity: current,
event,
@ -1836,20 +1836,37 @@ fn granted_entries(
.collect()
}
/// One affordance the kernel has already gated: the opportunity offers this
/// entry, the scope holds it, and `entry` is its catalog definition. It is the
/// only way to name an affordance to `action::exercise`, so a caller cannot
/// reach the invocation pipeline without passing the grant check first.
pub(super) struct GrantedAffordance<'a> {
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<GrantedAffordance<'a>, KernelError> {
if current.affordance_ids.contains(&affordance)
&& state
.affordance_grants
.get(&current.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, &current, event.invocation.affordance)?;
let granted = require_granted(state, &current, 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, &current, &event.invocation)?;
let derived =
action::exercise(state, command_id, &current, &granted, &event.invocation)?;
if derived != *event {
return Err(KernelError::Invariant(
"decision effect does not derive from its opportunity".into(),