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:
parent
c11e8a16ba
commit
e5ae6fe7af
4 changed files with 246 additions and 11 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue