Merge eureka-substrate/run-kind: the run kind, AlreadyClaimed and one claim rule for opening and reinstatement

Soul s1-s3 hold (eureka-substrate:verdict:cut-run-kind.s3). Additive on the wire; the live upgrade must be daemon-first (follow-up huginn-upgrade-daemon-first).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-09 17:02:29 +02:00
commit df7507b6aa
20 changed files with 931 additions and 45 deletions

View file

@ -22,6 +22,7 @@ sample.resolution eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resol
sample.instance yggdrasil:instance:self epiphany.pipeline.instance.v2 EpiphanyPipelineInstanceDocument 9184a8696e7374616e6365a979676764726173696cac646973706c61795f6e616d65ae59676764726173696c206d696e64aa637265617465645f6174aa323032362d30392d3135a4686f7374a979676764726173696c
sample.stewardship yggdrasil:stewardship:gamecult_-epiphany.n1 epiphany.pipeline.stewardship.v2 EpiphanyPipelineStewardshipDocument 9185a8696e7374616e6365a979676764726173696ca47265706fb147616d6543756c742f4570697068616e79a873657175656e636501ab61737369676e65645f6f6eaa323032362d30392d3135a46e6f7465b954686520457572656b612063616d706169676e207265706f2e
sample.hand_off yggdrasil:hand_off:thought-cage.gamecult_-epiphany.2026-09-15 epiphany.pipeline.hand_off.v2 EpiphanyPipelineHandOffDocument 9186ad66726f6d5f696e7374616e6365a979676764726173696cab746f5f696e7374616e6365ac74686f756768742d63616765a47265706fb147616d6543756c742f4570697068616e79a9646f63756d656e747392ac657572656b612d7374617465b6657572656b612d73746174653a72756c696e673a5238a6726561736f6ed92854686520776f726b73746174696f6e206d696e642074616b6573207468652063616d706169676e2ea968616e6465645f6f6eaa323032362d30392d3135
sample.run yggdrasil:run:mind-2026-10-09-1 epiphany.pipeline.run.v2 EpiphanyPipelineRunDocument 9189a8696e7374616e6365a979676764726173696ca56c6162656cb16d696e642d323032362d31302d30392d31ab6f706572617465645f6279a44d696e64a47475726ea753656c6652756ea4686f7374a979676764726173696caa737461727465645f6f6eaa323032362d30392d3135aa6275646765745f757364a3372e35a6636c61696d739182a46b696e64a743757453706563a26964d925657572656b612d73746174653a6375745f737065633a6375742d72756e2d6b696e642e7232a963616d706169676e7391ac657572656b612d7374617465
resolution.superseded eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581aa5375706572736564656481a262799282a46b696e64a652756c696e67a26964b6657572656b612d73746174653a72756c696e673a523882a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135
resolution.fixed-by-none eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a26279c0a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135
resolution.fixed-by-some eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a2627982a46b696e64a94375745265706f7274a26964d921657572656b612d73746174653a6375745f7265706f72743a6375742d33612e6831a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135

View file

