diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index f2baeea..62380a7 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -368,11 +368,6 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { if campaign.repos.is_empty() { return Err(MindRefusal::EmptyRepos { campaign: key.into() }); } - for repo in &campaign.repos { - if docs.stewardship_of(mind, repo, None).is_none() { - return Err(MindRefusal::RepoNotStewarded { repo: repo.0.clone() }); - } - } Ok(()) } D::Target(target) => { @@ -422,6 +417,18 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { return Err(MindRefusal::CitesResolvedDocument { kind: K::Ruling, id: ruling.0.clone() }); } } + for dependency in &spec.depends_on { + // A cut naming itself is no ordering (ruling self-dep). + if dependency.0 == spec.cut.0 { + return Err(MindRefusal::UnknownDependency { cut: dependency.0.clone() }); + } + let names_a_cut = docs.of_kind(K::CutSpec).any(|(_, document)| { + matches!(document, D::CutSpec(other) if other.campaign == spec.campaign && other.cut.0 == dependency.0) + }); + if !names_a_cut { + return Err(MindRefusal::UnknownDependency { cut: dependency.0.clone() }); + } + } let predecessor = D::CutSpec(epiphany_pipeline::PipelineCutSpec { revision: spec.revision.wrapping_sub(1), ..spec.clone() }); revision_rule(docs, K::CutSpec, key, spec.revision, &predecessor) } @@ -438,7 +445,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { if report.branch != spec.branch { return Err(MindRefusal::SpecMismatch { field: "branch".into() }); } - if !report.commits.iter().any(|commit| commit.sha == report.range.head) { + if !report.commits.iter().any(|commit| commit.sha.names_same_commit(&report.range.head)) { return Err(MindRefusal::RangeOutsideCommits { head: report.range.head.0.clone() }); } Ok(()) @@ -579,7 +586,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { (K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec), (K::CutSpec, O::Withdrawn { .. }) => true, (K::Finding, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), - (K::Finding, O::Deferred { to }) => to.kind == K::FollowUp, + (K::Finding, O::Deferred { to }) => matches!(to.kind, K::FollowUp | K::CutSpec), (K::Finding, O::Recorded { .. } | O::Withdrawn { .. }) => true, (K::FollowUp, O::Fixed { by, .. }) => by.as_ref().is_none_or(|report| report.kind == K::CutReport), (K::FollowUp, O::Superseded { by }) => all(by, K::FollowUp), @@ -1350,14 +1357,11 @@ mod tests { receipt.strong_reads ); - // Nothing stewards the repo now, and `stewardship_of` says so: a new - // campaign over it is refused. + // Nothing stewards the repo now, and a campaign over it still admits: + // campaign admission reads no stewardship (ruling stewardship-rule). let D::Campaign(mut second) = campaign(&[REPO]) else { panic!() }; second.slug = slug("second"); - assert_eq!( - refusal(admit(&mut mind, vec![D::Campaign(second)])), - MindRefusal::RepoNotStewarded { repo: REPO.into() } - ); + committed(admit(&mut mind, vec![D::Campaign(second)])); // Handed back, the mind stewards it again, as `n2`. No field names a // return: this is the same hand-off shape with the instances swapped. @@ -1910,6 +1914,136 @@ mod tests { ); } + #[test] + fn depends_on_names_cuts() { + let depending = |cut: &str, on: &[&str]| { + let mut spec = cut_spec(cut, 1); + spec.depends_on = on.iter().map(|label| s(label)).collect(); + D::CutSpec(spec) + }; + let mut mind = seeded(); + // An unknown label, and a spec id (a revision, not a cut), are refused. + assert_eq!( + refusal(admit(&mut mind, vec![depending("2", &["1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + assert_eq!( + refusal(admit(&mut mind, vec![depending("2", &[&id("cut_spec", "cut-1.r1")])])), + MindRefusal::UnknownDependency { cut: id("cut_spec", "cut-1.r1") } + ); + // The same batch carries the cut it depends on. + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1)), depending("2", &["1"])])); + // The image carries it, and any revision of the cut names it. + committed(admit(&mut mind, vec![depending("3", &["1", "2"])])); + // A cut of another campaign is not this campaign's cut. + let mut other = cut_spec("9", 1); + other.campaign = slug("elsewhere"); + let mut elsewhere = campaign(&[REPO]); + if let D::Campaign(campaign) = &mut elsewhere { + campaign.slug = slug("elsewhere"); + } + committed(admit(&mut mind, vec![elsewhere, D::CutSpec(other)])); + assert_eq!( + refusal(admit(&mut mind, vec![depending("4", &["9"])])), + MindRefusal::UnknownDependency { cut: "9".into() } + ); + } + + #[test] + fn depends_on_matches_cut_labels_whole() { + let depending = |cut: &str, on: &[&str]| { + let mut spec = cut_spec(cut, 1); + spec.depends_on = on.iter().map(|label| s(label)).collect(); + D::CutSpec(spec) + }; + // The image carries cut 10: neither its prefix nor an extension of it names it. + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("10", 1))])); + for near in ["1", "100"] { + assert_eq!( + refusal(admit(&mut mind, vec![depending("11", &[near])])), + MindRefusal::UnknownDependency { cut: near.into() } + ); + } + // The same holds when the cut arrives in the same batch. + for near in ["2", "200"] { + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(cut_spec("20", 1)), depending("21", &[near])])), + MindRefusal::UnknownDependency { cut: near.into() } + ); + } + committed(admit(&mut mind, vec![depending("12", &["10"])])); + } + + #[test] + fn a_cut_may_not_depend_on_itself() { + let depending = |cut: &str, revision: u32, on: &[&str]| { + let mut spec = cut_spec(cut, revision); + spec.depends_on = on.iter().map(|label| s(label)).collect(); + D::CutSpec(spec) + }; + let mut mind = seeded(); + // Alone in the batch, and beside another cut it legitimately names. + assert_eq!( + refusal(admit(&mut mind, vec![depending("1", 1, &["1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("2", 1))])); + assert_eq!( + refusal(admit(&mut mind, vec![depending("1", 1, &["2", "1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + // An earlier revision of the same cut in the image does not make it a real dependency. + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + assert_eq!( + refusal(admit(&mut mind, vec![depending("1", 2, &["1"])])), + MindRefusal::UnknownDependency { cut: "1".into() } + ); + } + + #[test] + fn a_report_head_may_spell_its_commit_short_or_full() { + let full = "5f98228d0123456789abcdef0123456789abcdef"; + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1))])); + // The commit is spelled short and the head full, and the reverse. + let mut report = cut_report("1", 1); + report.range.head = epiphany_pipeline::Sha(full.into()); + committed(admit(&mut mind, vec![D::CutReport(report)])); + let mut report = cut_report("1", 2); + report.commits[0].sha = epiphany_pipeline::Sha(full.into()); + committed(admit(&mut mind, vec![D::CutReport(report)])); + // A head that is no commit's prefix, long or short, is outside. + let mut report = cut_report("1", 3); + report.range.head = epiphany_pipeline::Sha("5f98229d0123456789abcdef0123456789abcdef".into()); + assert!(matches!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { .. })); + let mut report = cut_report("1", 3); + report.commits[0].sha = epiphany_pipeline::Sha(full.into()); + report.range.head = epiphany_pipeline::Sha("5f98229".into()); + assert!(matches!(refusal(admit(&mut mind, vec![D::CutReport(report)])), MindRefusal::RangeOutsideCommits { .. })); + } + + #[test] + fn a_finding_defers_to_a_cut_spec() { + let finding_ref = r(K::Finding, &id("finding", "cut-1.s1.F1")); + let defer_to = |to: PipelineRef| resolution(finding_ref.clone(), ResolutionOutcome::Deferred { to }); + committed(admit(&mut world(), vec![defer_to(r(K::CutSpec, &id("cut_spec", "cut-1.r1")))])); + // A spec superseded since is no longer a place to defer to. + let mut mind = world(); + committed(admit(&mut mind, vec![ + D::CutSpec(cut_spec("1", 2)), + resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-1.r2"))])), + ])); + assert_eq!( + refusal(admit(&mut mind, vec![defer_to(r(K::CutSpec, &id("cut_spec", "cut-1.r1")))])), + MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: id("cut_spec", "cut-1.r1") } + ); + assert_eq!( + refusal(admit(&mut world(), vec![defer_to(r(K::Ruling, &id("ruling", "R1")))])), + MindRefusal::IncompatibleResolution { subject_kind: K::Finding, outcome: "Deferred".into() } + ); + } + #[test] fn verdict_vocabulary_binds_claims_to_findings_promises_and_mutations() { let mut mind = seeded(); @@ -2041,27 +2175,15 @@ mod tests { } #[test] - fn a_campaign_names_only_repos_this_mind_stewards() { + fn a_campaign_needs_no_stewardship() { let mut mind = opened(MemoryStore::new(), INSTANCE); - assert_eq!( - refusal(admit(&mut mind, vec![instance(INSTANCE), campaign(&[REPO])])), - MindRefusal::RepoNotStewarded { repo: REPO.into() } - ); - committed(admit(&mut mind, vec![instance(INSTANCE), stewardship(INSTANCE, REPO), campaign(&[REPO])])); - // A campaign of no repos is the organ's refusal, not a leaf bound: - // "every repo is stewarded" is vacuous and the campaign steers - // nothing. + committed(admit(&mut mind, vec![instance(INSTANCE), campaign(&[REPO])])); let D::Campaign(mut empty) = campaign(&[]) else { panic!() }; empty.slug = slug("other"); assert_eq!( refusal(admit(&mut mind, vec![D::Campaign(empty)])), MindRefusal::EmptyRepos { campaign: "other:campaign:self".into() } ); - // The stewardship may be held in the image: a later campaign over the - // same repo carries no stewardship of its own. - let D::Campaign(mut second) = campaign(&[REPO]) else { panic!() }; - second.slug = slug("second"); - committed(admit(&mut mind, vec![D::Campaign(second)])); let mut spec = cut_spec("1", 1); spec.repo = repo(OTHER_REPO); assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 274d902..7f9d4ca 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1046,6 +1046,9 @@ mod tests { // `faculty`: attribution selects, and grants nothing (ruling 18). assert_eq!(by("faculty", &["Soul"]), vec![id("ruling", "R2")]); assert!(!by("faculty", &["Hands"]).contains(&id("ruling", "R2"))); + // The renamed faculty is selectable by its new name only. + assert!(by("faculty", &["Life"]).is_empty()); + assert!(invalid("fields[0].values", "MindSteward")(&refused(&mind, &with(Selection::default(), any_of("faculty", &["MindSteward"]))))); // A repo is named by its identity, which is case-insensitive. assert_eq!(by("repo", &["GAMECULT/EPIPHANY"]), epiphany); @@ -1698,11 +1701,20 @@ mod tests { fn a_summary_is_bounded_whatever_the_lists() { let mut mind = seeded(); let long = |unit: &str, bytes: usize| unit.repeat(bytes); + // depends_on holds cut labels, each a cut that exists (its spec key stays within 64 bytes). + let dependencies: Vec = (0..8).map(|n| format!("{n}{}", long("d", 55))).collect(); + let dependency_specs: Vec = dependencies.iter().map(|label| D::CutSpec(cut_spec(label, 1))).collect(); + let batch = crate::admission::PipelineAdmissionBatch { + instance: slug(INSTANCE), + provenance: provenance(Faculty::SelfFaculty), + documents: dependency_specs, + }; + committed(mind.admit(batch, now())); let mut maximal = cut_spec("1", 1); maximal.title = long("t", 200).as_str().into(); maximal.branch = s(&long("b", 200)); maximal.base = Sha("a".repeat(40)); - maximal.depends_on = (0..8).map(|n| s(&format!("{n}{}", long("d", 199)))).collect(); + maximal.depends_on = dependencies.iter().map(|label| s(label)).collect(); let maximal_provenance = || PipelineProvenance { faculty: Faculty::SelfFaculty, agent: s(&long("a", 200)), @@ -1738,7 +1750,7 @@ mod tests { let sizes: Vec = headers(&page).iter().map(|summary| rmp_serde::to_vec_named(summary).unwrap().len()).collect(); let widest = *sizes.iter().max().unwrap(); - assert!(widest > 2_000, "the fixture is at its bounds, not a small one: {widest}"); + assert!(widest > 1_500, "the fixture is at its bounds, not a small one: {widest}"); assert!(widest <= SUMMARY_MAX_BYTES, "{widest} bytes over {SUMMARY_MAX_BYTES}"); let closed = headers(&page).iter().find(|summary| summary.id.id.0 == id("ruling", "S0")).unwrap(); assert!(matches!(&closed.status, PipelineStatusSummary::Resolved { outcome: ResolutionOutcome::Superseded { by }, .. } if by.len() == 8)); diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs index f08188d..3abbf16 100644 --- a/crates/huginn-mind/src/receipt.rs +++ b/crates/huginn-mind/src/receipt.rs @@ -36,7 +36,8 @@ pub enum Faculty { Imagination, Hands, Soul, - MindSteward, + #[serde(alias = "MindSteward")] + Life, Eyes, Operator, } @@ -295,6 +296,17 @@ mod tests { use std::sync::{Arc, Mutex}; use super::*; + + /// A receipt written before the rename spells the faculty `MindSteward`; + /// it decodes as `Life`, and `Life` is what a new one writes. + #[test] + fn life_is_the_faculty_and_the_old_spelling_decodes() { + let old = rmp_serde::to_vec_named("MindSteward").unwrap(); + assert_eq!(rmp_serde::from_slice::(&old).unwrap(), Faculty::Life); + let new = rmp_serde::to_vec_named(&Faculty::Life).unwrap(); + assert_eq!(rmp_serde::from_slice::(&new).unwrap(), "Life"); + } + use crate::fixtures::{INSTANCE, admit, committed, id, instance, now, opened, prepare, provenance, question, refusal, seed, slug}; use crate::mind::schema_cache; use crate::store::test_stores::MemoryStore; diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index ae369d0..1a33ac7 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -42,8 +42,8 @@ pub enum MindRefusal { InvalidChoice { ruling: String, choice: String }, QuoteWithoutOperator { ruling: String }, EmptyRepos { campaign: String }, - RepoNotStewarded { repo: String }, RepoNotInCampaign { repo: String }, + UnknownDependency { cut: String }, CutReportWithoutSpec { report: String }, SpecMismatch { field: String }, RangeOutsideCommits { head: String }, diff --git a/crates/huginn-mind/src/rows.rs b/crates/huginn-mind/src/rows.rs index d25f53c..108cc9c 100644 --- a/crates/huginn-mind/src/rows.rs +++ b/crates/huginn-mind/src/rows.rs @@ -39,7 +39,7 @@ pub(crate) enum Alias { Outcome, } -const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "MindSteward", "Eyes", "Operator"]; +const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "Life", "Eyes", "Operator"]; const SEVERITIES: [&str; 4] = ["Blocker", "High", "Medium", "Low"]; const CONFIDENCES: [&str; 2] = ["Confirmed", "Plausible"]; const ORIGINS: [&str; 2] = ["Introduced", "PreExisting"]; diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index 6d56bcb..045cde5 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -329,7 +329,7 @@ "Imagination", "Hands", "Soul", - "MindSteward", + "Life", "Eyes", "Operator" ] diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 6f4cc21..91eeaa3 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -393,7 +393,7 @@ "Imagination", "Hands", "Soul", - "MindSteward", + "Life", "Eyes", "Operator" ] @@ -1129,26 +1129,6 @@ "EmptyRepos" ] }, - { - "type": "object", - "properties": { - "RepoNotStewarded": { - "type": "object", - "properties": { - "repo": { - "type": "string" - } - }, - "required": [ - "repo" - ] - } - }, - "additionalProperties": false, - "required": [ - "RepoNotStewarded" - ] - }, { "type": "object", "properties": { @@ -1169,6 +1149,26 @@ "RepoNotInCampaign" ] }, + { + "type": "object", + "properties": { + "UnknownDependency": { + "type": "object", + "properties": { + "cut": { + "type": "string" + } + }, + "required": [ + "cut" + ] + } + }, + "additionalProperties": false, + "required": [ + "UnknownDependency" + ] + }, { "type": "object", "properties": {