From 5ef4f5eef6a78b5df6282d9fbed988a7f61fcaec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 05:17:11 +0200 Subject: [PATCH] huginn-mind: refusals name a holder by a typed RunId; a run born closed owes no live slot AlreadyLive and AlreadyClaimed carry RunId, which parses as a run key on every door, so a peer cannot fill it with an arbitrary string. The live check applies only to a run the batch leaves in force. Tests pin a holder that differs in start, host and budget, a born-closed run, and the second live Persona turn's admission refusal in mind-body. Co-Authored-By: Claude Opus 5.5 --- crates/huginn-mind/src/admission.rs | 55 ++++++++++++++++++------ crates/huginn-mind/src/lib.rs | 2 +- crates/huginn-mind/src/refusal.rs | 66 +++++++++++++++++++++++++++-- crates/mind-body/src/launch.rs | 15 ++++++- 4 files changed, 119 insertions(+), 19 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 356e37f..45511f6 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -41,7 +41,7 @@ use serde::{Deserialize, Serialize}; use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, outcome_citations}; use crate::mind::{HuginnMindEpoch, Mind, unavailable}; use crate::receipt::{self, CommitOutcome, PipelineProvenance}; -use crate::refusal::MindRefusal; +use crate::refusal::{MindRefusal, RunId}; use crate::store::MindStore; /// The most envelopes one batch may carry. @@ -542,11 +542,11 @@ fn run_rule(docs: &Docs, run_key: &str, run: &PipelineRun, replay: bool) -> Resu 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() }); + return Err(MindRefusal::AlreadyClaimed { item: claim.id.0.clone(), run: RunId::held(holder) }); } } - if !replay && let Some(holder) = docs.live_holder(run_key, run) { - return Err(MindRefusal::AlreadyLive { run: holder.into() }); + if !replay && docs.in_force(K::Run, run_key) && let Some(holder) = docs.live_holder(run_key, run) { + return Err(MindRefusal::AlreadyLive { run: RunId::held(holder) }); } Ok(()) } @@ -1994,7 +1994,7 @@ mod tests { fn a_second_run_on_a_live_claim_is_already_claimed() { let mut mind = with_specs(); committed(admit(&mut mind, vec![run("a", &[spec_ref("1")])])); - let claimed = MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: run_key("a") }; + let claimed = MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: RunId::held(&run_key("a")) }; assert_eq!(refusal(admit(&mut mind, vec![run("b", &[spec_ref("1")])])), claimed); assert_eq!(refusal(admit(&mut mind, vec![run("b", &[spec_ref("2"), spec_ref("1")])])), claimed, "any one held claim refuses the run"); committed(admit(&mut mind, vec![run("b", &[spec_ref("2")])])); @@ -2016,14 +2016,14 @@ mod tests { fn a_second_live_run_of_hers_of_one_turn_is_already_live() { let mut mind = with_specs(); committed(admit(&mut mind, vec![mind_run("a", RunTurn::SelfRun, &[])])); - let live_a = MindRefusal::AlreadyLive { run: run_key("a") }; + let live_a = MindRefusal::AlreadyLive { run: RunId::held(&run_key("a")) }; assert_eq!(refusal(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[])])), live_a, "a claimless holder"); assert_eq!(refusal(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[spec_ref("1")])])), live_a); committed(admit(&mut mind, vec![recorded("a")])); committed(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[spec_ref("1")])])); - let live_b = MindRefusal::AlreadyLive { run: run_key("b") }; + let live_b = MindRefusal::AlreadyLive { run: RunId::held(&run_key("b")) }; assert_eq!(refusal(admit(&mut mind, vec![mind_run("c", RunTurn::SelfRun, &[spec_ref("2")])])), live_b, "a claiming holder"); - let claimed = MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: run_key("b") }; + let claimed = MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: RunId::held(&run_key("b")) }; assert_eq!(refusal(admit(&mut mind, vec![mind_run("c", RunTurn::SelfRun, &[spec_ref("1")])])), claimed, "claims answer first"); } @@ -2038,7 +2038,7 @@ mod tests { committed(admit(&mut mind, vec![run("op1", &[])])); committed(admit(&mut mind, vec![run("op2", &[])])); committed(admit(&mut mind, vec![mind_run("p", RunTurn::PersonaTurn, &[])])); - let live_p = MindRefusal::AlreadyLive { run: run_key("p") }; + let live_p = MindRefusal::AlreadyLive { run: RunId::held(&run_key("p")) }; assert_eq!(refusal(admit(&mut mind, vec![mind_run("p2", RunTurn::PersonaTurn, &[])])), live_p); committed(admit(&mut mind, vec![recorded("a")])); committed(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[])])); @@ -2050,6 +2050,33 @@ mod tests { committed(admit(&mut operator_only, vec![mind_run("a", RunTurn::SelfRun, &[])])); } + /// The slot is keyed on instance and turn alone: a holder that started + /// earlier, on another host and under another budget still blocks. + #[test] + fn a_holder_that_differs_in_start_host_and_budget_still_holds_the_slot() { + let mut mind = with_specs(); + let D::Run(mut holder) = mind_run("a", RunTurn::SelfRun, &[]) else { unreachable!() }; + holder.started_on = eureka_pipeline::Date("2026-01-01".into()); + holder.host = s("elsewhere"); + holder.budget_usd = s("9"); + committed(admit(&mut mind, vec![D::Run(holder)])); + assert_eq!( + refusal(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[])])), + MindRefusal::AlreadyLive { run: RunId::held(&run_key("a")) } + ); + } + + /// A run opened and closed in one batch is never live, so a live holder + /// of its turn does not refuse it; it holds no slot afterwards either. + #[test] + fn a_run_born_closed_beside_a_live_holder_is_admitted() { + let mut mind = with_specs(); + committed(admit(&mut mind, vec![mind_run("a", RunTurn::SelfRun, &[])])); + committed(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[]), recorded("b")])); + committed(admit(&mut mind, vec![recorded("a")])); + committed(admit(&mut mind, vec![mind_run("c", RunTurn::SelfRun, &[])])); + } + /// Two runs of hers of one turn in one batch hold nothing: the batch is /// checked against itself, refused whole, and nothing is written. #[test] @@ -2072,13 +2099,13 @@ mod tests { committed(admit(&mut mind, vec![mind_run("b", RunTurn::SelfRun, &[])])); assert_eq!( refusal(admit(&mut mind, vec![resolution(closure.clone(), withdrawn())])), - MindRefusal::AlreadyLive { run: run_key("b") } + MindRefusal::AlreadyLive { run: RunId::held(&run_key("b")) } ); committed(admit(&mut mind, vec![recorded("b")])); committed(admit(&mut mind, vec![resolution(closure, withdrawn())])); assert_eq!( refusal(admit(&mut mind, vec![mind_run("c", RunTurn::SelfRun, &[])])), - MindRefusal::AlreadyLive { run: run_key("a") }, + MindRefusal::AlreadyLive { run: RunId::held(&run_key("a")) }, "the reinstated run holds the slot" ); } @@ -2151,7 +2178,7 @@ mod tests { committed(admit(&mut mind, vec![run("c", &[spec_ref("1")])])); assert_eq!( refusal(admit(&mut mind, vec![resolution(closure.clone(), withdrawn())])), - MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: run_key("c") } + MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: RunId::held(&run_key("c")) } ); let mut free = with_specs(); @@ -2173,7 +2200,7 @@ mod tests { 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") } + MindRefusal::AlreadyClaimed { item: spec_ref(expected).id.0, run: RunId::held(&run_key("c")) } ); } } @@ -2206,7 +2233,7 @@ mod tests { committed(admit(&mut mind, vec![run("a", &[spec_ref("2"), spec_ref("1")])])); assert_eq!( refusal(admit(&mut mind, vec![run("b", &[spec_ref("1")])])), - MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: run_key("a") } + MindRefusal::AlreadyClaimed { item: spec_ref("1").id.0, run: RunId::held(&run_key("a")) } ); } diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 5aa59f7..7a23cf0 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -49,7 +49,7 @@ pub use query::{ PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, SUMMARY_MAX_BYTES, SemanticQuery, }; pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; -pub use refusal::MindRefusal; +pub use refusal::{MindRefusal, RunId}; pub use store::{MindStore, OwnedRedbMessagePackBackingStore}; pub use wire::{ DeferredAnswer, HuginnMindRequest, HuginnMindResponse, IndexStatus, MAX_DEFERRED_BODY_BYTES, MIND_REQUEST_SCHEMA, MIND_REQUEST_SCHEMA_JSON, diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 192558d..83ad904 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -3,7 +3,7 @@ //! serialises it, the tools (Cut 13) show it. use cultnet_rs::SelectionRefusal; -use eureka_pipeline::{PipelineKind, PipelineRefusal}; +use eureka_pipeline::{PipelineKind, PipelineRef, PipelineRefusal, Short}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -57,11 +57,11 @@ pub enum MindRefusal { NotStewarded { repo: String }, /// A run claims work another run in force already holds: `item` is the /// claimed document's id and `run` the id of the run that holds it. - AlreadyClaimed { item: String, run: String }, + AlreadyClaimed { item: String, run: RunId }, /// A run of hers opens, or is reinstated, while another in-force run of /// hers of the same instance and turn is live: `run` is the holder's id /// (ruling one-live-self-run). - AlreadyLive { run: String }, + AlreadyLive { run: RunId }, /// The one refusal a mind never raises: the answer exceeds the largest /// body the organ will deliver by any plane, one send or a deferred body. /// It lives here because a refusal rides the response schema and there is @@ -118,6 +118,50 @@ impl From for MindRefusal { } } +/// The id of a run: a full pipeline id of kind Run. A refusal that names a +/// holder carries one, so a reader gets a run's id from the wire only if it +/// parses as one; a peer cannot fill it with any other string. The only ways +/// in are `TryFrom`, which every deserialized value passes, and the +/// admission rules, which name a run the mind holds. The error is a fixed +/// message: it never echoes the offered value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(try_from = "String", into = "String")] +pub struct RunId(String); + +impl RunId { + /// The id as text. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The reference to the run this id names. + pub fn to_ref(&self) -> PipelineRef { + PipelineRef { kind: PipelineKind::Run, id: Short(self.0.clone()) } + } + + /// The key of a run the mind holds, which admission already held to the + /// grammar when it admitted the run. + pub(crate) fn held(key: &str) -> Self { + Self(key.into()) + } +} + +impl TryFrom for RunId { + type Error = &'static str; + + fn try_from(id: String) -> Result { + let candidate = PipelineRef { kind: PipelineKind::Run, id: Short(id) }; + candidate.validate_ref().map_err(|_| "not a run id")?; + Ok(Self(candidate.id.0)) + } +} + +impl From for String { + fn from(id: RunId) -> Self { + id.0 + } +} + #[cfg(test)] mod tests { use super::*; @@ -151,4 +195,20 @@ mod tests { }; assert!(matches!(MindRefusal::from(outside), MindRefusal::Unavailable { .. })); } + + /// A run id from the wire is a run's id or nothing: other kinds' ids and + /// arbitrary strings are refused, and the refusal never repeats the text. + #[test] + fn a_run_id_is_a_run_key_and_never_any_other_string() { + let held = "eureka:run:mind-1"; + let id: RunId = serde_json::from_str(&format!("\"{held}\"")).unwrap(); + assert_eq!((id.as_str(), serde_json::to_string(&id).unwrap()), (held, format!("\"{held}\""))); + assert_eq!(id.to_ref(), PipelineRef { kind: PipelineKind::Run, id: Short(held.into()) }); + for bad in ["not even a key", "eureka:cut_spec:cut-x.r1", "eureka:run:", "eureka:run:a:b", ""] { + let error = serde_json::from_str::(&format!("\"{bad}\"")).unwrap_err().to_string(); + assert!(error.contains("not a run id") && (bad.is_empty() || !error.contains(bad)), "{error}"); + } + let wire = serde_json::from_str::(r#"{"AlreadyLive":{"run":"not even a key"}}"#); + assert!(wire.is_err()); + } } diff --git a/crates/mind-body/src/launch.rs b/crates/mind-body/src/launch.rs index 753e67a..922b9f4 100644 --- a/crates/mind-body/src/launch.rs +++ b/crates/mind-body/src/launch.rs @@ -338,7 +338,20 @@ mod tests { // One live Persona turn of hers at a time (admission): end this one. rig.mind.committed(vec![close(run, recorded())]); } - assert_eq!(rig.started(), 3, "a Persona turn is never Busy"); + assert_eq!(rig.started(), 3, "each Persona turn ended before the next opened, so none was refused"); + } + + #[test] + fn a_second_live_persona_turn_is_refused_by_admission_and_starts_nothing() { + let rig = Rig::new(); + let LaunchOutcome::Launched { run } = rig.launch(RunTurn::PersonaTurn, &[], five()) else { panic!("not launched") }; + rig.at(1); + let second = rig.launch(RunTurn::PersonaTurn, &[], five()); + let LaunchOutcome::Refused(Declined::Admission(MindRefusal::AlreadyLive { run: holder })) = second else { + panic!("not refused as live: {second:?}") + }; + assert_eq!(holder.to_ref(), run); + assert_eq!((rig.runs(), rig.started()), (1, 1)); } #[test]