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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 05:17:11 +02:00
commit 5ef4f5eef6
4 changed files with 119 additions and 19 deletions

View file

@ -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")) }
);
}

View file

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

View file

@ -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<SelectionRefusal> 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<String>`, 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<String> for RunId {
type Error = &'static str;
fn try_from(id: String) -> Result<Self, Self::Error> {
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<RunId> 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::<RunId>(&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::<MindRefusal>(r#"{"AlreadyLive":{"run":"not even a key"}}"#);
assert!(wire.is_err());
}
}

View file

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