Fourth fix batch: fill N1 in its owner, and correct two ledger claims

N1 (medium-low). Moves the epiphany-pipeline pin to the leaf's new commit
(9d3a3efd), which opens Slug::validate_slug. Huginn's own cultcache-rs pin
is untouched; cargo tree -d still carries exactly one cultcache-rs.

Mind::require_instance now validates the declared name's grammar before
comparing identity, so admission's A1 and every read refuse a bad name
identically, through one door. Mind::open and open_with validate the
declared instance before path_for ever runs, so a Slug carrying `..` and a
path separator (Soul's outer/inner probe) is refused before any store is
created, not after it has already escaped the state root.

require_grammatical_instance and its daemon-side branch in daemon.rs are
deleted: the throwaway PipelineInstance wrapper it built is no longer the
only door onto the leaf's grammar. The refusal now names the field a
caller actually held the declared name under (declared, or instance) and
the whole declared name, not the old wrapper's instance.instance field or
a single failing label.

Adds a leaf-escape regression test pinning Soul's probe and asserting no
store is created, and a fullwidth-instance test showing Admit and Query
refuse identically. Updates the cut10 grammar-fold mutant (S3wide) to the
new function shape and the affected daemon test to the new field name.

N5 and N6, prose only. The cut10 entries gain a note by S1 and S1d: neither
mutant "dies on all three" faults its shared fixture carries, contrary to
an earlier claim; S1d exempts the orphan fault by construction and is
killed by the undecodable-document block instead, the next one the test
body reaches. The cut9 header's self-contradiction at lines 18-25 is
resolved (nothing in the suite exercises the door, not "nothing reaches
it"), its self-reference now points at store.rs:18-19, and its line-1
range is corrected to V1-V24L.

All three suites rerun clean after the pin move: cut8 67/67, cut9 38/38,
cut10 65/65, every M0 green, restores hash-verified. cargo test --workspace:
huginn-mind 61/61 (was 60), huginn-daemon 18/18 (was 17).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 05:14:34 +02:00
commit cc71a569dc
7 changed files with 134 additions and 73 deletions

2
Cargo.lock generated
View file

@ -306,7 +306,7 @@ dependencies = [
[[package]]
name = "epiphany-pipeline"
version = "0.1.0"
source = "git+https://github.com/GameCult/Epiphany.git?rev=5cda0886bd937b9df18309de7f505029d0e41f4d#5cda0886bd937b9df18309de7f505029d0e41f4d"
source = "git+https://github.com/GameCult/Epiphany.git?rev=9d3a3efd56b34893f8c124d3eeb2526326bac646#9d3a3efd56b34893f8c124d3eeb2526326bac646"
dependencies = [
"anyhow",
"chrono",

View file

@ -58,20 +58,16 @@ 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: 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.
/// other request names an instance: `require_instance` now applies the
/// leaf's own grammar before it compares identity, so a name outside the
/// grammar (a fullwidth fold, say) is refused by name rather than read as
/// merely a foreign mind, and the daemon asks nothing twice.
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)
{
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);
}
return HuginnMindResponse::Refused(refusal);
}
match request {
HuginnMindRequest::Whoami => HuginnMindResponse::Whoami(self.mind.status()),
@ -506,7 +502,7 @@ pub(crate) mod tests {
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(),
field: "declared".into(),
value: fullwidth.into(),
});
@ -527,6 +523,36 @@ pub(crate) mod tests {
assert_eq!(daemon.handle(history, now()), HuginnMindResponse::Refused(expected));
}
/// `Admit` is excluded from the daemon's own pre-check (its batch is not a
/// read naming an instance the mind is asked about first), but it reaches
/// the identical `require_instance` from inside `admit_steps`'s A1, so a
/// fullwidth *declared batch instance* is `InvalidFormat` there too,
/// named and valued the same as the read path's. One door,
/// `Mind::require_instance`, decides both. The batch's one document names
/// the mind's own real instance, `INSTANCE`, so it clears `admit`'s own
/// per-document `Bounded` check and the only fullwidth text anywhere in
/// the request is the batch's declared instance, the thing this test
/// pins.
#[test]
fn a_fullwidth_instance_is_refused_the_same_way_by_admit_and_by_query() {
let (_root, mut daemon) = seeded();
let fullwidth = "\u{FF59}ggdrasil";
let expected = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat {
field: "declared".into(),
value: fullwidth.into(),
});
let admit = HuginnMindRequest::Admit(batch(fullwidth, vec![identity(INSTANCE)]));
assert_eq!(
daemon.handle(admit, now()),
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(expected.clone()))
);
let query = HuginnMindRequest::Query { instance: slug(fullwidth), query: PipelineQuery::default() };
assert_eq!(daemon.handle(query, 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

@ -14,7 +14,7 @@ publish.workspace = true
anyhow = "1"
chrono = "0.4.44"
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" }
epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "5cda0886bd937b9df18309de7f505029d0e41f4d", package = "epiphany-pipeline" }
epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "9d3a3efd56b34893f8c124d3eeb2526326bac646", package = "epiphany-pipeline" }
rmp-serde = "1"
schemars = "1"
serde = { version = "1", features = ["derive"] }

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, require_grammatical_instance};
pub use mind::{HuginnMindEpoch, Mind};
pub use query::{
AdmissionFacts, HistoryScope, PipelineDocumentView, PipelineOpenItems, PipelineQuery, PipelineQueryPage,
PipelineStatus, QUERY_LIMIT_MAX, SemanticQuery,

View file

@ -12,10 +12,7 @@
use std::path::{Path, PathBuf};
use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore};
use epiphany_pipeline::{
Date, PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineInstance, PipelineKind, Short, Slug,
register_pipeline_document_types,
};
use epiphany_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types};
use crate::receipt::HuginnCommitReceipt;
use crate::refusal::MindRefusal;
@ -49,29 +46,28 @@ 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.
/// A declared name's grammar, checked before its bytes are compared to
/// anything or reach the filesystem. `Slug` is `epiphany_pipeline`'s,
/// dot-joined ASCII labels, and the leaf now opens a public door onto its own
/// check, `Slug::validate_slug`, on the pattern of `PipelineRef::validate_ref`
/// (Self's ruling on the F6 fork): the grammar is the leaf's, never
/// re-derived here, so a fold that widens what counts as ASCII (a fullwidth
/// character folded to its plain form, say) has nothing local to weaken.
///
/// 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)
/// The leaf's own refusal names its own field, `slug`, and for a dotted name
/// the one label that failed rather than the whole declared name; a caller
/// here reports neither, since both would misname what the caller actually
/// sent. This instead names the field the declared name was held under and
/// the whole declared name, so `require_instance` and `Mind::open` each
/// refuse by the name a client can recognise, not `instance.instance` (the
/// old wrapper's field, a client never sent) and not a label fragment.
fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefusal> {
declared.validate_slug().map_err(|_| {
MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat {
field: field.into(),
value: declared.0.clone(),
})
})
}
/// A cache that knows the fifteen types a mind's store may hold: the leaf's
@ -114,6 +110,7 @@ impl Mind<OwnedRedbMessagePackBackingStore> {
/// path, in this process or another, is `MindAlreadyOwned`. An empty
/// store is a valid open; only the first admission may write into it.
pub fn open(state_root: &Path, instance: &Slug) -> Result<Self, MindRefusal> {
require_grammatical_slug("instance", instance)?;
let path = Self::path_for(state_root, instance);
let store = OwnedRedbMessagePackBackingStore::new(&path).map_err(|error| {
// The store reports a held lock as an error like any other; its
@ -135,6 +132,7 @@ impl<S: MindStore> Mind<S> {
/// one `instance` document naming the declared instance; then register,
/// attach and pull.
pub fn open_with(store: S, instance: &Slug) -> Result<Self, MindRefusal> {
require_grammatical_slug("instance", instance)?;
let raw = store.pull_all().map_err(unavailable)?;
refuse_foreign_types(&raw)?;
refuse_foreign_epoch(&raw)?;
@ -152,6 +150,7 @@ impl<S: MindStore> Mind<S> {
/// asks it for every read that names an instance, and compares nothing
/// itself.
pub fn require_instance(&self, declared: &Slug) -> Result<(), MindRefusal> {
require_grammatical_slug("declared", declared)?;
if declared != self.instance() {
return Err(MindRefusal::ForeignInstance {
declared: declared.0.clone(),
@ -508,4 +507,31 @@ mod tests {
assert!(second.is_empty());
assert_eq!(Mind::path_for(root.path(), &yggdrasil), root.path().join("minds").join(INSTANCE).join("mind.redb"));
}
/// N1, Soul's probe: `Mind::open(outer/inner, Slug("..\..\escaped"))` used
/// to return `Ok` and create `outer/escaped/mind.redb`, a store outside
/// the state root nothing beneath `outer/inner` names. `path_for` joins a
/// declared name as written and never validated it, so a `Slug` carrying
/// `..` and a path separator escaped through the one door that touches the
/// filesystem. `require_grammatical_slug` now runs before `path_for` is
/// even called, so the store is never created and the refusal is the
/// leaf's own grammar, not a filesystem error surfacing later.
#[test]
fn a_slug_outside_the_grammar_cannot_escape_the_state_root() {
let outer = tempfile::tempdir().unwrap();
let inner = outer.path().join("inner");
std::fs::create_dir_all(&inner).unwrap();
let escaping = Slug("..\\..\\escaped".into());
assert_eq!(
Mind::open(&inner, &escaping).err(),
Some(MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat {
field: "instance".into(),
value: escaping.0.clone(),
}))
);
assert!(!outer.path().join("escaped").join("mind.redb").exists(), "no store escaped the state root");
assert!(!inner.join("minds").exists(), "no store was created under the state root either");
}
}

View file

@ -163,38 +163,38 @@
}
@{
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.'
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''s Slug::validate_slug 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)
fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefusal> {
declared.validate_slug().map_err(|_| {
MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat {
field: field.into(),
value: declared.0.clone(),
})
})
}
'@
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,
fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefusal> {
let folded = Slug(
declared
.0
.chars()
.map(|c| match c as u32 {
0xFF01..=0xFF5E => char::from_u32(c as u32 - 0xFEE0).unwrap_or(c),
_ => c,
})
.collect(),
);
folded.validate_slug().map_err(|_| {
MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat {
field: field.into(),
value: declared.0.clone(),
})
.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)
})
}
'@
}
@ -577,6 +577,17 @@ pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal>
}),
'@
}
# S1 and S1d's fixture, `open_items_refuses_on_a_fault_inside_the_requested_campaign`,
# runs three faults in one test body: an orphan receipt, an undecodable
# document, and a document two receipts claim. N5, correcting an
# earlier claim here: neither mutant "dies on all three". S1d's own
# `New` exempts any detail containing "has no commit receipt" by
# construction, so the orphan block never fails under it; the test
# panics at the very next block, the undecodable document, and never
# reaches the third. S1 has no such exemption and is killed by the
# same first block its own widened check reaches, the orphan. Each
# mutant dies on the fault its own body actually mangles, not on all
# three the fixture happens to carry.
@{
Id = 'S1'
Rule = 'open_items refuses on a fault anywhere in the image it must read, not only on one outside the requested campaign.'

View file

@ -1,4 +1,4 @@
# Cut 9 mutations, V1-V23: the read side's rules, each entry restoring one
# Cut 9 mutations, V1-V24L: the read side's rules, each entry restoring one
# permissiveness the cut closed and naming the test that must fail while it is
# applied. A rule with two weakenings has two entries: `Vn` is the revert, the
# rule simply absent, and `VnL` is the loosening, the rule weakened rather than
@ -16,14 +16,12 @@
# `eureka-cut8-mutations.psd1` -- one mutant, two suites, one owner.
#
# Two weakenings the spec names have no entry, not because they are
# unreachable but because nothing on this Body's own public surface reaches
# them today, which is a narrower claim than the one this header used to make.
# "No behaviour this Body can reach" rested on admission being the only write
# path, and that premise is false: `MindStore::compare_and_swap_batch` is
# `pub`, and `store.rs`'s own re-export of `CacheBackingStore` already lets
# code outside this crate push a row that skips A5 (and every other admission
# rule) entirely, the same door the cut9 header's line 18-24 claim rested on.
# Recorded as not yet reached, not as unreachable:
# unreachable but because nothing in this suite exercises the path that would
# reach them. `MindStore::compare_and_swap_batch` is `pub`, and `store.rs`'s
# own re-export of `CacheBackingStore` already lets code outside this crate
# push a row that skips A5 (and every other admission rule) entirely -- the
# door named at `store.rs:18-19`. Recorded as not yet reached, not as
# unreachable:
#
# - `assignments_of` comparing the repo and ignoring the instance. A5 refuses
# a stewardship naming another instance for every row admission writes, so