Runs: reinstatement shares the opening claim rule, liveness included

run_claims_rule is the one check for a run's claims (in force, held by no
other live run), called by opening and by the withdrawal that reinstates.
Tests: contested second claim on reinstatement, resolved claim refused and
live claims allowed, a repeated non-first claim refused at validate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-09 16:47:10 +02:00
commit e1b0a5c6ff
2 changed files with 63 additions and 17 deletions

View file

@ -1531,13 +1531,18 @@ mod tests {
/// A run claims an item once: a repeated claim is refused at validate.
#[test]
fn a_run_cannot_claim_one_item_twice() {
let claim = PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", "F1") };
let claim_one = || PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", "F1") };
let claim = claim_one();
let other = PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", "F2") };
let mut run = run_sample();
run.claims = vec![claim.clone(), other.clone()];
assert_eq!(PipelineDocument::Run(run.clone()).validate(), Ok(()));
run.claims = vec![claim.clone(), other, claim];
assert!(matches!(PipelineDocument::Run(run).validate(), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "run.claims[2]"));
let third = PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", "F3") };
let mut run = run_sample();
run.claims = vec![claim_one(), third.clone(), third];
assert!(matches!(PipelineDocument::Run(run).validate(), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "run.claims[2]"));
}
/// The budget has one spelling per amount.

View file

@ -32,7 +32,7 @@ use chrono::{DateTime, Utc};
use cultcache_rs::{CultCache, CultCacheEnvelope};
use eureka_pipeline::{
ClaimOutcome, FindingConfidence, Line, PipelineDocument, PipelineKind, PipelineRef, PipelineRefusal,
PipelineResolution, PipelineStewardship, ResolutionOutcome, RulingAuthority, Short, Slug, pipeline_key,
PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome, RulingAuthority, Short, Slug, pipeline_key,
validate_pipeline_write_envelope,
};
use schemars::JsonSchema;
@ -525,21 +525,27 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> {
// after the work it claimed has moved on.
D::Run(run) => {
let replay = docs.in_image(K::Run, key) == Some(&staged.document);
for claim in &run.claims {
if !replay && !docs.in_force(claim.kind, &claim.id.0) {
return Err(MindRefusal::CitesResolvedDocument { kind: claim.kind, id: claim.id.0.clone() });
}
let holder = docs.claim_holder(key, claim);
if let Some(holder) = holder {
return Err(MindRefusal::AlreadyClaimed { item: claim.id.0.clone(), run: holder.into() });
}
}
Ok(())
run_claims_rule(docs, key, run, replay)
}
D::FollowUp(_) | D::Instance(_) => Ok(()),
}
}
/// The one rule for a run holding claims, whether it is opened or reinstated
/// by withdrawing its close: every claim in force (a replay of an admitted run
/// owes none) and held by no other live run.
fn run_claims_rule(docs: &Docs, run_key: &str, run: &PipelineRun, replay: bool) -> Result<(), MindRefusal> {
for claim in &run.claims {
if !replay && !docs.in_force(claim.kind, &claim.id.0) {
return Err(MindRefusal::CitesResolvedDocument { kind: claim.kind, id: claim.id.0.clone() });
}
if let Some(holder) = docs.claim_holder(run_key, claim) {
return Err(MindRefusal::AlreadyClaimed { item: claim.id.0.clone(), run: holder.into() });
}
}
Ok(())
}
/// Revision 1 stands alone; revision N needs a resolution in the batch that
/// supersedes revision N-1 by this document. The predecessor's key is the
/// leaf's, from the same document one revision back.
@ -684,11 +690,7 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M
&& reinstating.subject.kind == K::Run
&& let Some(D::Run(run)) = docs.find(K::Run, &reinstating.subject.id.0)
{
for claim in &run.claims {
if let Some(holder) = docs.claim_holder(&reinstating.subject.id.0, claim) {
return Err(MindRefusal::AlreadyClaimed { item: claim.id.0.clone(), run: holder.into() });
}
}
run_claims_rule(docs, &reinstating.subject.id.0, run, false)?;
}
for (_, referent) in outcome_citations(&resolution.outcome) {
if !docs.in_force(referent.kind, &referent.id.0) {
@ -2064,6 +2066,45 @@ mod tests {
committed(admit(&mut free, vec![resolution(closure, withdrawn())]));
}
/// Reinstatement finds a contested claim anywhere in the run's list: a
/// reinstated run of two claims where only the second is held is refused,
/// and the first alone being contested is refused too.
#[test]
fn reinstating_a_run_finds_a_contested_second_claim() {
let closure = r(K::Resolution, &format!("{INSTANCE}:resolution:run.a.n1"));
for (held, expected) in [("2", "2"), ("1", "1")] {
let mut mind = with_specs();
committed(admit(&mut mind, vec![run("a", &[spec_ref("1"), spec_ref("2")])]));
committed(admit(&mut mind, vec![recorded("a")]));
committed(admit(&mut mind, vec![run("c", &[spec_ref(held)])]));
assert_eq!(
refusal(admit(&mut mind, vec![resolution(closure.clone(), withdrawn())])),
MindRefusal::AlreadyClaimed { item: spec_ref(expected).id.0, run: run_key("c") }
);
}
}
/// Reinstating a run needs every claim in force, as opening it does: once
/// a claimed item has resolved the withdrawal is refused, and while all
/// stay in force it is allowed.
#[test]
fn reinstating_a_run_needs_its_claims_in_force() {
let closure = r(K::Resolution, &format!("{INSTANCE}:resolution:run.a.n1"));
let mut mind = with_specs();
committed(admit(&mut mind, vec![run("a", &[spec_ref("1"), spec_ref("2")])]));
committed(admit(&mut mind, vec![recorded("a")]));
committed(admit(&mut mind, vec![resolution(spec_ref("2"), withdrawn())]));
assert_eq!(
refusal(admit(&mut mind, vec![resolution(closure.clone(), withdrawn())])),
MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: spec_ref("2").id.0 }
);
let mut live = with_specs();
committed(admit(&mut live, vec![run("a", &[spec_ref("1"), spec_ref("2")])]));
committed(admit(&mut live, vec![recorded("a")]));
committed(admit(&mut live, vec![resolution(closure, withdrawn())]));
}
/// A holder is found by any of its claims, not only the first.
#[test]
fn a_holder_is_found_by_its_second_claim() {