Check a declared instance against the slug grammar on the read path

F6: no declared instance Slug was ever checked against Slug's own grammar
before reaching Mind::require_instance's byte comparison, so a name outside
the grammar read as merely a foreign mind rather than being refused by its
own name.

Slug's grammar check is a crate-private trait in epiphany_pipeline, reachable
only through a real document, so require_grammatical_instance wraps the
declared name in a throwaway PipelineInstance and reads the answer back
through PipelineDocument::validate, the leaf's own public door; nothing here
re-derives the grammar. Daemon::handle asks it on the read path, before
Mind::require_instance, for every request that names an instance.

Add a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check,
driving a fullwidth Latin small letter y, and mutation entry S3wide, which
folds a fullwidth character to plain ASCII before validating and dies
against it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 03:20:59 +02:00
commit 4ecbf53056
4 changed files with 112 additions and 6 deletions

View file

@ -58,14 +58,20 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
/// One request, one answer. `Admit` goes to the mind whole, because the
/// batch declares its own instance and admission's A1 is the check. Every
/// other request names an instance, and the mind is asked whether that is
/// its own before the method runs.
/// other request names an instance: the read path asks the leaf whether
/// that name is even grammatical before asking the mind whether it is its
/// own, so a name outside the grammar (a fullwidth fold, say) is refused
/// by name rather than read as merely a foreign mind.
pub fn handle(&mut self, request: HuginnMindRequest, now: DateTime<Utc>) -> HuginnMindResponse {
if let Some(declared) = request.instance()
&& !matches!(request, HuginnMindRequest::Admit(_))
&& let Err(refusal) = self.mind.require_instance(declared)
{
return HuginnMindResponse::Refused(refusal);
if let Err(refusal) = huginn_mind::require_grammatical_instance(declared) {
return HuginnMindResponse::Refused(refusal);
}
if let Err(refusal) = self.mind.require_instance(declared) {
return HuginnMindResponse::Refused(refusal);
}
}
match request {
HuginnMindRequest::Whoami => HuginnMindResponse::Whoami(self.mind.status()),
@ -484,6 +490,43 @@ pub(crate) mod tests {
assert_eq!(status(&mut daemon).documents, 1, "nothing landed");
}
/// A declared instance outside `Slug`'s grammar is refused by its own
/// name, `InvalidFormat`, on the read path, before the identity
/// comparison ever runs: it is not merely a foreign mind, it names no
/// mind a client could compose. A fullwidth Latin small letter y is used
/// because it is the shape Soul named: a check that folds it to plain
/// ASCII `y` before validating would let this probe pass as grammatical
/// and reach `require_instance`, which would then answer `ForeignInstance`
/// instead — the wrong refusal, reached the wrong way.
#[test]
fn a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check() {
let (_root, mut daemon) = seeded();
let fullwidth = "\u{FF59}ggdrasil";
assert_ne!(fullwidth, INSTANCE, "not the mind's own bytes");
assert!(!fullwidth.is_ascii(), "outside the grammar: a Slug is dot-joined ASCII labels");
let expected = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat {
field: "instance.instance".into(),
value: fullwidth.into(),
});
let query = HuginnMindRequest::Query { instance: slug(fullwidth), query: PipelineQuery::default() };
assert_eq!(daemon.handle(query, now()), HuginnMindResponse::Refused(expected.clone()));
let view = HuginnMindRequest::View {
instance: slug(fullwidth),
id: PipelineRef { kind: PipelineKind::Question, id: Short("not-reached".into()) },
};
assert_eq!(daemon.handle(view, now()), HuginnMindResponse::Refused(expected.clone()));
let items = HuginnMindRequest::OpenItems { instance: slug(fullwidth), campaign: slug(CAMPAIGN) };
assert_eq!(daemon.handle(items, now()), HuginnMindResponse::Refused(expected.clone()));
let scope = HistoryScope::Repo(OrgRepo("GameCult/Huginn".into()));
let history = HuginnMindRequest::History { instance: slug(fullwidth), scope };
assert_eq!(daemon.handle(history, now()), HuginnMindResponse::Refused(expected));
}
/// A refusal a read method raised is the answer the dispatch returns, whole.
/// The instance check refuses before the method runs and is pinned above;
/// these are the refusals the methods themselves raise, so nothing here is

View file

@ -36,7 +36,7 @@ pub mod wire;
pub(crate) mod fixtures;
pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome};
pub use mind::{HuginnMindEpoch, Mind};
pub use mind::{HuginnMindEpoch, Mind, require_grammatical_instance};
pub use query::{
AdmissionFacts, HistoryScope, PipelineDocumentView, PipelineOpenItems, PipelineQuery, PipelineQueryPage,
PipelineStatus, QUERY_LIMIT_MAX, SemanticQuery,

View file

@ -13,7 +13,8 @@ use std::path::{Path, PathBuf};
use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore};
use epiphany_pipeline::{
PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types,
Date, PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineInstance, PipelineKind, Short, Slug,
register_pipeline_document_types,
};
use crate::receipt::HuginnCommitReceipt;
@ -48,6 +49,31 @@ pub(crate) fn unavailable(error: anyhow::Error) -> MindRefusal {
MindRefusal::Unavailable { detail: format!("{error:#}") }
}
/// A declared instance's grammar, checked before its bytes are compared to
/// anything. `Slug` is `epiphany_pipeline`'s, dot-joined ASCII labels, and the
/// leaf validates one only as a document field: its grammar check is a
/// crate-private trait, so a bare `Slug` cannot be asked directly and the only
/// public door is a real document. This wraps the declared name in a
/// throwaway `PipelineInstance` and reads the answer back through the leaf's
/// own public `PipelineDocument::validate`, so the grammar is the leaf's,
/// never re-derived here: a fold that widens what counts as ASCII (a
/// fullwidth character folded to its plain form, say) has nothing local to
/// weaken, because nothing local decides the grammar.
///
/// Read-path callers ask this before `Mind::require_instance`, so a name
/// outside the grammar is refused by its own name — the leaf's
/// `InvalidFormat` — rather than reaching the identity comparison and being
/// read as merely a foreign mind.
pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> {
let probe = PipelineDocument::Instance(PipelineInstance {
instance: declared.clone(),
display_name: Short("grammar-probe".into()),
created_at: Date("2026-01-01".into()),
host: Short("grammar-probe".into()),
});
probe.validate().map_err(MindRefusal::Document)
}
/// A cache that knows the fifteen types a mind's store may hold: the leaf's
/// thirteen through its registrar, the epoch record and the commit receipt.
/// It is the cache every envelope is prepared against.

