Refuse a ref that is no ref, rather than answering over it

The pin moves to Epiphany `5cda0886`, where `PipelineRef::validate_ref` opens
the leaf's grammar to a reader. `view` and `history`'s subject scope ask it
before they look, so a ref whose kind and id disagree is refused instead of
reporting the nothing a lookup finds. `query`'s `campaign`, `cut` and `repo`
filters take a `Slug`, a `Label` and an `OrgRepo`, not refs, and are unchanged.

The refusal is `MindRefusal::Document`, the leaf's `InvalidFormat` wrapped
where the organ already wraps the leaf's four. A new variant would be a second
name for one rule, and the grammar it names lives in the leaf.

`a_ref_whose_kind_and_id_disagree_is_refused_by_both_doors` pins both doors on
a disagreeing kind and on a local no writer composes, and pins that absence is
still absence: a well-formed id of a document this mind does not hold answers
`None` and an empty history as it always did. The history test's malformed
scope asserts the refusal where it asserted an empty answer.

`V24` and `V24L` in `tools/eureka-cut9-mutations.psd1` are the validation
dropped and the validation applied at `view` alone. `V10L` leaves the suite
with its reason in the header: a subject compared by `id` alone can no longer
differ from one compared whole, since both sides' kind is now a function of
the id, so the weakening is unreachable and a mutation that cannot fail is not
a proof.

`cargo test -p huginn-mind --lib` 57 passed, 0 warnings; `cargo check
--workspace` clean; Cut 8 and Cut 9 suites green-and-killed through the
harness. The lock moves the one pin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-16 22:41:17 +02:00
commit 5ba7b0c6e5
4 changed files with 124 additions and 26 deletions

2
Cargo.lock generated
View file

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

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 = "d5a36c2a291b910539072717d42d61a9aa7485d1", package = "epiphany-pipeline" }
epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "5cda0886bd937b9df18309de7f505029d0e41f4d", package = "epiphany-pipeline" }
rmp-serde = "1"
schemars = "1"
serde = { version = "1", features = ["derive"] }

View file