@ -472,6 +472,31 @@ fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<
Ok(())
}
/// A run claims work, never runs, rulings or campaigns: its claims are cut
/// specs, findings and follow-ups, the three kinds a Self takes on. And its
/// budget is a canonical decimal (`0`, `12`, `7.5`: no sign, no leading or
/// trailing zeros, no exponent), so one spend has one spelling.
fn run_is_well_formed(run: &PipelineRun, at: &str) -> Result<(), PipelineRefusal> {
for (index, claim) in run.claims.iter().enumerate() {
if !matches!(claim.kind, PipelineKind::CutSpec | PipelineKind::Finding | PipelineKind::FollowUp) {
return Err(format_error(&format!("{at}.claims[{index}].kind"), claim.kind.name()));
}
if run.claims[..index].contains(claim) {
return Err(format_error(&format!("{at}.claims[{index}]"), &claim.id.0));
}
}
let (whole, fraction) = run.budget_usd.0.split_once('.').map_or((run.budget_usd.0.as_str(), None), |(whole, fraction)| (whole, Some(fraction)));
let digits = |text: &str| !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit());
let canonical = digits(whole)
&& (whole == "0" || !whole.starts_with('0'))
&& fraction.is_none_or(|fraction| digits(fraction) && !fraction.ends_with('0'));
if canonical {
Ok(())
} else {
Err(format_error(&format!("{at}.budget_usd"), &run.budget_usd.0))
}
}
unit_enums! {
EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture }
ClaimOutcome { Holds, Falsified, Unproven }
@ -479,6 +504,8 @@ unit_enums! {
FindingSeverity { Blocker, High, Medium, Low }
RulingAuthority { Operator, Standing, Defaulted, Mind }
FindingOrigin { Introduced, PreExisting }
RunOperator { Operator, Mind }
RunTurn { PersonaTurn, SelfRun }
}
value_types! {
@ -595,6 +622,15 @@ value_types! {
from_instance: Slug, to_instance: Slug, repo: OrgRepo, documents: Vec<Short>[256],
reason: Para, handed_on: Date,
}
/// One launch of a Self or a Persona turn: the one record of what it took
/// on. `claims` are the work it holds, and a claim is live while its run
/// has no resolution in force. `budget_usd` is a canonical decimal string,
/// `0` for an interactive run. Instance-rooted like stewardship: the key
/// is `<instance>:run:<label>`. Whether a claim is free is admission's rule.
pub struct PipelineRun {
instance: Slug, label: Label, operated_by: RunOperator, turn: RunTurn, host: Short, started_on: Date,
budget_usd: Short, claims: Vec<PipelineRef>[64], campaigns: Vec<Slug>[8],
} => run_is_well_formed
}
/// A reference to another document. The id is parsed as a full pipeline id of
@ -697,7 +733,7 @@ macro_rules! pipeline_kinds {
/// because a value is a named map of its own and an internal tag would
/// have to be merged into it; the tag stays beside the value instead.
/// This is not a published schema: `schemas/cultnet` publishes the
/// thirteen per-kind value documents, and the envelope is the wire's
/// fourteen per-kind value documents, and the envelope is the wire's
/// shape, not a document's.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
@ -813,6 +849,8 @@ pipeline_kinds! {
"epiphany.pipeline.stewardship.v2", "EpiphanyPipelineStewardshipDocument";
HandOff(PipelineHandOff) => EpiphanyPipelineHandOffDocument, "hand_off",
"epiphany.pipeline.hand_off.v2", "EpiphanyPipelineHandOffDocument";
Run(PipelineRun) => EpiphanyPipelineRunDocument, "run",
"epiphany.pipeline.run.v2", "EpiphanyPipelineRunDocument";
}
/// Parses a full document id: the grammar read backwards. A key and an id are
@ -952,7 +990,7 @@ fn local(field: &str, kind: PipelineKind, parts: &[&str]) -> Result<String, Pipe
}
/// Derives a document's identity key (D1, "Keys: identity, not convenience").
/// Thirteen arms, one exit: every arm names its root and composes its local
/// Fourteen arms, one exit: every arm names its root and composes its local
/// through `local`, and the key is formatted here and nowhere else.
pub fn pipeline_key(document: &PipelineDocument) -> Result<String, PipelineRefusal> {
use PipelineDocument as D;
@ -1005,6 +1043,8 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result<String, PipelineRefus
)?,
)
}
// A run hangs off its instance too: its label is its whole local.
D::Run(value) => ("run.instance", value.instance.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
D::Target(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&format!("r{}", value.revision)])?),
D::Question(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
D::Ruling(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
@ -1242,6 +1282,12 @@ mod tests {
documents: vec![s(CAMPAIGN), id("ruling", "R8")],
reason: "The workstation mind takes the campaign.".into(), handed_on: date(),
}), format!("{INSTANCE}:hand_off:thought-cage.gamecult_-epiphany.{}", date().0)),
(D::Run(PipelineRun {
instance: slug(INSTANCE), label: l("mind-2026-10-09-1"), operated_by: RunOperator::Mind, turn: RunTurn::SelfRun,
host: s("yggdrasil"), started_on: date(), budget_usd: s("7.5"),
claims: vec![PipelineRef { kind: PipelineKind::CutSpec, id: id("cut_spec", "cut-run-kind.r2") }],
campaigns: vec![slug(CAMPAIGN)],
}), format!("{INSTANCE}:run:mind-2026-10-09-1")),
]
}
@ -1429,6 +1475,124 @@ mod tests {
Ok(())
}
fn run_sample() -> PipelineRun {
let PipelineDocument::Run(run) = samples().remove(13).0 else { unreachable!() };
run
}
fn run_key(run: PipelineRun) -> Result<String, PipelineRefusal> {
pipeline_key(&PipelineDocument::Run(run))
}
/// Identity: a run is its instance and its label, and no other field moves
/// its key, so two launches under one label are one key.
#[test]
fn a_run_keys_off_its_instance_and_label_and_nothing_else() -> Result<()> {
let base = run_sample();
let key = run_key(base.clone())?;
assert_eq!(key, format!("{INSTANCE}:run:mind-2026-10-09-1"));
let mut varied = base.clone();
varied.operated_by = RunOperator::Operator;
varied.turn = RunTurn::PersonaTurn;
varied.host = s("starfire");
varied.started_on = Date("2026-10-10".into());
varied.budget_usd = s("0");
varied.claims = vec![];
varied.campaigns = vec![];
assert_eq!(run_key(varied)?, key, "no other field is part of the key");
let mut relabelled = base.clone();
relabelled.label = l("mind-2026-10-09-2");
assert_ne!(run_key(relabelled)?, key);
let mut moved = base;
moved.instance = slug("thought-cage");
assert_ne!(run_key(moved)?, key);
Ok(())
}
/// Claims name work: a cut spec, a finding or a follow-up, and every other
/// kind is refused at validate, whatever its id.
#[test]
fn a_run_claims_only_a_cut_spec_a_finding_or_a_follow_up() {
for kind in PipelineKind::ALL.iter().copied() {
let mut run = run_sample();
run.claims = vec![PipelineRef { kind, id: id(kind.name(), "x") }];
let outcome = PipelineDocument::Run(run).validate();
if matches!(kind, PipelineKind::CutSpec | PipelineKind::Finding | PipelineKind::FollowUp) {
assert_eq!(outcome, Ok(()), "{kind:?} is claimable");
} else {
assert!(
matches!(&outcome, Err(PipelineRefusal::InvalidFormat { field, value }) if field == "run.claims[0].kind" && value == kind.name()),
"{kind:?} is not claimable, got {outcome:?}"
);
}
}
}
/// A run claims an item once: a repeated claim is refused at validate.
#[test]
fn a_run_cannot_claim_one_item_twice() {
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.
#[test]
fn a_runs_budget_is_a_canonical_decimal() {
let validated = |budget: &str| {
let mut run = run_sample();
run.budget_usd = s(budget);
PipelineDocument::Run(run).validate()
};
for good in ["0", "1", "12", "7.5", "0.25", "100.001"] {
assert_eq!(validated(good), Ok(()), "{good}");
}
for bad in ["", "00", "01", "1.", "1.0", "7.50", "0.0", ".5", "-1", "+1", "1e3", "1,5", " 1", "1 ", "1.2.3"] {
assert!(
matches!(validated(bad), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "run.budget_usd"),
"{bad:?} is refused"
);
}
}
/// Bounds: a run's campaigns and claims stop at their maximums.
#[test]
fn a_runs_lists_are_bounded() {
let mut run = run_sample();
run.campaigns = (0..9).map(|n| slug(&format!("c{n}"))).collect();
assert!(matches!(PipelineDocument::Run(run).validate(), Err(PipelineRefusal::FieldBound { limit: 8, .. })));
let mut run = run_sample();
run.claims = (0..65).map(|n| PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", &format!("F{n}")) }).collect();
assert!(matches!(PipelineDocument::Run(run).validate(), Err(PipelineRefusal::FieldBound { limit: 64, .. })));
}
/// every-subject-resolvable (ruling key-bound): at the widest instance and
/// label a run's id fits a `Short`, and its resolution, and that
/// resolution's withdrawal, key within the 111-byte resolution local.
#[test]
fn the_widest_run_id_fits_a_short_and_resolves_two_deep() -> Result<()> {
let mut run = run_sample();
run.instance = slug(&"i".repeat(64));
run.label = l(&"a".repeat(64));
let run_id = run_key(run)?;
assert_eq!(run_id.len(), 64 + ":run:".len() + 64);
assert!(run_id.len() <= 200);
PipelineRef { kind: PipelineKind::Run, id: Short(run_id.clone()) }.validate_ref()?;
let one = resolved_at(PipelineKind::Run, &run_id, u32::MAX)?;
let two = resolved_at(PipelineKind::Resolution, &one, u32::MAX)?;
assert!(two.len() <= 200);
Ok(())
}
/// The live registrar plus the foreign stand-in, which only the tests
/// register.
fn schema_cache() -> Result<CultCache> {
@ -2859,11 +3023,11 @@ mod tests {
};
let document_schema = serde_json::to_value(schemars::schema_for!(PipelineDocument))?;
let variants = document_schema["oneOf"].as_array().expect("the document schema is a oneOf");
assert_eq!(PipelineKind::ALL.len(), 13, "thirteen kinds");
assert_eq!(PipelineKind::ALL.len(), 14, "fourteen kinds");
assert_eq!(
variants.iter().map(tag).collect::<Vec<_>>(),
PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::<Vec<_>>(),
"the document schema lists exactly the thirteen kinds, by name"
"the document schema lists exactly the fourteen kinds, by name"
);
let refusal_schema = serde_json::to_value(schemars::schema_for!(PipelineRefusal))?;

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;
@ -221,13 +221,14 @@ fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result<Staged, MindRefusal
Ok(Staged { kind: document.kind(), key: envelope.key.clone(), document, envelope })
}
/// A5: every document carrying an instance field names this mind; a hand-off
/// names it on one side.
/// A5: every document carrying an instance field names this mind (a run
/// included); a hand-off names it on one side.
fn refuse_foreign_instance(document: &PipelineDocument, mind: &Slug) -> 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,10 +518,34 @@ 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);
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.
@ -593,6 +618,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,
};
@ -656,6 +682,16 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M
later: later.into(),
});
}
// Withdrawing a run's closure puts the run back in force, and a run in
// force holds its claims: refuse while another run in force holds one.
if matches!(resolution.outcome, ResolutionOutcome::Withdrawn { .. })
&& subject_kind == K::Resolution
&& let Some(D::Resolution(reinstating)) = docs.find(K::Resolution, subject_id)
&& reinstating.subject.kind == K::Run
&& let Some(D::Run(run)) = docs.find(K::Run, &reinstating.subject.id.0)
{
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) {
return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id.0 });
@ -1925,6 +1961,232 @@ 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<MemoryStore> {
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 }
);
}
/// Withdrawing a run's Recorded resolution puts the run back in force, so
/// it goes through the same exclusivity as an opening: refused while
/// another live run holds one of its claims, allowed when none does.
#[test]
fn reinstating_a_run_over_a_live_holder_is_already_claimed() {
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")])]));
committed(admit(&mut mind, vec![recorded("a")]));
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") }
);
let mut free = with_specs();
committed(admit(&mut free, vec![run("a", &[spec_ref("1")])]));
committed(admit(&mut free, vec![recorded("a")]));
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() {
let mut mind = with_specs();
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") }
);
}
/// A key already holding a run is taken: the same key with different
/// content is an IdentityCollision, never a replay; only byte-identical
/// content answers AlreadyAdmitted.
#[test]
fn a_run_key_reused_with_different_content_is_an_identity_collision() {
let mut mind = with_specs();
committed(admit(&mut mind, vec![run("a", &[spec_ref("1")])]));
let D::Run(mut changed) = run("a", &[spec_ref("1")]) else { panic!() };
changed.budget_usd = "6".into();
assert_eq!(
refusal(admit(&mut mind, vec![D::Run(changed)])),
MindRefusal::IdentityCollision { kind: K::Run, id: run_key("a") }
);
assert_eq!(
refusal(admit(&mut mind, vec![run("a", &[spec_ref("2")])])),
MindRefusal::IdentityCollision { kind: K::Run, id: run_key("a") }
);
}
/// The replay clause is byte identity, not the key: a changed run under an
/// admitted key whose claim has since closed is not a replay, so the closed
/// claim refuses it.
#[test]
fn a_changed_run_under_an_admitted_key_is_not_a_replay() {
let mut mind = with_specs();
committed(admit(&mut mind, vec![run("a", &[spec_ref("1")])]));
committed(admit(&mut mind, vec![resolution(spec_ref("1"), withdrawn())]));
let D::Run(mut changed) = run("a", &[spec_ref("1")]) else { panic!() };
changed.budget_usd = "6".into();
assert_eq!(
refusal(admit(&mut mind, vec![D::Run(changed)])),
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]| {

View file

@ -215,6 +215,20 @@ impl Docs {
self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key)
}
/// The in-force run, other than `run_key`, that holds `claim`: the one
/// consumption fact run-is-the-grant rests on, asked by a run's opening
/// and by the withdrawal that would put a closed run back in force.
pub(crate) fn claim_holder(&self, run_key: &str, claim: &PipelineRef) -> Option<&str> {
self.of_kind(PipelineKind::Run).find_map(|(other_key, document)| match document {
PipelineDocument::Run(other)
if other_key != run_key && other.claims.contains(claim) && self.in_force(PipelineKind::Run, other_key) =>
{
Some(other_key)
}
_ => None,
})
}
/// The stewardship of `(mind, repo)` a hand-off acts on: the one whose
/// withdrawal already carries `hand_off_key`, when image or batch holds
/// that withdrawal, so a replay derives the same withdrawal again and A9
@ -286,10 +300,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 +320,7 @@ impl CitationRole {
Self::ResolvedBy,
Self::DeferredTo,
Self::Documents,
Self::Claims,
];
/// The wire spelling, equal to the referring field's name.
@ -325,6 +341,7 @@ impl CitationRole {
Self::ResolvedBy => "resolved_by",
Self::DeferredTo => "deferred_to",
Self::Documents => "documents",
Self::Claims => "claims",
}
}
@ -345,12 +362,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<PipelineKind> {
use PipelineKind as K;
match self {
@ -360,6 +378,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 +456,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

View file

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

View file

@ -86,7 +86,7 @@ pub fn index_text(document: &PipelineDocument) -> Option<String> {
}
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() {

View file

@ -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<CultCache, MindRefusal> {
let mut cache = CultCache::new();
@ -168,7 +168,7 @@ impl<S: MindStore> Mind<S> {
/// 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

View file

@ -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<PipelineRef> },
}
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<MemoryStore>, 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::<Vec<_>>();
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() };

View file

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

View file

@ -12,7 +12,10 @@
use std::collections::BTreeMap;
use cultnet_rs::{Row, RowSet, Selection};
use eureka_pipeline::{OrgRepo, Label, PipelineDocument, PipelineKind, PipelineRef, ResolutionOutcome, RulingAuthority, Slug};
use eureka_pipeline::{
FindingConfidence, FindingOrigin, FindingSeverity, Label, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, ResolutionOutcome,
RulingAuthority, Slug,
};
use crate::docs::{CitationRole, Held, kind_of_id, kind_of_type};
use crate::query::PipelineDocumentView;
@ -40,10 +43,6 @@ pub(crate) enum Alias {
}
const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "Life", "Eyes", "Operator"];
const SEVERITIES: [&str; 4] = ["Blocker", "High", "Medium", "Low"];
const CONFIDENCES: [&str; 2] = ["Confirmed", "Plausible"];
const ORIGINS: [&str; 2] = ["Introduced", "PreExisting"];
const CLAIM_OUTCOMES: [&str; 3] = ["Holds", "Falsified", "Unproven"];
const OUTCOMES: [&str; 6] = ["Superseded", "Answered", "Fixed", "Deferred", "Recorded", "Withdrawn"];
const BOOLEANS: [&str; 2] = ["true", "false"];
@ -102,11 +101,11 @@ impl Alias {
match self {
Self::InForce => Some(&BOOLEANS),
Self::Faculty => Some(&FACULTIES),
Self::Severity => Some(&SEVERITIES),
Self::Confidence => Some(&CONFIDENCES),
Self::Origin => Some(&ORIGINS),
Self::Severity => Some(FindingSeverity::NAMES),
Self::Confidence => Some(FindingConfidence::NAMES),
Self::Origin => Some(FindingOrigin::NAMES),
Self::Authority => Some(RulingAuthority::NAMES),
Self::ClaimOutcome => Some(&CLAIM_OUTCOMES),
Self::ClaimOutcome => Some(eureka_pipeline::ClaimOutcome::NAMES),
Self::Outcome => Some(&OUTCOMES),
Self::Root | Self::Repo | Self::Cut => None,
}
@ -183,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()
}
},
@ -241,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;
@ -287,7 +286,7 @@ fn invalid(field: impl Into<String>, value: &str, message: impl Into<String>) ->
/// 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
@ -354,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, &[]),
@ -368,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();
@ -380,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"]),
@ -397,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);

