From e1b0a5c6ff7232db413b6504aeac28ee66b064a6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:47:10 +0200 Subject: [PATCH] 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 --- crates/eureka-pipeline/src/lib.rs | 7 ++- crates/huginn-mind/src/admission.rs | 73 ++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index dd8763d..abced06 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -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. diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index a290b66..148568b 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -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() {