Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
4442e1acd9 Stewardship reinstatement: the repo already has a steward is one refusal, earlier or later rival
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:27:00 +02:00
Meta Vens
e1332fbd30 Stewardship: rival_in_force is the one owner of one-in-force; admission and reinstatement ask it
AlreadyStewarded and the withdrawal-over-later check each decided it with their own comparison, and disagreed when a later record was reinstated beside an earlier one. later_than's stewardship arm is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:58:06 +02:00
Meta Vens
383600c234 Lineage: a follow-up's line is global, as a ruling's; re-homing work across campaigns supersedes (ruling supersession-lineage-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:56:58 +02:00
Meta Vens
d289fcb09a Restore LF line endings in admission.rs and docs.rs (my edits wrote CRLF)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:44:51 +02:00
Meta Vens
e4f8434f61 Supersession tests: every kind on its line, foreign lines refused, prefix-sharing campaigns at revisions above 1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:12:10 +02:00
Meta Vens
5d9a845e69 Supersession: line_of(record) is the one lineage; same_lineage and later_than call it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:04:43 +02:00
Meta Vens
adda2bf9ae same_lineage: name PipelineDocument locally (528a7f7 does not build)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:40:19 +02:00
Meta Vens
528a7f7e5f Supersession: one lineage rule for targets, cut_specs and rulings; tests pin every path
same_lineage replaces the Target and CutSpec arms. A target is superseded only within its campaign, a cut_spec within its campaign and cut, a ruling by any ruling (ruling supersession-lineage). Tests cover same-batch, first/last supersessor, subject in batch, cut_spec cross-campaign, and ruling cross-campaign.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:38:22 +02:00
Meta Vens
86adbc85bd repos_in_force: tests pin the campaign filter and the in-force filter
Docs-level image holds a closed higher revision and another campaign's higher target; admission-level test for the foreign target. HM3 (min revision) is equivalent and noted in the test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:16:26 +02:00
Meta Vens
bb4ef404dc Resolution matrix: a target is superseded only by a target of its own campaign
A lone foreign supersession closed a repo-naming target and narrowed membership to the opening set. Refuses IncompatibleResolution, as the cut_spec supersession rule does. Includes the admission tests (not yet verified).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:16:25 +02:00
Meta Vens
6e5fb9153c Regenerate golden and schemas for target.repos
One appended golden line (target.repos) dumped by golden_dump, one header
comment line; the target, mind_request and mind_response schemas regenerated
from the derivation. No existing golden line changed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 17:40:14 +02:00
Meta Vens
9298371f95 Target gains repos: a campaign's repo set changes by target revision
PipelineTarget.repos (additive, `= absent`, epoch stays v2) names the
campaign's whole repo set from that revision on; repos_in_force is the one
owner of cut_spec membership and replaces the direct campaign.repos check;
a revision past the first may not empty the set its predecessor named
(EmptyRepos on the target id). Golden line and regenerated schemas follow
in the next commit (ruling campaign-repo-set-home).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 17:38:04 +02:00
11 changed files with 596 additions and 43 deletions

View file

@ -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

View file

@ -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<OrgRepo>[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<TargetInvariant>[32], not_in_scope: Vec<Line>[32],
canonical_implementations: Vec<Line>[16], doc: DocRef,
}
canonical_implementations: Vec<Line>[16], repos: Vec<OrgRepo>[16] = absent, doc: DocRef,
} => target_repos_are_distinct
pub struct PipelineQuestion {
campaign: Slug, label: Label, title: Title, question: Para, options: Vec<QuestionOption>[8], recommended: Label,
depends: Vec<Line>[8], raised_in: Option<PipelineRef>, 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::<PipelineDocument>(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::<PipelineDocument>(json)?, document);
let packed = rmp_serde::to_vec_named(&document)?;
assert_eq!(rmp_serde::from_slice::<PipelineDocument>(&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<OrgRepo>| {
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::<Vec<_>>();
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() {

View file

@ -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::<Vec<_>>();
@ -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::<Vec<_>>();
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<D> {
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<D> {
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<D> {
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<MemoryStore> {
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");

View file

@ -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<Lineage<'_>> {
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");
}
}

View file

@ -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(),
})
}

View file

@ -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 },

View file

@ -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(),
})
}

View file

@ -116,6 +116,7 @@ fn seed() -> Vec<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(),
}),
]

View file

@ -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,

View file

@ -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",

View file

@ -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",