diff --git a/crates/eureka-pipeline/golden/envelopes.txt b/crates/eureka-pipeline/golden/envelopes.txt index a5e6fc8..cbd66ac 100644 --- a/crates/eureka-pipeline/golden/envelopes.txt +++ b/crates/eureka-pipeline/golden/envelopes.txt @@ -10,6 +10,7 @@ # Epiphany ef956865 modulo that rename (verdict ...s2). Never regenerate to make # a failing test pass: a diff here is a wire change and needs its own ruling. # cut_spec.reads-and-symbol and finding.location-symbol were appended after 44c010c added CodeLocation.symbol and PipelineCutSpec.reads (ruling context-pack-read-anchors); no earlier line changed. +# target.repos was appended after 9298371 added PipelineTarget.repos (ruling campaign-repo-set-home); no earlier line changed. sample.campaign eureka-state:campaign:self epiphany.pipeline.campaign.v2 EpiphanyPipelineCampaignDocument 9185a4736c7567ac657572656b612d7374617465a57469746c65b5457572656b6120706970656c696e65207374617465a57265706f7391b147616d6543756c742f4570697068616e79ae776f726b696e675f6272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465aa7461726765745f646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 sample.target eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9186a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 sample.question eureka-state:question:Q1 epiphany.pipeline.question.v2 EpiphanyPipelineQuestionDocument 9189a863616d706169676eac657572656b612d7374617465a56c6162656ca25131a57469746c65b357686f206f776e73207468652073746174653fa87175657374696f6eb357686f206f776e73207468652073746174653fa76f7074696f6e739282a56c6162656ca141a474657874ab616e20696e7374616e636582a56c6162656ca142a474657874a661207265706fab7265636f6d6d656e646564a141a7646570656e647391a6437574203361a97261697365645f696e82a46b696e64a743757453706563a26964bf657572656b612d73746174653a6375745f737065633a6375742d33612e7231a861736b65645f6f6eaa323032362d30392d3135 @@ -43,3 +44,4 @@ finding.severity-low eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding ruling.authority-mind eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a44d696e64 cut_spec.reads-and-symbol eureka-state:cut_spec:cut-3a.r1 epiphany.pipeline.cut_spec.v2 EpiphanyPipelineCutSpecDocument 91de0013a863616d706169676eac657572656b612d7374617465a3637574a23361a87265766973696f6e01a57469746c65b2506970656c696e6520646f63756d656e7473a47265706fb147616d6543756c742f4570697068616e79a66272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465a462617365a83566393832323864aa646570656e64735f6f6e91a132a5666972737491ae526561642074686520737065632ea764656c657465739183a470617468a66f6c642e7273a56c696e657303a46e6f7465a464656164ab6b656570735f6d6f76657391ac636f6d6d6974206f776e6572a46164647391b5706970656c696e655f646f63756d656e74732e7273ac66696c655f6368616e6765739182a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab76616c75655f7479706573a66368616e6765a3616464a572656164739282a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e650aa8656e645f6c696e6514a673796d626f6caf506970656c696e6543757453706563a3776879b274686520646f63756d656e7420736861706582a86c6f636174696f6e83a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e651ea8656e645f6c696e65c0a3776879a86f6e65206c696e65ad617574686f726974795f6d617087a56f776e6572a4636f7265a6696e7075747391af747970656420646f63756d656e7473a76f75747075747391a9656e76656c6f706573ad646572697665645f737461746591ac64657269766564206b657973b1666f7262696464656e5f7772697465727391a34d4350ac7368617265645f706174687391a961646d697373696f6ead64656c6574696f6e5f6c696e65a36e2f61ac766572696669636174696f6e84a66275696c647391ab636172676f20636865636ba574657374739182a46e616d65a46b657973a470696e73af6f6e652064657269766564206b6579a86e656761746976659182a77061747465726ea75665633c75383ea573636f7065a9646f63756d656e7473a86f70657261746f7291a46e6f6e65a8657374696d61746588ab6c696e65735f6164646564cd0384ad6c696e65735f72656d6f76656400b2646570656e64656e636965735f616464656491a8736368656d617273b4646570656e64656e636965735f72656d6f76656490ad666f726d6174735f616464656491b66570697068616e792e706970656c696e652e2a2e7631af666f726d6174735f72656d6f76656490ad746172676574735f616464656490af746172676574735f72656d6f76656490a772756c696e677391b6657572656b612d73746174653a72756c696e673a5238a97175657374696f6e7391b8657572656b612d73746174653a7175657374696f6e3a5131 finding.location-symbol eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a448696768a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739184a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab2631313533303930343938b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564 +target.repos eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9187a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a57265706f7392b147616d6543756c742f4570697068616e79af47616d6543756c742f487567696e6ea3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index 07c6857..d19d192 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -491,16 +491,17 @@ macro_rules! unit_enums { )*}; } -/// RS-L closing fix 4: `campaign.repos` refuses two entries naming the same +/// RS-L closing fix 4: a repo list refuses two entries naming the same /// repository by identity, not by raw spelling, so `GameCult/Epiphany` and /// `gamecult/epiphany` in one list refuse exactly as one entry twice would. -/// This is a cross-element invariant on `PipelineCampaign` alone, not a -/// per-type format rule any other `value_types!` struct shares, so it is -/// wired in as that struct's one `=> extra` hook rather than folded into -/// `Bounded for OrgRepo` or the list bound in `bounded_field!`. -fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> { +/// This is a cross-element invariant on the lists alone, not a per-type format +/// rule any other `value_types!` struct shares, so it is wired in as the +/// `=> extra` hook of the two structs that carry a repo list +/// (`PipelineCampaign.repos` and `PipelineTarget.repos`) rather than folded +/// into `Bounded for OrgRepo` or the list bound in `bounded_field!`. +fn repos_are_distinct(repos: &[OrgRepo], at: &str) -> Result<(), PipelineRefusal> { let mut seen = std::collections::HashSet::new(); - for repo in &campaign.repos { + for repo in repos { if !seen.insert(repo.identity()) { return Err(PipelineRefusal::DuplicateRepo { field: format!("{at}.repos"), @@ -511,6 +512,14 @@ fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result< Ok(()) } +fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> { + repos_are_distinct(&campaign.repos, at) +} + +fn target_repos_are_distinct(target: &PipelineTarget, at: &str) -> Result<(), PipelineRefusal> { + repos_are_distinct(&target.repos, at) +} + /// The most lines one read anchor may span. const READ_WINDOW_MAX_LINES: u32 = 100; @@ -625,10 +634,12 @@ value_types! { } pub struct PipelineCampaign { slug: Slug, title: Title, repos: Vec[8], working_branch: Short, target_doc: DocRef } => campaign_repos_are_distinct + /// `repos`, when non-empty, is the campaign's whole repo set from this + /// revision on; empty keeps the previous set (ruling campaign-repo-set-home). pub struct PipelineTarget { campaign: Slug, revision: u32, invariants: Vec[32], not_in_scope: Vec[32], - canonical_implementations: Vec[16], doc: DocRef, - } + canonical_implementations: Vec[16], repos: Vec[16] = absent, doc: DocRef, + } => target_repos_are_distinct pub struct PipelineQuestion { campaign: Slug, label: Label, title: Title, question: Para, options: Vec[8], recommended: Label, depends: Vec[8], raised_in: Option, asked_on: Date, @@ -1238,7 +1249,7 @@ mod tests { (D::Target(PipelineTarget { campaign: slug(CAMPAIGN), revision: 2, invariants: vec![TargetInvariant { label: l("mind-admits"), statement: "Only admission writes.".into() }], - not_in_scope: vec!["Eve browsing".into()], canonical_implementations: vec!["CultLib".into()], doc: doc_ref(), + not_in_scope: vec!["Eve browsing".into()], canonical_implementations: vec!["CultLib".into()], repos: vec![], doc: doc_ref(), }), format!("{CAMPAIGN}:target:r2")), (D::Question(PipelineQuestion { campaign: slug(CAMPAIGN), label: l("Q1"), title: t("Who owns the state?"), question: "Who owns the state?".into(), @@ -1506,6 +1517,9 @@ mod tests { let mut f = finding_sample(); f.locations[0].symbol = Some("&1153090498".into()); out.push(("finding.location-symbol".into(), D::Finding(f))); + let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() }; + target.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Huginn".into())]; + out.push(("target.repos".into(), D::Target(target))); out } @@ -3163,6 +3177,47 @@ mod tests { assert_eq!(PipelineDocument::Finding(finding).validate(), Ok(())); } + #[test] + fn target_repos_absent_decode_and_round_trip() -> Result<()> { + // The MCP door: a JSON target with no `repos` key decodes to an empty list. + let PipelineDocument::Target(sample) = samples().remove(1).0 else { unreachable!() }; + let mut json = serde_json::to_value(PipelineDocument::Target(sample.clone()))?; + assert!(json["value"].get("repos").is_none(), "an empty repos is not written"); + let PipelineDocument::Target(decoded) = serde_json::from_value::(json.clone())? else { unreachable!() }; + assert!(decoded.repos.is_empty()); + assert!(!rmp_serde::to_vec_named(&PipelineDocument::Target(sample.clone()))?.windows(5).any(|window| window == b"repos")); + + let mut named = sample; + named.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Huginn".into())]; + let document = PipelineDocument::Target(named); + document.validate()?; + json = serde_json::to_value(&document)?; + assert_eq!(serde_json::from_value::(json)?, document); + let packed = rmp_serde::to_vec_named(&document)?; + assert_eq!(rmp_serde::from_slice::(&packed)?, document); + assert!(packed.windows(5).any(|window| window == b"repos"), "the repos key is written"); + Ok(()) + } + + #[test] + fn target_repos_bounded_and_distinct() { + let with_repos = |repos: Vec| { + let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() }; + target.repos = repos; + PipelineDocument::Target(target).validate() + }; + let distinct = |count: usize| (0..count).map(|index| OrgRepo(format!("GameCult/Repo{index}"))).collect::>(); + assert_eq!(with_repos(distinct(16)), Ok(())); + assert_eq!( + with_repos(distinct(17)), + Err(PipelineRefusal::FieldBound { field: "target.repos".into(), limit: 16, actual: 17 }) + ); + assert_eq!( + with_repos(vec![OrgRepo("GameCult/Huginn".into()), OrgRepo("gamecult/huginn".into())]), + Err(PipelineRefusal::DuplicateRepo { field: "target.repos".into(), repo: "gamecult/huginn".into() }) + ); + } + /// The marker needs an `Absent` spelling; the two impls say what absent is. #[test] fn absent_is_none_and_empty() { diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 8d55f90..96b39f9 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -39,7 +39,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, outcome_citations}; +use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, line_of, outcome_citations}; use crate::mind::{HuginnMindEpoch, Mind, unavailable}; use crate::persona::{self, HuginnPersonaEntry, PersonaPutOutcome}; use crate::receipt::{self, CommitOutcome, PipelineProvenance}; @@ -456,6 +456,15 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { D::Target(target) => { let predecessor = D::Target(eureka_pipeline::PipelineTarget { revision: target.revision.wrapping_sub(1), ..target.clone() }); revision_rule(docs, K::Target, key, target.revision, &predecessor)?; + // A revision past the first may not empty the repo set its predecessor + // named: empty means "keep the previous set", and that would silently + // narrow it to the campaign's opening set (ruling campaign-repo-set-home). + if target.repos.is_empty() && target.revision > 1 { + let named = matches!(docs.find(K::Target, &pipeline_key(&predecessor)?), Some(D::Target(previous)) if !previous.repos.is_empty()); + if named { + return Err(MindRefusal::EmptyRepos { campaign: key.into() }); + } + } unique_labels("target.invariants", target.invariants.iter().map(|invariant| invariant.label.0.as_str())) } D::Question(question) => { @@ -492,7 +501,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { let Some(campaign) = campaign else { return Err(MindRefusal::MissingReference { kind: K::Campaign, id: spec.campaign.0.clone() }); }; - if !campaign.repos.contains(&spec.repo) { + if !docs.repos_in_force(campaign).contains(&spec.repo) { return Err(MindRefusal::RepoNotInCampaign { repo: spec.repo.0.clone() }); } for ruling in &spec.rulings { @@ -589,7 +598,7 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { actual: stewardship.sequence, }); } - if docs.stewardships_of(mind, &stewardship.repo, None).iter().any(|(other, _)| *other != key) { + if docs.rival_in_force(key, &staged.document).is_some() { return Err(MindRefusal::AlreadyStewarded { repo: stewardship.repo.0.clone() }); } Ok(()) @@ -761,11 +770,16 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M && subject_kind == K::Resolution && let Some(D::Resolution(reinstating)) = docs.find(K::Resolution, subject_id) && let Some(base) = docs.find(reinstating.subject.kind, &reinstating.subject.id.0) - && let Some(later) = docs.later_in_force(base) + && let Some(rival) = docs.rival_in_force(&reinstating.subject.id.0, base) { + // The repo already has a steward, earlier or later: the admission + // refusal for the same fact, from the same owner. + if let D::Stewardship(base) = base { + return Err(MindRefusal::AlreadyStewarded { repo: base.repo.0.clone() }); + } return Err(MindRefusal::WouldReinstateOverLater { subject: reinstating.subject.id.0.clone(), - later: later.into(), + later: rival.into(), }); } // Withdrawing a run's closure puts the run back in force, and a run in @@ -785,11 +799,12 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M } } match &resolution.outcome { - ResolutionOutcome::Superseded { by } if subject_kind == K::CutSpec => { - let Some(D::CutSpec(subject)) = docs.find(K::CutSpec, subject_id) else { return Ok(()) }; + ResolutionOutcome::Superseded { by } => { + let Some(subject) = docs.find(subject_kind, subject_id) else { return Ok(()) }; + let line = line_of(subject); for supersessor in by { - if let Some(D::CutSpec(spec)) = docs.find(K::CutSpec, &supersessor.id.0) - && spec.cut != subject.cut + if let Some(next) = docs.find(subject_kind, &supersessor.id.0) + && (line.is_none() || line != line_of(next)) { return Err(incompatible()); } @@ -1541,7 +1556,8 @@ mod tests { /// Q19 A's rationale, which the cap alone does not carry: a withdrawal /// never re-raises an earlier record over a later one. The repo went away /// and came back, so withdrawing the hand-off's withdrawal would put the - /// first assignment back in force beside the second. + /// first assignment back in force beside the second: the repo already has + /// a steward. #[test] fn a_withdrawal_does_not_reinstate_a_stewardship_over_a_later_one() { let mut mind = seeded(); @@ -1549,12 +1565,11 @@ mod tests { let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() }; back.handed_on = eureka_pipeline::Date("2026-09-17".into()); committed(admit(&mut mind, vec![D::HandOff(back)])); - let first = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"); let again = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2"); let withdrawal = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1"); assert_eq!( refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])), - MindRefusal::WouldReinstateOverLater { subject: first, later: again.clone() } + MindRefusal::AlreadyStewarded { repo: REPO.into() } ); let docs = Docs::from_image(mind.envelopes()).unwrap(); let in_force = docs.stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| *key).collect::>(); @@ -1672,6 +1687,33 @@ mod tests { assert_eq!(in_force, vec![stewardship_key(1)]); } + /// One assignment stands per repo, whichever record a withdrawal would put + /// back: reinstating the later of two withdrawn assignments while the + /// earlier is back in force is refused, and the next assignment then meets + /// exactly the one that stands. + #[test] + fn a_withdrawal_does_not_reinstate_a_stewardship_beside_an_earlier_one() { + let mut mind = seeded(); + let n1 = stewardship_key(1); + let n2 = stewardship_key(2); + let first = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1"); + let second = format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n2.n1"); + committed(admit(&mut mind, vec![resolution(r(K::Stewardship, &n1), withdrawn())])); + committed(admit(&mut mind, vec![stewardship_n(INSTANCE, REPO, 2)])); + committed(admit(&mut mind, vec![resolution(r(K::Stewardship, &n2), withdrawn())])); + committed(admit(&mut mind, vec![resolution(r(K::Resolution, &first), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &second), withdrawn())])), + MindRefusal::AlreadyStewarded { repo: REPO.into() } + ); + let in_force = docs_of(&mind).stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| key.to_string()).collect::>(); + assert_eq!(in_force, vec![n1], "the reinstated assignment stands alone"); + assert_eq!( + refusal(admit(&mut mind, vec![stewardship_n(INSTANCE, REPO, 3)])), + MindRefusal::AlreadyStewarded { repo: REPO.into() } + ); + } + /// And it reads the batch: the assignment that stands later than the one /// a withdrawal would reinstate may be landing beside that withdrawal, /// derived by a hand-off in the same batch. @@ -1685,7 +1727,7 @@ mod tests { hand_off_on(OTHER_INSTANCE, INSTANCE, "2026-09-17"), resolution(r(K::Resolution, &withdrawal), withdrawn()), ])), - MindRefusal::WouldReinstateOverLater { subject: stewardship_key(1), later: stewardship_key(2) } + MindRefusal::AlreadyStewarded { repo: REPO.into() } ); } @@ -2704,6 +2746,321 @@ mod tests { assert_eq!(refusal(admit(&mut mind, vec![D::CutSpec(spec)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); } + /// A target revision naming repos, and the supersession of its predecessor. + fn repo_revision(revision: u32, repos: &[&str]) -> Vec { + let D::Target(mut named) = target(revision, &[INVARIANT]) else { panic!() }; + named.repos = repos.iter().map(|name| repo(name)).collect(); + let previous = r(K::Target, &id("target", &format!("r{}", revision - 1))); + let this = r(K::Target, &id("target", &format!("r{revision}"))); + vec![D::Target(named), resolution(previous, superseded(&[this]))] + } + + fn spec_on(cut: &str, repo_name: &str) -> D { + let mut spec = cut_spec(cut, 1); + spec.repo = repo(repo_name); + D::CutSpec(spec) + } + + #[test] + fn a_target_revision_adds_a_repo_a_spec_may_name() { + let mut mind = seeded(); + assert_eq!(refusal(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); + committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO]))); + committed(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])); + + // The same revision, supersession and spec in one batch. + let mut fresh = seeded(); + let mut batch = repo_revision(2, &[REPO, OTHER_REPO]); + batch.push(spec_on("1", OTHER_REPO)); + committed(admit(&mut fresh, batch)); + } + + #[test] + fn a_target_revision_removes_a_repo() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![spec_on("1", REPO)])); + committed(admit(&mut mind, repo_revision(2, &[OTHER_REPO]))); + let spec_id = id("cut_spec", "cut-1.r1"); + let spec_view = mind.view(&r(K::CutSpec, &spec_id)).expect("a valid ref").expect("the spec is held"); + assert_eq!(spec_view.status, crate::query::PipelineStatus::InForce, "spec 1 r1 stays in force"); + committed(admit(&mut mind, vec![D::CutReport(cut_report("1", 1))])); + committed(admit(&mut mind, vec![resolution(r(K::CutSpec, &spec_id), withdrawn())])); + + let mut second = cut_spec("1", 2); + second.repo = repo(REPO); + let revised = id("cut_spec", "cut-1.r2"); + assert_eq!( + refusal(admit(&mut mind, vec![D::CutSpec(second), resolution(r(K::CutSpec, &spec_id), superseded(&[r(K::CutSpec, &revised)]))])), + MindRefusal::RepoNotInCampaign { repo: REPO.into() } + ); + committed(admit(&mut mind, vec![spec_on("2", OTHER_REPO)])); + } + + #[test] + fn a_target_revision_cannot_silently_drop_its_repos() { + let mut mind = seeded(); + committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO]))); + assert_eq!( + refusal(admit(&mut mind, repo_revision(3, &[]))), + MindRefusal::EmptyRepos { campaign: id("target", "r3") } + ); + // Nothing was written: the restating revision takes the same key. + committed(admit(&mut mind, repo_revision(3, &[REPO, OTHER_REPO]))); + // An empty revision whose predecessor named none keeps the campaign's set. + let mut plain = seeded(); + committed(admit(&mut plain, repo_revision(2, &[]))); + } + + #[test] + fn a_repo_revision_replays_idempotently() { + let mut mind = seeded(); + let (first, _) = committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO]))); + assert_eq!( + admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO])), + PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: first } + ); + committed(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])); + } + + /// Another campaign, opened with `repos`, and its own target revision 1 naming `named`. + fn other_campaign(repos: &[&str], named: &[&str]) -> Vec { + let D::Campaign(mut campaign) = campaign(repos) else { panic!("a campaign") }; + campaign.slug = "other-campaign".into(); + let D::Target(mut target) = target(1, &[INVARIANT]) else { panic!("a target") }; + target.campaign = "other-campaign".into(); + target.repos = named.iter().map(|name| repo(name)).collect(); + vec![D::Campaign(campaign), D::Target(target)] + } + + #[test] + fn another_campaigns_target_does_not_widen_membership() { + let mut mind = seeded(); + committed(admit(&mut mind, other_campaign(&[REPO], &[REPO, OTHER_REPO]))); + assert_eq!(refusal(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])), MindRefusal::RepoNotInCampaign { repo: OTHER_REPO.into() }); + } + + #[test] + fn a_target_is_superseded_only_within_its_campaign() { + let mut mind = seeded(); + committed(admit(&mut mind, repo_revision(2, &[REPO, OTHER_REPO]))); + committed(admit(&mut mind, other_campaign(&[REPO], &[]))); + let foreign = r(K::Target, "other-campaign:target:r1"); + assert_eq!( + refusal(admit(&mut mind, vec![resolution(r(K::Target, &id("target", "r2")), superseded(&[foreign]))])), + MindRefusal::IncompatibleResolution { subject_kind: K::Target, outcome: "Superseded".into() } + ); + // Nothing was written: r2 still names the repos, and its own revision still lands. + committed(admit(&mut mind, vec![spec_on("1", OTHER_REPO)])); + committed(admit(&mut mind, repo_revision(3, &[REPO, OTHER_REPO]))); + } + + /// A target of the first campaign (`CAMPAIGN`) at `revision`, naming no repos. + fn own_target(revision: u32) -> D { + target(revision, &[INVARIANT]) + } + + const FOREIGN_TARGET: &str = "other-campaign:target:r1"; + + fn supersession_refused(outcome: PipelineAdmissionOutcome, subject_kind: K) -> bool { + refusal(outcome) == MindRefusal::IncompatibleResolution { subject_kind, outcome: "Superseded".into() } + } + + #[test] + fn a_foreign_target_in_the_same_batch_does_not_supersede() { + let mut mind = seeded(); + let mut batch = other_campaign(&[REPO], &[]); + batch.push(resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, FOREIGN_TARGET)]))); + assert!(supersession_refused(admit(&mut mind, batch), K::Target)); + } + + #[test] + fn every_supersessor_of_a_target_is_checked_whichever_is_foreign() { + for foreign_first in [false, true] { + let mut mind = seeded(); + committed(admit(&mut mind, other_campaign(&[REPO], &[]))); + let own = r(K::Target, &id("target", "r2")); + let foreign = r(K::Target, FOREIGN_TARGET); + let by = if foreign_first { [foreign, own] } else { [own, foreign] }; + let batch = vec![own_target(2), resolution(r(K::Target, &id("target", "r1")), superseded(&by))]; + assert!(supersession_refused(admit(&mut mind, batch), K::Target), "foreign first: {foreign_first}"); + } + } + + #[test] + fn a_new_campaigns_target_in_its_own_batch_is_not_closed_by_a_foreign_one() { + let mut mind = seeded(); + let mut batch = other_campaign(&[REPO], &[]); + batch.push(resolution(r(K::Target, FOREIGN_TARGET), superseded(&[r(K::Target, &id("target", "r1"))]))); + assert!(supersession_refused(admit(&mut mind, batch), K::Target)); + } + + #[test] + fn a_cut_spec_is_superseded_only_within_its_campaign() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![spec_on("1", REPO)])); + let mut batch = other_campaign(&[REPO], &[]); + let D::CutSpec(mut foreign) = spec_on("1", REPO) else { panic!("a spec") }; + foreign.campaign = "other-campaign".into(); + batch.push(D::CutSpec(foreign)); + committed(admit(&mut mind, batch)); + let lone = vec![resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, "other-campaign:cut_spec:cut-1.r1")]))]; + assert!(supersession_refused(admit(&mut mind, lone), K::CutSpec)); + // Nothing was written: this campaign's own revision still lands. + let D::CutSpec(mut next) = spec_on("1", REPO) else { panic!("a spec") }; + next.revision = 2; + let this = r(K::CutSpec, &id("cut_spec", "cut-1.r2")); + committed(admit(&mut mind, vec![D::CutSpec(next), resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[this]))])); + } + + #[test] + fn a_ruling_is_superseded_by_a_ruling_of_any_campaign() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("old"))])); + let mut batch = other_campaign(&[REPO], &[]); + let mut foreign = ruling("new"); + foreign.campaign = "other-campaign".into(); + batch.push(D::Ruling(foreign)); + batch.push(resolution(r(K::Ruling, &id("ruling", "old")), superseded(&[r(K::Ruling, "other-campaign:ruling:new")]))); + committed(admit(&mut mind, batch)); + } + + /// The slug that shares its dash prefix with `CAMPAIGN` (`eureka-state`). + const RIVAL: &str = "eureka-substrate"; + + /// Revision `revision` of the rival campaign's target, with its campaign when it is + /// the first and the supersession of its predecessor when it is not. + fn rival_revision(revision: u32) -> Vec { + let D::Target(mut named) = target(revision, &[INVARIANT]) else { panic!("a target") }; + named.campaign = RIVAL.into(); + let mut batch = Vec::new(); + if revision == 1 { + let D::Campaign(mut opened) = campaign(&[REPO]) else { panic!("a campaign") }; + opened.slug = RIVAL.into(); + batch.push(D::Campaign(opened)); + } + batch.push(D::Target(named)); + if revision > 1 { + let previous = r(K::Target, &format!("{RIVAL}:target:r{}", revision - 1)); + batch.push(resolution(previous, superseded(&[r(K::Target, &format!("{RIVAL}:target:r{revision}"))]))); + } + batch + } + + /// Both campaigns at revision 3, so each one's target in force is above revision 1. + fn both_campaigns_at_revision_three() -> Mind { + let mut mind = seeded(); + for revision in 2..=3 { + committed(admit(&mut mind, repo_revision(revision, &[REPO]))); + } + for revision in 1..=3 { + committed(admit(&mut mind, rival_revision(revision))); + } + mind + } + + #[test] + fn a_target_is_not_superseded_by_a_campaign_sharing_its_prefix_or_a_higher_revision() { + for (subject, supersessor) in [ + (id("target", "r3"), format!("{RIVAL}:target:r3")), + (format!("{RIVAL}:target:r3"), id("target", "r3")), + ] { + let mut mind = both_campaigns_at_revision_three(); + let lone = vec![resolution(r(K::Target, &subject), superseded(&[r(K::Target, &supersessor)]))]; + assert!(supersession_refused(admit(&mut mind, lone), K::Target), "{subject} by {supersessor}"); + } + // A foreign revision above the subject's is no likelier to pass. + let mut mind = seeded(); + committed(admit(&mut mind, repo_revision(2, &[REPO]))); + for revision in 1..=4 { + committed(admit(&mut mind, rival_revision(revision))); + } + let lone = vec![resolution(r(K::Target, &id("target", "r2")), superseded(&[r(K::Target, &format!("{RIVAL}:target:r4"))]))]; + assert!(supersession_refused(admit(&mut mind, lone), K::Target)); + } + + #[test] + fn a_stewardship_is_superseded_only_on_its_own_instance_and_repo_line() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![stewardship(INSTANCE, OTHER_REPO)])); + let epiphany = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"); + let huginn = format!("{INSTANCE}:stewardship:gamecult_-huginn.n1"); + let lone = vec![resolution(r(K::Stewardship, &epiphany), superseded(&[r(K::Stewardship, &huginn)]))]; + assert!(supersession_refused(admit(&mut mind, lone), K::Stewardship)); + // Nothing was written: the repo's own next assignment still supersedes it. + let next = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2"); + committed(admit(&mut mind, vec![ + stewardship_n(INSTANCE, REPO, 2), + resolution(r(K::Stewardship, &epiphany), superseded(&[r(K::Stewardship, &next)])), + ])); + } + + /// Re-homing work is how Self routes it (ruling `supersession-lineage-3`): + /// a gap follow-up of one campaign is superseded by its successor in the + /// campaign that owns the work now, and the typed edge survives. + #[test] + fn a_follow_up_is_superseded_by_one_re_homed_to_another_campaign() { + let mut mind = seeded(); + let source = r(K::Target, &id("target", "r1")); + committed(admit(&mut mind, vec![follow_up("gap-session-id", source.clone())])); + let mut batch = rival_revision(1); + let D::FollowUp(mut foreign) = follow_up("gap-session-id", source) else { panic!("a follow-up") }; + foreign.campaign = RIVAL.into(); + batch.push(D::FollowUp(foreign)); + batch.push(resolution( + r(K::FollowUp, &id("follow_up", "gap-session-id")), + superseded(&[r(K::FollowUp, &format!("{RIVAL}:follow_up:gap-session-id"))]), + )); + committed(admit(&mut mind, batch)); + let docs = docs_of(&mind); + assert!(!docs.in_force(K::FollowUp, &id("follow_up", "gap-session-id"))); + assert!(docs.in_force(K::FollowUp, &format!("{RIVAL}:follow_up:gap-session-id"))); + } + + #[test] + fn supersession_on_its_own_line_commits_for_every_kind() { + let source = r(K::Target, &id("target", "r1")); + let mut mind = seeded(); + committed(admit(&mut mind, repo_revision(2, &[REPO]))); + let mut mind = seeded(); + committed(admit(&mut mind, vec![spec_on("1", REPO)])); + let D::CutSpec(mut next) = spec_on("1", REPO) else { panic!("a spec") }; + next.revision = 2; + committed(admit(&mut mind, vec![ + D::CutSpec(next), + resolution(r(K::CutSpec, &id("cut_spec", "cut-1.r1")), superseded(&[r(K::CutSpec, &id("cut_spec", "cut-1.r2"))])), + ])); + let mut mind = seeded(); + committed(admit(&mut mind, vec![follow_up("old", source.clone())])); + committed(admit(&mut mind, vec![ + follow_up("new", source), + resolution(r(K::FollowUp, &id("follow_up", "old")), superseded(&[r(K::FollowUp, &id("follow_up", "new"))])), + ])); + let mut mind = seeded(); + let epiphany = format!("{INSTANCE}:stewardship:gamecult_-epiphany"); + committed(admit(&mut mind, vec![ + stewardship_n(INSTANCE, REPO, 2), + resolution(r(K::Stewardship, &format!("{epiphany}.n1")), superseded(&[r(K::Stewardship, &format!("{epiphany}.n2"))])), + ])); + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("old"))])); + committed(admit(&mut mind, vec![ + D::Ruling(ruling("new")), + resolution(r(K::Ruling, &id("ruling", "old")), superseded(&[r(K::Ruling, &id("ruling", "new"))])), + ])); + } + + #[test] + fn a_ruling_of_one_campaign_is_superseded_by_a_ruling_of_a_prefix_sharing_campaign() { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::Ruling(ruling("old"))])); + let mut batch = rival_revision(1); + let mut foreign = ruling("new"); + foreign.campaign = RIVAL.into(); + batch.push(D::Ruling(foreign)); + batch.push(resolution(r(K::Ruling, &id("ruling", "old")), superseded(&[r(K::Ruling, &format!("{RIVAL}:ruling:new"))]))); + committed(admit(&mut mind, batch)); + } + #[test] fn a_hand_off_derives_this_minds_side_only() { let key = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.gamecult_-epiphany.2026-09-16"); diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index 2427338..f7af836 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -7,7 +7,7 @@ use cultcache_rs::CultCacheEnvelope; use eureka_pipeline::{ - OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome, + Label, OrgRepo, PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome, RunOperator, Short, Slug, }; use schemars::JsonSchema; @@ -182,6 +182,23 @@ impl Docs { }) } + /// The repos a campaign's cuts may name: the repo set of the highest-revision + /// in-force target of `campaign.slug` that names one, else the campaign's own + /// opening set. The one owner of membership (ruling campaign-repo-set-home). + pub(crate) fn repos_in_force<'a>(&'a self, campaign: &'a PipelineCampaign) -> &'a [OrgRepo] { + self.of_kind(PipelineKind::Target) + .filter_map(|(key, document)| match document { + PipelineDocument::Target(target) + if target.campaign == campaign.slug && !target.repos.is_empty() && self.in_force(PipelineKind::Target, key) => + { + Some(target) + } + _ => None, + }) + .max_by_key(|target| target.revision) + .map_or(campaign.repos.as_slice(), |target| target.repos.as_slice()) + } + /// The resolution that closes the document, if any: the views' status, /// derived where admission derives whether there is one at all. pub(crate) fn closing_resolution(&self, kind: PipelineKind, id: &str) -> Option<(&str, &PipelineResolution)> { @@ -207,12 +224,17 @@ impl Docs { .collect() } - /// The in-force record that stands later than `base` in the scope `base` - /// is sequenced in, if any: what a withdrawal that put `base` back in - /// force would leave standing beside it. - pub(crate) fn later_in_force(&self, base: &PipelineDocument) -> Option<&str> { + /// The in-force record, other than `base_key`, that cannot stand beside + /// `base` on its line: the one question "may this record be in force" + /// asks, whether `base` is being admitted (a stewardship's + /// `AlreadyStewarded`) or put back in force by a withdrawal. A line holds + /// one stewardship at a time, so any other stands in its way; a revision + /// is only displaced by a later one. + pub(crate) fn rival_in_force(&self, base_key: &str, base: &PipelineDocument) -> Option<&str> { let kind = base.kind(); - self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key) + self.of_kind(kind) + .find(|(key, other)| *key != base_key && rivals(base, other) && self.in_force(kind, key)) + .map(|(key, _)| key) } /// The in-force run, other than `run_key`, that holds `claim`: the one @@ -282,22 +304,57 @@ impl Docs { } } -/// Whether `other` is a later record than `base` in the scope `base` is -/// sequenced in: `(instance, repo)` for a stewardship, the campaign for a -/// target and the cut for a cut spec. Kinds that carry no sequence have no -/// scope and no order, and a resolution is never a base here: the cap above -/// refuses the withdrawal that would reinstate one before the question of a -/// later record arises. -fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool { +/// The line a record belongs to (rulings `supersession-lineage-2` and `-3`): the one place +/// lineage is decided. A record supersedes, and is reinstated over, only a record +/// of its own line. `None` is a kind the resolution matrix never lets be +/// superseded; the match names every kind, so a new kind fails to compile until +/// its line is stated. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Lineage<'a> { + /// A target: the campaign. + Campaign(&'a Slug), + /// A cut spec: the campaign and the cut. + Cut(&'a Slug, &'a Label), + /// A stewardship: the instance and the repo. + Assignment(&'a Slug, &'a OrgRepo), + /// A ruling or a follow-up: operator directions and re-homed work cut + /// across campaigns. + Global, +} + +pub(crate) fn line_of(document: &PipelineDocument) -> Option> { use PipelineDocument as D; + match document { + D::Target(target) => Some(Lineage::Campaign(&target.campaign)), + D::CutSpec(spec) => Some(Lineage::Cut(&spec.campaign, &spec.cut)), + D::Stewardship(stewardship) => Some(Lineage::Assignment(&stewardship.instance, &stewardship.repo)), + D::Ruling(_) | D::FollowUp(_) => Some(Lineage::Global), + D::Campaign(_) + | D::Question(_) + | D::CutReport(_) + | D::Verdict(_) + | D::Finding(_) + | D::Resolution(_) + | D::Instance(_) + | D::HandOff(_) + | D::Run(_) => None, + } +} + +/// Whether `other` cannot stand in force beside `base`, being on `base`'s line: +/// any other stewardship of an instance and repo, a later revision of a target +/// or cut spec. A resolution is never a base here: the cap in `resolution_rule` +/// refuses the withdrawal that would reinstate one first. +fn rivals(base: &PipelineDocument, other: &PipelineDocument) -> bool { + use PipelineDocument as D; + let Some(line) = line_of(base) else { return false }; + if line_of(other) != Some(line) { + return false; + } match (base, other) { - (D::Stewardship(base), D::Stewardship(other)) => { - other.instance == base.instance && other.repo == base.repo && other.sequence > base.sequence - } - (D::Target(base), D::Target(other)) => other.campaign == base.campaign && other.revision > base.revision, - (D::CutSpec(base), D::CutSpec(other)) => { - other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision - } + (D::Stewardship(_), D::Stewardship(_)) => true, + (D::Target(base), D::Target(other)) => other.revision > base.revision, + (D::CutSpec(base), D::CutSpec(other)) => other.revision > base.revision, _ => false, } } @@ -484,3 +541,51 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel } edges } + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::*; + use PipelineDocument as D; + use PipelineKind as K; + + fn held(kind: K, key: String, document: D) -> Held { + Held { kind, key, document } + } + + fn named(campaign: &str, revision: u32, repos: &[&str]) -> (String, D) { + let D::Target(mut target) = target(revision, &[INVARIANT]) else { panic!("a target") }; + target.campaign = campaign.into(); + target.repos = repos.iter().map(|name| repo(name)).collect(); + (format!("{campaign}:target:r{revision}"), D::Target(target)) + } + + /// Membership is the highest-revision target of this campaign that is in + /// force and names repos. The image is built by hand because admission + /// cannot reach it: a revision above 1 supersedes its predecessor in its + /// own batch, a target is superseded only within its campaign, and an + /// empty revision after a repo-naming one is refused, so through admission + /// a closed repo-naming target is always older than the in-force one. + /// + /// Taking the lowest revision instead of the highest changes nothing there + /// either (one target per campaign is in force), so that mutation is + /// equivalent and not pinned. + #[test] + fn repos_in_force_is_this_campaigns_in_force_naming_target() { + let D::Campaign(this) = campaign(&[REPO]) else { panic!("a campaign") }; + let (in_force_key, in_force) = named(CAMPAIGN, 1, &[REPO, OTHER_REPO]); + let (closed_key, closed) = named(CAMPAIGN, 2, &["GameCult/Closed"]); + let (foreign_key, foreign) = named("other-campaign", 3, &["GameCult/Foreign"]); + let closing = resolution(r(K::Target, &closed_key), superseded(&[r(K::Target, &in_force_key)])); + let docs = Docs { + image: vec![ + held(K::Target, in_force_key, in_force), + held(K::Target, closed_key, closed), + held(K::Target, foreign_key, foreign), + held(K::Resolution, id("resolution", "target.r2.n1"), closing), + ], + batch: Vec::new(), + }; + assert_eq!(docs.repos_in_force(&this), [repo(REPO), repo(OTHER_REPO)], "neither the closed higher revision nor another campaign's higher target decides"); + } +} diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index 86cb827..77dbd45 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -155,6 +155,7 @@ pub(crate) fn target(revision: u32, invariants: &[&str]) -> PipelineDocument { .collect(), not_in_scope: vec![], canonical_implementations: vec![], + repos: vec![], doc: doc_ref(), }) } diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index e85113a..150f652 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -41,6 +41,7 @@ pub enum MindRefusal { InvalidOptions { question: String }, InvalidChoice { ruling: String, choice: String }, QuoteWithoutOperator { ruling: String }, + /// `campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos. EmptyRepos { campaign: String }, RepoNotInCampaign { repo: String }, UnknownDependency { cut: String }, diff --git a/crates/mind-body/src/testkit.rs b/crates/mind-body/src/testkit.rs index d00db01..2c27bf6 100644 --- a/crates/mind-body/src/testkit.rs +++ b/crates/mind-body/src/testkit.rs @@ -214,6 +214,7 @@ fn target() -> PipelineDocument { invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }], not_in_scope: vec![], canonical_implementations: vec![], + repos: vec![], doc: doc_ref(), }) } diff --git a/crates/mind-body/tests/wake.rs b/crates/mind-body/tests/wake.rs index c535391..58ffe12 100644 --- a/crates/mind-body/tests/wake.rs +++ b/crates/mind-body/tests/wake.rs @@ -116,6 +116,7 @@ fn seed() -> Vec { invariants: vec![TargetInvariant { label: "run-is-the-grant".into(), statement: "A run is the grant.".into() }], not_in_scope: vec![], canonical_implementations: vec![], + repos: vec![], doc: doc_ref(), }), ] diff --git a/schemas/cultnet/epiphany.pipeline.target.v2.schema.json b/schemas/cultnet/epiphany.pipeline.target.v2.schema.json index 32a5ab3..ee7fd68 100644 --- a/schemas/cultnet/epiphany.pipeline.target.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.target.v2.schema.json @@ -37,6 +37,11 @@ "maxLength": 1000, "type": "string" }, + "OrgRepo": { + "description": "A GitHub repository as `Org/Repo`, at most 140 UTF-8 bytes (39 + 1 +\n100). The owner is 1 to 39 `[A-Za-z0-9-]` bytes with no leading or\ntrailing hyphen; the repo is 1 to 100 `[A-Za-z0-9._-]` bytes, is never\n`.` or `..`, and does not end in `.git`, ASCII-case-insensitively.\nIdentity is case-insensitive: `GameCult/Epiphany` and `gamecult/epiphany`\nare the same repository.", + "maxLength": 140, + "type": "string" + }, "Sha": { "description": "A git commit id: 7-40 lowercase hex characters.", "maxLength": 40, @@ -70,6 +75,7 @@ }, "$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.target.v2.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "`repos`, when non-empty, is the campaign's whole repo set from this\nrevision on; empty keeps the previous set (ruling campaign-repo-set-home).", "properties": { "campaign": { "$ref": "#/$defs/Slug" @@ -98,6 +104,13 @@ "maxItems": 32, "type": "array" }, + "repos": { + "items": { + "$ref": "#/$defs/OrgRepo" + }, + "maxItems": 16, + "type": "array" + }, "revision": { "format": "uint32", "minimum": 0, diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index c075586..77ee834 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -1542,6 +1542,7 @@ ] }, "PipelineTarget": { + "description": "`repos`, when non-empty, is the campaign's whole repo set from this\nrevision on; empty keeps the previous set (ruling campaign-repo-set-home).", "type": "object", "properties": { "campaign": { @@ -1571,6 +1572,13 @@ }, "maxItems": 32 }, + "repos": { + "type": "array", + "items": { + "$ref": "#/$defs/OrgRepo" + }, + "maxItems": 16 + }, "revision": { "type": "integer", "format": "uint32", diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 4fd5482..28d1fa2 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -1152,6 +1152,7 @@ ] }, { + "description": "`campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.", "type": "object", "properties": { "EmptyRepos": { @@ -3801,6 +3802,7 @@ ] }, "PipelineTarget": { + "description": "`repos`, when non-empty, is the campaign's whole repo set from this\nrevision on; empty keeps the previous set (ruling campaign-repo-set-home).", "type": "object", "properties": { "campaign": { @@ -3830,6 +3832,13 @@ }, "maxItems": 32 }, + "repos": { + "type": "array", + "items": { + "$ref": "#/$defs/OrgRepo" + }, + "maxItems": 16 + }, "revision": { "type": "integer", "format": "uint32",