@ -313,7 +313,15 @@ fn ordered(views: &mut [PipelineDocumentView]) {
impl<S: MindStore> Mind<S> {
/// One document with its admission facts and its derived status, or
/// `None` when the mind does not hold it.
///
/// The ref is validated first, through the leaf's own door: a kind and an
/// id that disagree name no document any writer could have composed, and
/// answering `None` over one would report a malformed reference as an
/// absent document. The refusal is the leaf's, wrapped: this crate owns no
/// grammar, and a second name for `InvalidFormat` would be a second
/// vocabulary for one rule.
pub fn view(&self, id: &PipelineRef) -> Result<Option<PipelineDocumentView>, MindRefusal> {
id.validate_ref()?;
let reader = Reader::new(self)?;
reader.held(id.kind, &id.id.0).map(|held| reader.view_of(held)).transpose()
}
@ -385,7 +393,15 @@ impl<S: MindStore> Mind<S> {
/// Every record of one scope, withdrawn ones included with their status
/// and their reasons, in the scope's own sequence order. Uncapped: a
/// scope's history is bounded by the scope.
///
/// A subject scope is validated as `view`'s id is, and for the same
/// reason: an empty history is the answer for a subject with no records,
/// not for a ref that is no ref. A repo scope is an `OrgRepo`, which the
/// type already holds to its own format.
pub fn history(&self, scope: &HistoryScope) -> Result<Vec<PipelineDocumentView>, MindRefusal> {
if let HistoryScope::Subject(subject) = scope {
subject.validate_ref()?;
}
let reader = Reader::new(self)?;
let (kind, mut records) = match scope {
HistoryScope::Subject(subject) => (
@ -429,7 +445,8 @@ mod tests {
use crate::store::test_stores::MemoryStore;
use chrono::{TimeZone, Utc};
use epiphany_pipeline::{
ClaimOutcome, Date, FindingConfidence, PipelineDocument as D, PipelineKind as K, ResolutionOutcome,
ClaimOutcome, Date, FindingConfidence, PipelineDocument as D, PipelineKind as K, PipelineRefusal,
ResolutionOutcome,
};
fn view(mind: &Mind<MemoryStore>, kind: K, key: &str) -> PipelineDocumentView {
@ -440,6 +457,12 @@ mod tests {
views.iter().map(|view| view.id.id.0.clone()).collect()
}
/// The refusal a door answers for a ref whose kind is not its id's: the
/// leaf's own `InvalidFormat`, at the field the leaf names it, wrapped.
fn malformed(id: &str) -> MindRefusal {
MindRefusal::Document(PipelineRefusal::InvalidFormat { field: "ref.id".into(), value: id.into() })
}
/// A ruling that answers a question, which is what derives its
/// `Answered` resolution.
fn answering(label: &str, question: &str) -> D {
@ -510,6 +533,38 @@ mod tests {
assert_eq!(view(&mind, K::Verdict, &id("verdict", "cut-1.s1")).status, PipelineStatus::InForce);
}
/// Ruling 1's read half: the grammar lives in the leaf, and both doors
/// that take a ref ask it before they look. A ref that is no ref is
/// refused; a ref that is well formed and names nothing is still the
/// empty answer, so the door refuses malformation and not absence.
#[test]
fn a_ref_whose_kind_and_id_disagree_is_refused_by_both_doors() {
let mut mind = seeded();
let q1 = id("question", "Q1");
committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]));
committed(admit(&mut mind, vec![answering("R1", &q1)]));
// The id names a question this mind holds and a resolution it derived;
// read as a ruling it names neither, and is not a reference at all.
let disagreeing = r(K::Ruling, &q1);
assert_eq!(mind.view(&disagreeing), Err(malformed(&q1)));
assert_eq!(mind.history(&HistoryScope::Subject(disagreeing)), Err(malformed(&q1)));
// A local no writer composes is the same refusal, so the door is the
// whole grammar and not the kind segment alone. The refusal's `value`
// is the failing part, here the empty part after the trailing dot,
// and not the whole id.
let dotted = r(K::Question, &format!("{q1}."));
assert_eq!(mind.view(&dotted), Err(malformed("")));
assert_eq!(mind.history(&HistoryScope::Subject(dotted)), Err(malformed("")));
// Absence is not malformation: a well-formed id of a kind this mind
// does not hold answers as it always did.
let absent = id("question", "Q9");
assert_eq!(mind.view(&r(K::Question, &absent)), Ok(None));
assert_eq!(mind.history(&HistoryScope::Subject(r(K::Question, &absent))), Ok(Vec::new()));
}
/// R-B and D7: a subject's resolutions are a first-class view, withdrawn
/// ones included with their status and their reasons, in sequence order.
#[test]
@ -552,13 +607,15 @@ mod tests {
id("resolution", "resolution.question.Q1.n1.n1")
]);
// The scope is the whole ref, kind included: a subject whose kind
// disagrees with its id is a subject this mind has no resolution of,
// even though the id names one with ten. The read side does not
// validate the ref it is handed -- the leaf's validator is not public
// at the pinned rev -- so a malformed scope is an empty history rather
// than a typed refusal.
assert!(mind.history(&HistoryScope::Subject(r(K::Ruling, &q1))).unwrap().is_empty());
// A subject whose kind disagrees with its id is no subject at all, and
// the door says so: the id names ten resolutions, and a mind that
// answered an empty history over it would report a malformed reference
// as a subject with no records.
assert_eq!(
mind.history(&HistoryScope::Subject(r(K::Ruling, &q1))),
Err(malformed(&q1)),
"a malformed scope is refused, not answered over"
);
// History lands one record per batch (Cut 12's import constraint), so
// ten records carry ten receipts.

View file

@ -15,23 +15,26 @@
# breaks it for the views. V1L is H41's own edit, listed here as well as in
# `eureka-cut8-mutations.psd1` -- one mutant, two suites, one owner.
#
# One weakening the spec names has no entry because it changes no behaviour
# Two weakenings the spec names have no entry because they change no behaviour
# this Body can reach, and a mutation that cannot fail is not a proof:
#
# - `assignments_of` comparing the repo and ignoring the instance. A5 refuses
# a stewardship naming another instance, so every stewardship a mind holds
# already names that mind, and every other caller is a sequence this mind
# derives over its own scope.
# - `resolutions_of` comparing a subject by `id` alone, which was `V10L` until
# the leaf opened `PipelineRef::validate_ref` and `view` and `history` began
# asking it. A ref that reaches the comparison now has a kind its id's kind
# segment agrees with, and every stored subject was held to the same grammar
# at admission, so on both sides the kind is a function of the id and
# comparing the whole ref and comparing the id alone cannot differ. The rule
# is still the rule; it is the reachable weakening that went, and what
# defends it is `V24`/`V24L` at the doors instead.
#
# The two listed beside it in the spec are failable and have entries. The
# The one listed beside them in the spec is failable and has an entry: the
# `semantic` check below `Reader::new` changes the answer for a store the
# reader refuses -- a doubled receipt, a row that does not decode -- from the
# semantic refusal to the integrity one, and `V8L` is that move. And
# `resolutions_of` comparing a subject by `id` alone is reachable because the
# read side never validates the `PipelineRef` it is handed: the leaf's
# validator is not public at the pinned rev, so `history(Subject(ref))` and
# `view(ref)` take a ref whose kind and id disagree and answer over it. `V10L`
# is that comparison.
# semantic refusal to the integrity one, and `V8L` is that move.
@{
Mutations = @(
@{
@ -229,14 +232,6 @@
.map(|(key, resolution)| { let _ = subject; (resolution.sequence, key.to_string()) })
'@
}
@{
Id = 'V10L'
Rule = 'A subject is selected by the whole ref, kind included: the read side validates no ref it is handed, so a kind that disagrees with its id selects nothing rather than that id''s records.'
Test = 'query::tests::a_subjects_history_lists_every_resolution_with_its_status_and_receipt'
File = 'crates/huginn-mind/src/docs.rs'
Old = ' self.resolutions().filter(move |(_, resolution)| resolution.subject == *subject)'
New = ' self.resolutions().filter(move |(_, resolution)| resolution.subject.id == subject.id)'
}
@{
Id = 'V11'
Rule = 'A repo''s history is selected by (this mind, this repo), so another repo''s assignments are another history.'
@ -480,5 +475,51 @@
Old = ' D::Campaign(campaign) => campaign.repos.contains(repo),'
New = ' D::Campaign(_) => false,'
}
@{
Id = 'V24'
Rule = 'Every door that takes a ref asks the leaf''s grammar first: a ref whose kind and id disagree is refused, never answered over as an absent document or an empty history.'
Test = 'query::tests::a_ref_whose_kind_and_id_disagree_is_refused_by_both_doors'
Edits = @(
@{
File = 'crates/huginn-mind/src/query.rs'
Old = @'
id.validate_ref()?;
let reader = Reader::new(self)?;
'@
New = @'
let reader = Reader::new(self)?;
'@
}
@{
File = 'crates/huginn-mind/src/query.rs'
Old = @'
if let HistoryScope::Subject(subject) = scope {
subject.validate_ref()?;
}
'@
New = @'
if let HistoryScope::Subject(subject) = scope {
let _ = subject;
}
'@
}
)
}
@{
Id = 'V24L'
Rule = 'Both doors ask, not one: a mind that validates the ref it views and not the subject it takes a history of still answers an empty history over a ref that is no ref.'
Test = 'query::tests::a_ref_whose_kind_and_id_disagree_is_refused_by_both_doors'
File = 'crates/huginn-mind/src/query.rs'
Old = @'
if let HistoryScope::Subject(subject) = scope {
subject.validate_ref()?;
}
'@
New = @'
if let HistoryScope::Subject(subject) = scope {
let _ = subject;
}
'@
}
)
}