View file

@ -12,7 +12,7 @@ compares file to derivation byte for byte.
## The pipeline catalogue
`index.json` is the publication manifest for every schema here: the thirteen
`index.json` is the publication manifest for every schema here: the fourteen
`epiphany.pipeline.*.v2` schemas, `gamecult.persona_state.v0` and the two
`huginn.mind_*` wire schemas. The pipeline
schemas are derived from the value types in `crates/eureka-pipeline` and checked

View file

@ -160,7 +160,8 @@
"Resolution",
"Instance",
"Stewardship",
"HandOff"
"HandOff",
"Run"
],
"type": "string"
},

View file

@ -54,7 +54,8 @@
"Resolution",
"Instance",
"Stewardship",
"HandOff"
"HandOff",
"Run"
],
"type": "string"
},

View file

@ -34,7 +34,8 @@
"Resolution",
"Instance",
"Stewardship",
"HandOff"
"HandOff",
"Run"
],
"type": "string"
},

View file

@ -29,7 +29,8 @@
"Resolution",
"Instance",
"Stewardship",
"HandOff"
"HandOff",
"Run"
],
"type": "string"
},

View file

@ -67,7 +67,8 @@
"Resolution",
"Instance",
"Stewardship",
"HandOff"
"HandOff",
"Run"
],
"type": "string"
},

