From bb9e64dca199de61fd9b85369d248683e55b9e12 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 9 Oct 2026 16:07:19 +0200 Subject: [PATCH] Mind: run admission with AlreadyClaimed, the run resolution row, the claims edge A run's instance must be this mind. Every claim must name a document in force (MissingReference, CitesResolvedDocument); a claim held by another run in force is refused AlreadyClaimed { item, run }, so the claim is the one consumption fact. A byte-identical replay of an admitted run owes its claims no fresh freedom and answers AlreadyAdmitted. A run resolves Recorded or Withdrawn only. Runs cite their claims under role `claims`, so cites and cited selections reach them; the header facts carry label, operator, turn, start and claims. The published schemas that embed PipelineKind (finding, follow_up, question, resolution, ruling) and the two mind wire schemas are re-derived: the Run variant, PipelineFacts::Run, AlreadyClaimed and the claims role. The leaf commit before this one has those five stale. Co-Authored-By: Claude Opus 5.5 --- crates/huginn-mind/src/admission.rs | 147 +++++++++++++++- crates/huginn-mind/src/docs.rs | 10 +- crates/huginn-mind/src/fixtures.rs | 19 ++- crates/huginn-mind/src/index.rs | 2 +- crates/huginn-mind/src/mind.rs | 6 +- crates/huginn-mind/src/query.rs | 45 ++++- crates/huginn-mind/src/refusal.rs | 3 + crates/huginn-mind/src/rows.rs | 20 ++- .../epiphany.pipeline.finding.v2.schema.json | 3 +- ...epiphany.pipeline.follow_up.v2.schema.json | 3 +- .../epiphany.pipeline.question.v2.schema.json | 3 +- ...piphany.pipeline.resolution.v2.schema.json | 3 +- .../epiphany.pipeline.ruling.v2.schema.json | 3 +- .../huginn.mind_request.v1.schema.json | 87 +++++++++- .../huginn.mind_response.v1.schema.json | 158 +++++++++++++++++- 15 files changed, 482 insertions(+), 30 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index faaab29..e4d9c74 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -221,13 +221,14 @@ fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result Result<(), MindRefusal> { let foreign = |declared: &Slug| MindRefusal::ForeignInstance { declared: declared.0.clone(), mind: mind.0.clone() }; match document { PipelineDocument::Instance(value) if value.instance != *mind => Err(foreign(&value.instance)), PipelineDocument::Stewardship(value) if value.instance != *mind => Err(foreign(&value.instance)), + PipelineDocument::Run(value) if value.instance != *mind => Err(foreign(&value.instance)), PipelineDocument::HandOff(value) if value.from_instance != *mind && value.to_instance != *mind => { Err(foreign(&value.from_instance)) } @@ -517,6 +518,27 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> { } Ok(()) } + // run-is-the-grant: the claim is the one consumption fact. A claim must + // be in force, and no other run in force may hold it. A run already in + // the image under this key, byte for byte, is a replay and owes its + // claims no fresh freedom, so the replay still answers AlreadyAdmitted + // 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.of_kind(K::Run).find_map(|(other_key, document)| match document { + D::Run(other) if other_key != key && other.claims.contains(claim) && docs.in_force(K::Run, other_key) => Some(other_key), + _ => None, + }); + if let Some(holder) = holder { + return Err(MindRefusal::AlreadyClaimed { item: claim.id.0.clone(), run: holder.into() }); + } + } + Ok(()) + } D::FollowUp(_) | D::Instance(_) => Ok(()), } } @@ -593,6 +615,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { (K::FollowUp, O::Withdrawn { .. }) => true, (K::Stewardship, O::Superseded { by }) => all(by, K::Stewardship), (K::Stewardship, O::Withdrawn { .. }) => true, + (K::Run, O::Recorded { .. } | O::Withdrawn { .. }) => true, (K::Resolution, O::Withdrawn { .. }) => true, _ => false, }; @@ -1925,6 +1948,126 @@ mod tests { ); } + fn spec_ref(cut: &str) -> PipelineRef { + r(K::CutSpec, &id("cut_spec", &format!("cut-{cut}.r1"))) + } + + fn run_key(label: &str) -> String { + format!("{INSTANCE}:run:{label}") + } + + /// A seeded mind holding cut specs 1 and 2. + fn with_specs() -> Mind { + let mut mind = seeded(); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1)), D::CutSpec(cut_spec("2", 1))])); + mind + } + + fn recorded(label: &str) -> PipelineDocument { + resolution(r(K::Run, &run_key(label)), ResolutionOutcome::Recorded { reason: "finished".into() }) + } + + /// run-is-the-grant: the claim is the only consumption fact. A second run + /// on a claim a live run holds is AlreadyClaimed, naming the claim and the + /// holder; another claim is free; once the holder is Recorded the claim is + /// free, and withdrawing that record makes the holder live again. + #[test] + 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") }; + 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")])])); + committed(admit(&mut mind, vec![recorded("a")])); + committed(admit(&mut mind, vec![run("c", &[spec_ref("1")])])); + + let mut reopened = with_specs(); + committed(admit(&mut reopened, vec![run("a", &[spec_ref("1")])])); + committed(admit(&mut reopened, vec![recorded("a")])); + let closure = r(K::Resolution, &format!("{INSTANCE}:resolution:run.a.n1")); + committed(admit(&mut reopened, vec![resolution(closure, withdrawn())])); + assert_eq!(refusal(admit(&mut reopened, vec![run("d", &[spec_ref("1")])])), claimed); + } + + /// Two openers on one claim in one batch: the claim is held by neither, so + /// the batch is refused whole and nothing is written. One opener at a time + /// is `a_second_run_on_a_live_claim_is_already_claimed`. + #[test] + fn two_openers_in_one_batch_hold_nothing() { + let mut mind = with_specs(); + let refused = refusal(admit(&mut mind, vec![run("a", &[spec_ref("1")]), run("b", &[spec_ref("1")])])); + assert!(matches!(refused, MindRefusal::AlreadyClaimed { .. }), "{refused:?}"); + assert!(mind.envelope(K::Run, &run_key("a")).is_none() && mind.envelope(K::Run, &run_key("b")).is_none()); + } + + /// Every claim names a document that exists and is in force. + #[test] + fn a_runs_claims_must_exist_and_be_in_force() { + let mut mind = with_specs(); + assert_eq!( + refusal(admit(&mut mind, vec![run("a", &[spec_ref("9")])])), + MindRefusal::MissingReference { kind: K::CutSpec, id: spec_ref("9").id.0 } + ); + committed(admit(&mut mind, vec![resolution(spec_ref("2"), withdrawn())])); + assert_eq!( + refusal(admit(&mut mind, vec![run("a", &[spec_ref("2")])])), + MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: spec_ref("2").id.0 } + ); + assert!(mind.envelope(K::Run, &run_key("a")).is_none()); + } + + /// A replay of an admitted run is AlreadyAdmitted even when its claim has + /// since been closed: a retry owes nothing to the work moving on. A + /// different run on that closed claim is still refused. + #[test] + fn replaying_an_admitted_run_after_its_claim_closed_is_already_admitted() { + let mut mind = with_specs(); + let (first, _) = committed(admit(&mut mind, vec![run("a", &[spec_ref("1")])])); + committed(admit(&mut mind, vec![resolution(spec_ref("1"), withdrawn())])); + assert_eq!(admit(&mut mind, vec![run("a", &[spec_ref("1")])]), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: first }); + assert_eq!( + refusal(admit(&mut mind, vec![run("b", &[spec_ref("1")])])), + MindRefusal::CitesResolvedDocument { kind: K::CutSpec, id: spec_ref("1").id.0 } + ); + } + + /// A5 for runs: a run of another instance is a ForeignInstance. + #[test] + fn a_run_of_another_mind_is_a_foreign_instance() { + let mut mind = with_specs(); + let D::Run(mut foreign) = run("a", &[]) else { panic!() }; + foreign.instance = slug(OTHER_INSTANCE); + assert_eq!( + refusal(admit(&mut mind, vec![D::Run(foreign)])), + MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() } + ); + } + + /// A run ends: Recorded or Withdrawn, and no other outcome. + #[test] + fn a_run_resolves_recorded_or_withdrawn_and_nothing_else() { + let incompatible = |outcome: &str| MindRefusal::IncompatibleResolution { subject_kind: K::Run, outcome: outcome.into() }; + let subject = || r(K::Run, &run_key("a")); + let open = || { + let mut mind = with_specs(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1")), run("a", &[spec_ref("1")]), run("z", &[])])); + mind + }; + committed(admit(&mut open(), vec![recorded("a")])); + committed(admit(&mut open(), vec![resolution(subject(), withdrawn())])); + let other_run = r(K::Run, &run_key("z")); + let refused = [ + (superseded(&[other_run.clone()]), "Superseded"), + (ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) }, "Answered"), + (ResolutionOutcome::Fixed { commit: sha(), by: None }, "Fixed"), + (ResolutionOutcome::Deferred { to: other_run }, "Deferred"), + ]; + for (outcome, name) in refused { + assert_eq!(refusal(admit(&mut open(), vec![resolution(subject(), outcome)])), incompatible(name), "{name}"); + } + } + #[test] fn depends_on_names_cuts() { let depending = |cut: &str, on: &[&str]| { diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index e338154..ad94aa7 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -286,10 +286,11 @@ pub enum CitationRole { ResolvedBy, DeferredTo, Documents, + Claims, } impl CitationRole { - pub const ALL: [CitationRole; 15] = [ + pub const ALL: [CitationRole; 16] = [ Self::RaisedIn, Self::Answers, Self::Rulings, @@ -305,6 +306,7 @@ impl CitationRole { Self::ResolvedBy, Self::DeferredTo, Self::Documents, + Self::Claims, ]; /// The wire spelling, equal to the referring field's name. @@ -325,6 +327,7 @@ impl CitationRole { Self::ResolvedBy => "resolved_by", Self::DeferredTo => "deferred_to", Self::Documents => "documents", + Self::Claims => "claims", } } @@ -345,12 +348,13 @@ impl CitationRole { Self::Source => K::FollowUp, Self::Subject | Self::SupersededBy | Self::ResolvedBy | Self::DeferredTo => K::Resolution, Self::Documents => K::HandOff, + Self::Claims => K::Run, } } /// The kinds a referent of this field may be: the one kind the field is /// typed to, or every kind for the fields typed `PipelineRef` or a full - /// id of any kind. "Any" is spelled as all thirteen, never as none. + /// id of any kind. "Any" is spelled as every kind, never as none. pub fn target_kinds(self) -> Vec { use PipelineKind as K; match self { @@ -360,6 +364,7 @@ impl CitationRole { Self::CutReport => vec![K::CutReport], Self::Findings => vec![K::Finding], Self::Verdict => vec![K::Verdict], + Self::Claims => vec![K::CutSpec, K::Finding, K::FollowUp], Self::RaisedIn | Self::Source | Self::Subject @@ -437,6 +442,7 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel D::HandOff(hand_off) => edges.extend( hand_off.documents.iter().filter_map(|to| kind_of_id(&to.0).map(|kind| (R::Documents, cited(kind, to)))), ), + D::Run(run) => edges.extend(run.claims.iter().map(|to| (R::Claims, to.clone()))), D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) => {} } edges diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index 34e44c3..7dde8ca 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -9,8 +9,8 @@ use eureka_pipeline::{ FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, MutationRecord, OrgRepo, PipelineCampaign, PipelineCutReport, PipelineCutSpec, PipelineDocument, PipelineFinding, PipelineFollowUp, PipelineHandOff, PipelineInstance, PipelineKind, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRuling, - PipelineStewardship, PipelineTarget, PipelineVerdict, Promise, QuestionOption, ReportCommit, ResolutionOutcome, - RulingAuthority, Sha, Short, Slug, StructuralDelta, TargetInvariant, VerdictClaim, + PipelineRun, PipelineStewardship, PipelineTarget, PipelineVerdict, Promise, QuestionOption, ReportCommit, + ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, Short, Slug, StructuralDelta, TargetInvariant, VerdictClaim, }; use crate::admission::{PipelineAdmissionBatch, PipelineAdmissionOutcome}; @@ -327,6 +327,21 @@ pub(crate) fn hand_off(from: &str, to: &str, repo_name: &str, documents: &[&str] }) } +/// A Self run of this mind that claims `claims`. +pub(crate) fn run(label: &str, claims: &[PipelineRef]) -> PipelineDocument { + PipelineDocument::Run(PipelineRun { + instance: slug(INSTANCE), + label: l(label), + operated_by: RunOperator::Mind, + turn: RunTurn::SelfRun, + host: s("yggdrasil"), + started_on: date(), + budget_usd: s("5"), + claims: claims.to_vec(), + campaigns: vec![slug(CAMPAIGN)], + }) +} + pub(crate) fn provenance(faculty: Faculty) -> PipelineProvenance { PipelineProvenance { faculty, agent: s("claude"), session: s("session-1"), tool: s("admit") } } diff --git a/crates/huginn-mind/src/index.rs b/crates/huginn-mind/src/index.rs index 5719684..017a38f 100644 --- a/crates/huginn-mind/src/index.rs +++ b/crates/huginn-mind/src/index.rs @@ -86,7 +86,7 @@ pub fn index_text(document: &PipelineDocument) -> Option { } D::Resolution(resolution) => lines.push(&resolution.rationale.0), D::HandOff(hand_off) => lines.push(&hand_off.reason.0), - D::Instance(_) | D::Stewardship(_) => {} + D::Instance(_) | D::Stewardship(_) | D::Run(_) => {} } lines.retain(|line| !line.is_empty()); if lines.is_empty() { diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index a557ff4..a026dd1 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -71,8 +71,8 @@ fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefu }) } -/// A cache that knows the fifteen types a mind's store may hold: the leaf's -/// thirteen through its registrar, the epoch record and the commit receipt. +/// A cache that knows the sixteen types a mind's store may hold: the leaf's +/// fourteen through its registrar, the epoch record and the commit receipt. /// It is the cache every envelope is prepared against. pub(crate) fn schema_cache() -> Result { let mut cache = CultCache::new(); @@ -168,7 +168,7 @@ impl Mind { /// Fail-closed, in this order, nothing attached until every step passes: /// pull the raw envelopes; if anything is stored, exactly one epoch - /// record at the current epoch; every type is one of the fifteen; and + /// record at the current epoch; every type is one of the sixteen; and /// exactly one `instance` document naming the declared instance; then /// register, attach and pull. The epoch gate runs first so a store /// written at a foreign epoch is refused as `ForeignEpoch`, never as diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index d96c0d9..04cffe5 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -27,8 +27,8 @@ use cultnet_rs::{ }; use eureka_pipeline::{ ClaimOutcome, CommitRange, Date, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, OrgRepo, - PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, ResolutionOutcome, RulingAuthority, Sha, Short, - Slug, Title, + PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, + Short, Slug, Title, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -136,6 +136,7 @@ pub enum PipelineFacts { Instance { instance: Slug, display_name: Short, host: Short, created_at: Date }, Stewardship { instance: Slug, repo: OrgRepo, sequence: u32, assigned_on: Date, note: Line }, HandOff { from_instance: Slug, to_instance: Slug, repo: OrgRepo, handed_on: Date }, + Run { label: Label, operated_by: RunOperator, turn: RunTurn, started_on: Date, claims: Vec }, } impl PipelineFacts { @@ -227,6 +228,13 @@ impl PipelineFacts { repo: value.repo.clone(), handed_on: value.handed_on.clone(), }, + D::Run(value) => Self::Run { + label: value.label.clone(), + operated_by: value.operated_by, + turn: value.turn, + started_on: value.started_on.clone(), + claims: value.claims.clone(), + }, } } } @@ -1263,6 +1271,33 @@ mod tests { assert_eq!(ids(&mind.query(&right).unwrap()), vec![id("follow_up", "FU-1")]); } + /// The claim edge is the waker's queue: a `cites` hop over the `claims` + /// role returns exactly the runs that claim the spec, closed ones included + /// with their status. + #[test] + fn a_claims_selection_returns_exactly_the_runs_claiming_a_spec() { + let mut mind = seeded(); + let (one, two) = (id("cut_spec", "cut-1.r1"), id("cut_spec", "cut-2.r1")); + committed(admit(&mut mind, vec![D::CutSpec(cut_spec("1", 1)), D::CutSpec(cut_spec("2", 1))])); + committed(admit(&mut mind, vec![run("a", &[r(K::CutSpec, &one)]), run("b", &[r(K::CutSpec, &two)])])); + let claiming = |mind: &Mind, spec: &str| { + let selection = Selection { cites: Some(cites(K::CutSpec, spec, Some("claims"))), ..of_kinds(&[K::Run]) }; + ids(&mind.query(&selection).unwrap()) + }; + assert_eq!(claiming(&mind, &one), vec![format!("{INSTANCE}:run:a")]); + assert_eq!(claiming(&mind, &two), vec![format!("{INSTANCE}:run:b")]); + let done = ResolutionOutcome::Recorded { reason: "done".into() }; + committed(admit(&mut mind, vec![resolution(r(K::Run, &format!("{INSTANCE}:run:a")), done)])); + assert_eq!(claiming(&mind, &one), vec![format!("{INSTANCE}:run:a")], "a closed run is still a citer"); + let page = mind.query(&of_kinds(&[K::Run])).unwrap(); + let facts = headers(&page).iter().map(|header| header.facts.clone()).collect::>(); + assert!(facts.iter().any(|fact| matches!( + fact, + PipelineFacts::Run { label, turn: RunTurn::SelfRun, operated_by: RunOperator::Mind, claims, .. } + if label.0 == "a" && claims.len() == 1 + )), "{facts:?}"); + } + /// The substrate's own refusals cross as themselves: an undeclared alias /// is a `SelectionInvalid` from the substrate's door, before the organ's. #[test] @@ -1336,6 +1371,8 @@ mod tests { ])); let (_, landed) = committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&q1])])); let handoff = landed.iter().find(|write| write.kind == K::HandOff).expect("the hand-off landed").id.0.clone(); + let (_, landed) = committed(admit(&mut mind, vec![run("w1", &[r(K::CutSpec, &id("cut_spec", "cut-1.r2")), r(K::FollowUp, &fu)])])); + let run_id = landed[0].id.0.clone(); let (n1, f1_closed, f2_closed) = ( id("resolution", "question.Q1.n1"), @@ -1367,8 +1404,10 @@ mod tests { (CitationRole::ResolvedBy, &f1_closed, &report), (CitationRole::DeferredTo, &f2_closed, &fu), (CitationRole::Documents, &handoff, &q1), + (CitationRole::Claims, &run_id, &spec_r2), + (CitationRole::Claims, &run_id, &fu), ]; - assert_eq!(CitationRole::ALL.len(), 15); + assert_eq!(CitationRole::ALL.len(), 16); for role in CitationRole::ALL { let selection = Selection { cited: Some(Incoming { role: role.name().into(), exists: true }), ..Selection::default() }; diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index 7236184..c771744 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -55,6 +55,9 @@ pub enum MindRefusal { FindingWithoutEvidence, UnknownInvariant { label: String }, 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 }, /// 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 diff --git a/crates/huginn-mind/src/rows.rs b/crates/huginn-mind/src/rows.rs index 30f6cd1..5fe4b52 100644 --- a/crates/huginn-mind/src/rows.rs +++ b/crates/huginn-mind/src/rows.rs @@ -182,7 +182,7 @@ impl SelectionRow { D::FollowUp(follow_up) => vec![follow_up.repo.identity()], D::Stewardship(stewardship) => vec![stewardship.repo.identity()], D::HandOff(hand_off) => vec![hand_off.repo.identity()], - D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) => { + D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) => { Vec::new() } }, @@ -240,7 +240,7 @@ impl Row for SelectionRow { } } -/// The organ's closed lists: thirteen schemas, eleven aliases, fifteen roles. +/// The organ's closed lists: fourteen schemas, eleven aliases, sixteen roles. /// A schema's name is its id, so the substrate's alias matching resolves /// nothing beyond exact ids. pub(crate) struct Vocabulary; @@ -286,7 +286,7 @@ fn invalid(field: impl Into, value: &str, message: impl Into) -> /// because `OrgRepo` identity is case-insensitive. /// /// Refuses, naming the field the client sent: a `schemas` entry that is not -/// one of the thirteen ids; a `keys` entry that is not a full id whose kind +/// one of the fourteen ids; a `keys` entry that is not a full id whose kind /// segment reads and passes the leaf's ref grammar; an `any_of` value outside /// its alias's domain; and a `cites.target` whose key fails the ref grammar /// for its schema's kind (V24's rule, at this door). Whether the target @@ -353,7 +353,7 @@ mod tests { /// refused at the substrate's door. #[test] fn each_kind_declares_exactly_its_aliases_and_none_is_numeric() { - let table: [(K, &[&str]); 13] = [ + let table: [(K, &[&str]); 14] = [ (K::Campaign, &["repo"]), (K::Target, &[]), (K::Question, &[]), @@ -367,6 +367,7 @@ mod tests { (K::Instance, &[]), (K::Stewardship, &["repo"]), (K::HandOff, &["repo"]), + (K::Run, &[]), ]; for (kind, extra) in table { let want = [&["root", "in_force", "faculty"][..], extra].concat(); @@ -379,10 +380,10 @@ mod tests { /// Each role is declared by the one kind whose field carries it, and its /// targets are the kinds that field is typed to, "any" spelled as all - /// thirteen. + /// fourteen. #[test] fn each_kind_declares_the_roles_it_carries_and_their_targets() { - let table: [(K, &[&str]); 13] = [ + let table: [(K, &[&str]); 14] = [ (K::Campaign, &[]), (K::Target, &[]), (K::Question, &["raised_in"]), @@ -396,13 +397,18 @@ mod tests { (K::Instance, &[]), (K::Stewardship, &[]), (K::HandOff, &["documents"]), + (K::Run, &["claims"]), ]; for (kind, roles) in table { assert_eq!(sorted(Vocabulary.declared_roles(kind.type_id())), strings(roles), "{kind:?}"); } assert_eq!(Vocabulary.target_leaves("answers"), vec![K::Question.type_id().to_string()]); assert_eq!(Vocabulary.target_leaves("rulings"), vec![K::Ruling.type_id().to_string()]); - assert_eq!(Vocabulary.target_leaves("subject").len(), 13); + assert_eq!(Vocabulary.target_leaves("subject").len(), 14); + assert_eq!( + sorted(Vocabulary.target_leaves("claims")), + sorted([K::CutSpec, K::Finding, K::FollowUp].iter().map(|kind| kind.type_id().to_string()).collect()) + ); assert!(Vocabulary.target_leaves("nonsense").is_empty()); assert_eq!(Vocabulary.schema_name(K::Question.type_id()), Some(K::Question.type_id().to_string())); assert_eq!(Vocabulary.schema_name("epiphany.pipeline.nonsense.v2"), None); diff --git a/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json b/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json index 798ce0d..d0c6a2f 100644 --- a/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json @@ -160,7 +160,8 @@ "Resolution", "Instance", "Stewardship", - "HandOff" + "HandOff", + "Run" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json b/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json index c624c04..2458e05 100644 --- a/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json @@ -54,7 +54,8 @@ "Resolution", "Instance", "Stewardship", - "HandOff" + "HandOff", + "Run" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.question.v2.schema.json b/schemas/cultnet/epiphany.pipeline.question.v2.schema.json index 1fea941..5e17b04 100644 --- a/schemas/cultnet/epiphany.pipeline.question.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.question.v2.schema.json @@ -34,7 +34,8 @@ "Resolution", "Instance", "Stewardship", - "HandOff" + "HandOff", + "Run" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json b/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json index 4d9c9c1..6351f76 100644 --- a/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json @@ -29,7 +29,8 @@ "Resolution", "Instance", "Stewardship", - "HandOff" + "HandOff", + "Run" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json b/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json index 2439653..f1b2641 100644 --- a/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json @@ -67,7 +67,8 @@ "Resolution", "Instance", "Stewardship", - "HandOff" + "HandOff", + "Run" ], "type": "string" }, diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index 322186b..06dd8f7 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -767,7 +767,7 @@ ] }, "PipelineDocument": { - "description": "The envelope enum, adjacently tagged on the kind name: a document\non a wire is `{ kind, value }`, and `kind` is exactly what\n`PipelineKind::name()` returns, so a reader dispatches on the kind\nsegment it already knows from the key and needs no second registry\nmapping variant spellings to kinds. Adjacent rather than internal\nbecause a value is a named map of its own and an internal tag would\nhave to be merged into it; the tag stays beside the value instead.\nThis is not a published schema: `schemas/cultnet` publishes the\nthirteen per-kind value documents, and the envelope is the wire's\nshape, not a document's.", + "description": "The envelope enum, adjacently tagged on the kind name: a document\non a wire is `{ kind, value }`, and `kind` is exactly what\n`PipelineKind::name()` returns, so a reader dispatches on the kind\nsegment it already knows from the key and needs no second registry\nmapping variant spellings to kinds. Adjacent rather than internal\nbecause a value is a named map of its own and an internal tag would\nhave to be merged into it; the tag stays beside the value instead.\nThis is not a published schema: `schemas/cultnet` publishes the\nfourteen per-kind value documents, and the envelope is the wire's\nshape, not a document's.", "oneOf": [ { "type": "object", @@ -976,6 +976,22 @@ "kind", "value" ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "run" + }, + "value": { + "$ref": "#/$defs/PipelineRun" + } + }, + "required": [ + "kind", + "value" + ] } ] }, @@ -1172,7 +1188,8 @@ "Resolution", "Instance", "Stewardship", - "HandOff" + "HandOff", + "Run" ] }, "PipelineProvenance": { @@ -1371,6 +1388,58 @@ "authority" ] }, + "PipelineRun": { + "description": "One launch of a Self or a Persona turn: the one record of what it took\non. `claims` are the work it holds, and a claim is live while its run\nhas no resolution in force. `budget_usd` is a canonical decimal string,\n`0` for an interactive run. Instance-rooted like stewardship: the key\nis `:run: