diff --git a/Cargo.lock b/Cargo.lock index 7b31144..1fe2492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index b4abed4..ca01ae8 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -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"] } diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 8cf21fb..f2fbb5c 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -313,7 +313,15 @@ fn ordered(views: &mut [PipelineDocumentView]) { impl Mind { /// 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, 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 Mind { /// 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, 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, 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. diff --git a/tools/eureka-cut9-mutations.psd1 b/tools/eureka-cut9-mutations.psd1 index 54c7b37..ae34383 100644 --- a/tools/eureka-cut9-mutations.psd1 +++ b/tools/eureka-cut9-mutations.psd1 @@ -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; + } +'@ + } ) }