Grammar check has one owner; pin later-revision doors and the speak render path

GrammarBinding::refusal is the single grammar predicate. The resolver maps it
to Mismatch, and admit_resolved (the catalog's only writer, reached by
apply_effect) maps it to an Invariant error, so an effect resolved elsewhere
cannot install an out-of-grammar entry. The speak entry must also be bound as
a Conversation verb (Mismatch::SpeakRenderPathDisagreesWithGrammar).

Tests: owner, play, consumer and elaborator doors refuse kind and role
signature after the revision has moved past genesis; speak bound as ShipAction
is refused at bind; apply_effect refuses a forged effect.

Builds (cargo check --tests, Starfire compile only). Merge of origin/main
8d263bb was mechanical (no conflicts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 16:40:33 +02:00
commit e5ae6fe7af
4 changed files with 246 additions and 11 deletions

View file

@ -8,7 +8,7 @@
//! digest its verbs do not have.
use crate::SubjectKind;
use crate::patch::{EntityKind, RefKind, RoleSpec};
use crate::patch::{AffordanceKindName, EntityKind, RefKind, RoleSpec, kernel_speak_entry};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
@ -72,6 +72,14 @@ impl GrammarVerb {
}
}
/// Why a bound grammar refuses an affordance entry.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GrammarRefusal {
KindNotInGrammar,
RolesDisagree,
SpeakNotConversation,
}
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
pub enum GrammarError {
#[error("grammar schema id is empty")]
@ -162,6 +170,26 @@ impl GrammarBinding {
pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> {
self.verbs.get(kind)
}
/// The one grammar check. The resolver turns a refusal into a mismatch and
/// `admit_resolved`, the only writer of the affordance catalog, into an
/// invariant error, so no path adds an entry without passing it.
pub(crate) fn refusal(
&self,
kind: &AffordanceKindName,
roles: &[RoleSpec],
) -> Option<GrammarRefusal> {
let Some(verb) = self.verb(&kind.0) else {
return Some(GrammarRefusal::KindNotInGrammar);
};
if !verb.admits_roles(roles) {
return Some(GrammarRefusal::RolesDisagree);
}
if *kind == kernel_speak_entry().kind && verb.render_path != RenderPath::Conversation {
return Some(GrammarRefusal::SpeakNotConversation);
}
None
}
}
#[cfg(test)]

View file

@ -514,3 +514,196 @@ fn existing_world_reopens_with_unchanged_digests() {
snapshot.state_digest
);
}
fn submit_as(
kernel: &mut WorldKernel,
caller: CallerId,
answers: Option<crate::PatchAnswer>,
patch: WorldPatch,
) -> Result<SubmitReceipt, KernelError> {
let snapshot = kernel.snapshot().unwrap();
kernel.submit(
command(
&snapshot,
CommandId::new(),
caller.clone(),
CommandBody::AdmitPatch { answers, patch },
),
&crate::AuthenticatedCaller::fixture(caller),
)
}
/// A bound world whose revision has moved past genesis: one owner patch that
/// adds a dead-end place the elaborator can answer, then activation.
fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) {
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
let (dir, kernel) = create(bound(Some(grammar_with(&extra)), "Advanced"));
let mut kernel = kernel.unwrap();
let commons = *kernel.state.entities.keys().next().unwrap();
admit(
&mut kernel,
patch_of(vec![
Declaration::Entity(crate::patch::EntityDeclaration {
handle: DraftHandle::new("dead-end"),
label: "The Unwalked Road".into(),
kind: EntityKind::Place,
container: None,
}),
Declaration::Route(crate::patch::RouteDeclaration {
handle: DraftHandle::new("gate"),
label: "The Field Gate".into(),
from: crate::patch::Ref::Existing(commons),
to: crate::patch::Ref::Draft(DraftHandle::new("dead-end")),
access: crate::patch::AccessKind::Public,
cost: crate::patch::Cost(1),
}),
]),
)
.unwrap();
crate::tests::activate(&mut kernel);
let dead_end = *kernel
.state
.entities
.iter()
.find(|(_, record)| record.label == "The Unwalked Road")
.map(|(id, _)| id)
.unwrap();
(dir, kernel, dead_end)
}
#[test]
fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved() {
let (_dir, mut kernel, dead_end) = advanced_grammar_world();
assert!(kernel.state.revision > 1, "the world has moved past genesis");
let answer = crate::derive_boundaries(&kernel.state)
.unwrap()
.into_iter()
.find(|boundary| {
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
if *place == dead_end)
})
.expect("the dead end is an unelaborated destination");
let doors: Vec<(&str, CallerId, Option<crate::PatchAnswer>)> = vec![
("owner", CallerId::Principal(owner()), None),
("play", CallerId::System(crate::SystemCapability::Play), None),
(
"consumer",
CallerId::System(crate::SystemCapability::Consumer {
consumer: crate::ConsumerId::of_name("grammar-door"),
}),
None,
),
(
"elaborator",
CallerId::System(crate::SystemCapability::Elaborator {
jurisdiction: crate::JurisdictionKey::PlaceSubtree(dead_end),
}),
Some(crate::PatchAnswer::Boundary(answer)),
),
];
for (door, caller, answers) in doors {
let revision = kernel.state.revision;
let kind = submit_as(
&mut kernel,
caller.clone(),
answers.clone(),
patch_of(vec![declare("wave", "wave", &[])]),
);
assert_eq!(rejected(kind), not_in_grammar("wave"), "{door}");
let roles = submit_as(
&mut kernel,
caller,
answers,
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
);
assert_eq!(rejected(roles), disagrees("hail"), "{door}");
assert_eq!(kernel.state.revision, revision, "{door} refusal moved the world");
}
// The same path admits what the grammar carries, so the refusals above are
// the grammar's and not a closed door.
let receipt = submit_as(
&mut kernel,
CallerId::System(crate::SystemCapability::Play),
None,
patch_of(vec![declare("hail", "hail", &[])]),
)
.unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
}
#[test]
fn a_grammar_that_binds_speak_as_anything_but_conversation_is_refused_at_bind() {
let ship = |roles: &[(&str, GrammarReferentKind)]| {
GrammarBinding::new(
"aetheria.grammar",
1,
[
("speak".to_owned(), verb(RenderPath::ShipAction, roles)),
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
],
)
.unwrap()
};
assert_eq!(
rejected(create(bound(Some(ship(&[])), "Filmed speech")).1),
vec![Mismatch::SpeakRenderPathDisagreesWithGrammar {
handle: DraftHandle::new(crate::patch::KERNEL_SPEAK_HANDLE),
}]
);
// A ShipAction speak with a role fails on the signature first and is still
// refused: no variant of speak but zero roles, Conversation, is admitted.
assert_eq!(
rejected(create(bound(Some(ship(&[("to", GrammarReferentKind::Person)])), "Loud")).1),
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
);
// The control: Conversation speak binds.
assert!(create(bound(Some(grammar_with(&[])), "Spoken")).1.is_ok());
}
/// `apply_effect` re-decides an effect it is handed. An effect resolved against
/// a world with no grammar must not install an entry the bound grammar lacks:
/// the catalog's only writer holds the grammar check itself.
#[test]
fn an_effect_resolved_elsewhere_cannot_install_an_entry_outside_the_grammar() {
let (_dir, kernel) = create(bound(Some(grammar_with(&[])), "Forged"));
let kernel = kernel.unwrap();
let mut twin = kernel.state.clone();
twin.grammar = None;
let resolve = |kind: &str, roles: &[(&str, RefKind)]| {
crate::patch::resolve_patch(
&twin,
CommandId::new(),
&patch_of(vec![declare("probe", kind, roles)]),
None,
None,
)
.expect("the unbound twin admits any canonical kind")
};
for (label, resolved, admitted) in [
("outside kind", resolve("wave", &[]), false),
("wrong roles", resolve("convene", &[("who", person())]), false),
("carried verb", resolve("convene", &[]), true),
] {
let mut state = kernel.state.clone();
let catalog = state.affordance_catalog.len();
let result = crate::apply_effect(
&mut state,
CommandId::new(),
&CallerId::Principal(owner()),
&crate::WorldEffect::PatchAdmitted {
answers: None,
resolved,
},
);
if admitted {
result.unwrap_or_else(|error| panic!("{label}: {error:?}"));
assert_eq!(state.affordance_catalog.len(), catalog + 1, "{label}");
} else {
assert!(
matches!(result, Err(KernelError::Invariant(_))),
"{label}: {result:?}"
);
assert_eq!(state.affordance_catalog.len(), catalog, "{label}");
}
}
}