View file

@ -0,0 +1,126 @@
{
"$defs": {
"Date": {
"description": "A calendar date, `YYYY-MM-DD`.",
"maxLength": 10,
"type": "string"
},
"Label": {
"description": "A key label: `[A-Za-z0-9_-]{1,64}`.",
"maxLength": 64,
"type": "string"
},
"PipelineKind": {
"enum": [
"Campaign",
"Target",
"Question",
"Ruling",
"CutSpec",
"CutReport",
"Verdict",
"Finding",
"FollowUp",
"Resolution",
"Instance",
"Stewardship",
"HandOff",
"Run"
],
"type": "string"
},
"PipelineRef": {
"description": "A reference to another document. The id is parsed as a full pipeline id of\nthe declared `kind`, so the kind and the id's kind segment cannot disagree.",
"properties": {
"id": {
"$ref": "#/$defs/Short"
},
"kind": {
"$ref": "#/$defs/PipelineKind"
}
},
"required": [
"kind",
"id"
],
"type": "object"
},
"RunOperator": {
"enum": [
"Operator",
"Mind"
],
"type": "string"
},
"RunTurn": {
"enum": [
"PersonaTurn",
"SelfRun"
],
"type": "string"
},
"Short": {
"description": "Text of at most 200 UTF-8 bytes.",
"maxLength": 200,
"type": "string"
},
"Slug": {
"description": "A campaign slug or key segment: label parts joined by `.`.",
"maxLength": 64,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.run.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"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 `<instance>:run:<label>`. Whether a claim is free is admission's rule.",
"properties": {
"budget_usd": {
"$ref": "#/$defs/Short"
},
"campaigns": {
"items": {
"$ref": "#/$defs/Slug"
},
"maxItems": 8,
"type": "array"
},
"claims": {
"items": {
"$ref": "#/$defs/PipelineRef"
},
"maxItems": 64,
"type": "array"
},
"host": {
"$ref": "#/$defs/Short"
},
"instance": {
"$ref": "#/$defs/Slug"
},
"label": {
"$ref": "#/$defs/Label"
},
"operated_by": {
"$ref": "#/$defs/RunOperator"
},
"started_on": {
"$ref": "#/$defs/Date"
},
"turn": {
"$ref": "#/$defs/RunTurn"
}
},
"required": [
"instance",
"label",
"operated_by",
"turn",
"host",
"started_on",
"budget_usd",
"claims",
"campaigns"
],
"title": "PipelineRun",
"type": "object"
}

View file

@ -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 `<instance>:run:<label>`. Whether a claim is free is admission's rule.",
"type": "object",
"properties": {
"budget_usd": {
"$ref": "#/$defs/Short"
},
"campaigns": {
"type": "array",
"items": {
"$ref": "#/$defs/Slug"
},
"maxItems": 8
},
"claims": {
"type": "array",
"items": {
"$ref": "#/$defs/PipelineRef"
},
"maxItems": 64
},
"host": {
"$ref": "#/$defs/Short"
},
"instance": {
"$ref": "#/$defs/Slug"
},
"label": {
"$ref": "#/$defs/Label"
},
"operated_by": {
"$ref": "#/$defs/RunOperator"
},
"started_on": {
"$ref": "#/$defs/Date"
},
"turn": {
"$ref": "#/$defs/RunTurn"
}
},
"required": [
"instance",
"label",
"operated_by",
"turn",
"host",
"started_on",
"budget_usd",
"claims",
"campaigns"
]
},
"PipelineStewardship": {
"description": "Stewardship over a repo, as an assignment recorded in a mind. One\ninstance may steward several repos, so the repo is part of the key.\n`sequence` is per `(instance, repo)`, set by the writer, `1` for the\nfirst, so a repo transferred away and back is two records under one\nprefix rather than one document overwriting the other. `assigned_on`\nis a field, not a key part. As on a resolution, whether the sequence\nis the previous plus one, and which assignment is in force, are\nadmission's rules and not this crate's.",
"type": "object",
@ -1678,6 +1747,20 @@
"Mind"
]
},
"RunOperator": {
"type": "string",
"enum": [
"Operator",
"Mind"
]
},
"RunTurn": {
"type": "string",
"enum": [
"PersonaTurn",
"SelfRun"
]
},
"SemanticQuery": {
"description": "A semantic query: the text to find documents near, and how many. The index\nowns which fields of each kind are text (`index_text`), so no substring\nfilter lives here to become a second answer to that question.",
"type": "object",

View file

@ -184,7 +184,8 @@
"superseded_by",
"resolved_by",
"deferred_to",
"documents"
"documents",
"claims"
]
},
"ClaimOutcome": {
@ -1349,6 +1350,31 @@
"NotStewarded"
]
},
{
"description": "A run claims work another run in force already holds: `item` is the\nclaimed document's id and `run` the id of the run that holds it.",
"type": "object",
"properties": {
"AlreadyClaimed": {
"type": "object",
"properties": {
"item": {
"type": "string"
},
"run": {
"type": "string"
}
},
"required": [
"item",
"run"
]
}
},
"additionalProperties": false,
"required": [
"AlreadyClaimed"
]
},
{
"description": "The one refusal a mind never raises: the answer exceeds the largest\nbody the organ will deliver by any plane, one send or a deferred body.\nIt lives here because a refusal rides the response schema and there is\none of those, so a client reads it the way it reads every other refusal\nrather than parsing a second vocabulary. `bytes` is the encoded answer\n(the payload, not its envelope) and `limit` is the largest body the\ndaemon defers, both so a caller can narrow its own request; nothing is\ntruncated or paginated on its behalf. The daemon owns it and its\ndeferral bound decides it; no rule of the mind is involved.",
"type": "object",
@ -1889,7 +1915,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",
@ -2098,6 +2124,22 @@
"kind",
"value"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "run"
},
"value": {
"$ref": "#/$defs/PipelineRun"
}
},
"required": [
"kind",
"value"
]
}
]
},
@ -2727,6 +2769,49 @@
"kind",
"value"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "run"
},
"value": {
"type": "object",
"properties": {
"claims": {
"type": "array",
"items": {
"$ref": "#/$defs/PipelineRef"
}
},
"label": {
"$ref": "#/$defs/Label"
},
"operated_by": {
"$ref": "#/$defs/RunOperator"
},
"started_on": {
"$ref": "#/$defs/Date"
},
"turn": {
"$ref": "#/$defs/RunTurn"
}
},
"required": [
"label",
"operated_by",
"turn",
"started_on",
"claims"
]
}
},
"required": [
"kind",
"value"
]
}
]
},
@ -2923,7 +3008,8 @@
"Resolution",
"Instance",
"Stewardship",
"HandOff"
"HandOff",
"Run"
]
},
"PipelinePageItems": {
@ -3290,6 +3376,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 `<instance>:run:<label>`. Whether a claim is free is admission's rule.",
"type": "object",
"properties": {
"budget_usd": {
"$ref": "#/$defs/Short"
},
"campaigns": {
"type": "array",
"items": {
"$ref": "#/$defs/Slug"
},
"maxItems": 8
},
"claims": {
"type": "array",
"items": {
"$ref": "#/$defs/PipelineRef"
},
"maxItems": 64
},
"host": {
"$ref": "#/$defs/Short"
},
"instance": {
"$ref": "#/$defs/Slug"
},
"label": {
"$ref": "#/$defs/Label"
},
"operated_by": {
"$ref": "#/$defs/RunOperator"
},
"started_on": {
"$ref": "#/$defs/Date"
},
"turn": {
"$ref": "#/$defs/RunTurn"
}
},
"required": [
"instance",
"label",
"operated_by",
"turn",
"host",
"started_on",
"budget_usd",
"claims",
"campaigns"
]
},
"PipelineSelectionPage": {
"description": "One page of a selection. `matched` is the count before paging, `as_of` the\nsnapshot the page and every cursor of its walk are exact as of, and `edges`\nis present exactly when the selection follows a hop.",
"type": "object",
@ -3706,6 +3844,20 @@
"Mind"
]
},
"RunOperator": {
"type": "string",
"enum": [
"Operator",
"Mind"
]
},
"RunTurn": {
"type": "string",
"enum": [
"PersonaTurn",
"SelfRun"
]
},
"Sha": {
"description": "A git commit id: 7-40 lowercase hex characters.",
"type": "string",

View file

@ -155,6 +155,17 @@
"title": "Epiphany Pipeline HandOff v2",
"path": "epiphany.pipeline.hand_off.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.run.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.run.v2",
"documentType": "epiphany.pipeline.run.v2",
"title": "Epiphany Pipeline Run v2",
"path": "epiphany.pipeline.run.v2.schema.json"
},
{
"schemaId": "huginn.mind_request.v1",
"kind": "wire_message",