View file

@ -143,6 +143,43 @@
Old = ' if declared != self.instance() {'
New = ' if !declared.0.eq_ignore_ascii_case(&self.instance().0) {'
}
@{
Id = 'S3wide'
Rule = 'The grammar check runs on the declared bytes as given: nothing folds a fullwidth character to its plain ASCII form before asking the leaf whether the name is grammatical.'
Test = 'daemon::tests::a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check'
Command = 'cargo test -p huginn-daemon --lib'
File = 'crates/huginn-mind/src/mind.rs'
Old = @'
pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> {
let probe = PipelineDocument::Instance(PipelineInstance {
instance: declared.clone(),
display_name: Short("grammar-probe".into()),
created_at: Date("2026-01-01".into()),
host: Short("grammar-probe".into()),
});
probe.validate().map_err(MindRefusal::Document)
}
'@
New = @'
pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> {
let folded: String = declared
.0
.chars()
.map(|c| match c as u32 {
0xFF01..=0xFF5E => char::from_u32(c as u32 - 0xFEE0).unwrap_or(c),
_ => c,
})
.collect();
let probe = PipelineDocument::Instance(PipelineInstance {
instance: Slug(folded),
display_name: Short("grammar-probe".into()),
created_at: Date("2026-01-01".into()),
host: Short("grammar-probe".into()),
});
probe.validate().map_err(MindRefusal::Document)
}
'@
}
@{
Id = 'D2'
Rule = 'Rulings 14 and 18 on the wire: the daemon passes the declared instance to the mind as the client sent it.'