View file

@ -2101,6 +2101,18 @@ fn admit_resolved(
.filter_map(ControllerAssignment::id)
.collect();
for entry in &resolved.affordances {
// The catalog's only writer holds the same grammar check the resolver
// runs, so an effect resolved elsewhere cannot install an entry the
// bound grammar refuses.
if state.grammar.as_ref().is_some_and(|grammar| {
grammar
.refusal(&entry.affordance.kind, &entry.affordance.roles)
.is_some()
}) {
return Err(KernelError::Invariant(
"admitted affordance is outside the bound grammar".into(),
));
}
if state
.affordance_catalog
.insert(entry.affordance_id, entry.affordance.clone())

View file

@ -13,6 +13,7 @@
use super::clock::{FictionalMinutes, TickMinutes};
use super::tool_schema;
use super::grammar::GrammarRefusal;
use super::{
AffordanceId, CommandId, ControllerAssignment, ControllerId, EdgeId, EntityId, LensWeights,
NewController, SubjectId, SubjectKind, SubjectState, WorldId,
@ -1360,6 +1361,10 @@ pub enum Mismatch {
AffordanceRolesDisagreeWithGrammar {
handle: DraftHandle,
},
/// The grammar binds the kernel's speak verb as anything but a Conversation.
SpeakRenderPathDisagreesWithGrammar {
handle: DraftHandle,
},
UnresolvedDraft {
site: Site,
referent: DraftHandle,
@ -3083,17 +3088,14 @@ fn check_against_grammar(
let Some(grammar) = &state.grammar else {
return;
};
match grammar.verb(&kind.0) {
None => mismatches.push(Mismatch::AffordanceKindNotInGrammar {
handle: handle.clone(),
}),
Some(verb) if !verb.admits_roles(roles) => {
mismatches.push(Mismatch::AffordanceRolesDisagreeWithGrammar {
handle: handle.clone(),
})
let handle = handle.clone();
mismatches.extend(grammar.refusal(kind, roles).map(|refusal| match refusal {
GrammarRefusal::KindNotInGrammar => Mismatch::AffordanceKindNotInGrammar { handle },
GrammarRefusal::RolesDisagree => Mismatch::AffordanceRolesDisagreeWithGrammar { handle },
GrammarRefusal::SpeakNotConversation => {
Mismatch::SpeakRenderPathDisagreesWithGrammar { handle }
}
Some(_) => {}
}
}));
}
/// The one resolution owner for every admission lane: declarations, references,