RS-3 1: the edge list moves to its owner (docs.rs citations)

docs.rs::citations and CitationRole are the one edge list: the fifteen
roles, one per referring leaf field. Admission's A7 references() is a map
over them, carrying the Missing classification by role and the hand-off
source-side filter, with the same refusals in the same order. kind_of_id and
outcome_references leave admission; the CitesResolvedDocument check reads
outcome_citations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 05:16:05 +02:00
commit 511afa2f44
3 changed files with 209 additions and 75 deletions

View file

@ -37,7 +37,7 @@ use epiphany_pipeline::{
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::docs::{Docs, Staged};
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;
@ -170,13 +170,6 @@ impl<S: MindStore> Mind<S> {
}
}
/// The kind segment of a full id, for the one field typed `Short` whose
/// referent may be of any kind.
fn kind_of_id(id: &str) -> Option<PipelineKind> {
let name = id.split(':').nth(1)?;
PipelineKind::ALL.iter().copied().find(|kind| kind.name() == name)
}
/// A3 through the leaf, then A5.
fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result<Staged, MindRefusal> {
validate_pipeline_write_envelope(&envelope)?;
@ -212,74 +205,35 @@ struct Reference {
missing: Missing,
}
fn reference(kind: PipelineKind, id: &str) -> Reference {
Reference { kind, id: id.to_string(), missing: Missing::Reference }
}
fn referenced(pipeline_ref: &PipelineRef) -> Reference {
reference(pipeline_ref.kind, &pipeline_ref.id.0)
}
fn outcome_references(outcome: &ResolutionOutcome) -> Vec<Reference> {
match outcome {
ResolutionOutcome::Superseded { by } => by
.iter()
.map(|supersessor| Reference { kind: supersessor.kind, id: supersessor.id.0.clone(), missing: Missing::Supersessor })
.collect(),
ResolutionOutcome::Answered { by } => vec![referenced(by)],
ResolutionOutcome::Fixed { by: Some(by), .. } => vec![referenced(by)],
ResolutionOutcome::Deferred { to } => vec![referenced(to)],
ResolutionOutcome::Fixed { by: None, .. } | ResolutionOutcome::Recorded { .. } | ResolutionOutcome::Withdrawn { .. } => {
vec![]
}
}
}
/// A7's field list: every `PipelineRef` and every full-id `Short` field.
/// `Fixed.commit` and `ForeignRef` are syntax only. A hand-off's documents
/// are cited on the source side only; the receiving side imports them
/// afterwards (Cut 12).
/// A7's field list is `docs::citations`: every `PipelineRef` and every
/// full-id `Short` field, each carrying the refusal its absence earns. A
/// hand-off's documents are cited on the source side only, and each must read
/// as an id of a kind; the receiving side imports them afterwards (Cut 12).
fn references(staged: &Staged, mind: &Slug) -> Result<Vec<Reference>, MindRefusal> {
use PipelineDocument as D;
use PipelineKind as K;
let mut refs = Vec::new();
match &staged.document {
D::Question(question) => refs.extend(question.raised_in.as_ref().map(referenced)),
D::Ruling(ruling) => refs.extend(ruling.answers.as_ref().map(|question| reference(K::Question, &question.0))),
D::CutSpec(spec) => {
refs.extend(spec.rulings.iter().map(|ruling| reference(K::Ruling, &ruling.0)));
refs.extend(spec.questions.iter().map(|question| reference(K::Question, &question.0)));
if let PipelineDocument::HandOff(hand_off) = &staged.document {
if hand_off.from_instance != *mind {
return Ok(Vec::new());
}
D::CutReport(report) => {
refs.push(Reference {
kind: K::CutSpec,
id: report.cut_spec.0.clone(),
missing: Missing::SpecOfReport(staged.key.clone()),
});
refs.extend(report.forks.iter().map(|question| reference(K::Question, &question.0)));
}
D::Verdict(verdict) => {
refs.push(reference(K::CutReport, &verdict.cut_report.0));
refs.extend(verdict.claims.iter().flat_map(|claim| claim.findings.iter()).map(|finding| reference(K::Finding, &finding.0)));
}
D::Finding(finding) => refs.push(reference(K::Verdict, &finding.verdict.0)),
D::FollowUp(follow_up) => refs.push(referenced(&follow_up.source)),
D::Resolution(resolution) => {
refs.push(referenced(&resolution.subject));
refs.extend(outcome_references(&resolution.outcome));
}
D::HandOff(hand_off) if hand_off.from_instance == *mind => {
for document in &hand_off.documents {
let kind = kind_of_id(&document.0).ok_or_else(|| PipelineRefusal::InvalidFormat {
field: "hand_off.documents".into(),
value: document.0.clone(),
})?;
refs.push(reference(kind, &document.0));
if let Some(document) = hand_off.documents.iter().find(|document| kind_of_id(&document.0).is_none()) {
return Err(PipelineRefusal::InvalidFormat {
field: "hand_off.documents".into(),
value: document.0.clone(),
}
.into());
}
D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::HandOff(_) => {}
}
Ok(refs)
Ok(citations(&staged.document)
.into_iter()
.map(|(role, target)| Reference {
kind: target.kind,
id: target.id.0,
missing: match role {
CitationRole::CutSpec => Missing::SpecOfReport(staged.key.clone()),
CitationRole::SupersededBy => Missing::Supersessor,
_ => Missing::Reference,
},
})
.collect())
}
/// A7: every referent is in the batch or the image; the image ones are the
@ -652,9 +606,9 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M
later: later.into(),
});
}
for referent in outcome_references(&resolution.outcome) {
if !docs.in_force(referent.kind, &referent.id) {
return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id });
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 });
}
}
match &resolution.outcome {

View file

@ -8,8 +8,10 @@
use cultcache_rs::CultCacheEnvelope;
use epiphany_pipeline::{
OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineStewardship, ResolutionOutcome,
Slug,
Short, Slug,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::refusal::MindRefusal;
@ -262,3 +264,180 @@ fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool {
_ => false,
}
}
/// The referring field of a citation edge, one per leaf field that names
/// another document. The wire spelling is the field's own name, so a client
/// reads an edge's role as the field it came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CitationRole {
RaisedIn,
Answers,
Rulings,
Questions,
CutSpec,
Forks,
CutReport,
Findings,
Verdict,
Source,
Subject,
SupersededBy,
ResolvedBy,
DeferredTo,
Documents,
}
impl CitationRole {
pub const ALL: [CitationRole; 15] = [
Self::RaisedIn,
Self::Answers,
Self::Rulings,
Self::Questions,
Self::CutSpec,
Self::Forks,
Self::CutReport,
Self::Findings,
Self::Verdict,
Self::Source,
Self::Subject,
Self::SupersededBy,
Self::ResolvedBy,
Self::DeferredTo,
Self::Documents,
];
/// The wire spelling, equal to the referring field's name.
pub fn name(self) -> &'static str {
match self {
Self::RaisedIn => "raised_in",
Self::Answers => "answers",
Self::Rulings => "rulings",
Self::Questions => "questions",
Self::CutSpec => "cut_spec",
Self::Forks => "forks",
Self::CutReport => "cut_report",
Self::Findings => "findings",
Self::Verdict => "verdict",
Self::Source => "source",
Self::Subject => "subject",
Self::SupersededBy => "superseded_by",
Self::ResolvedBy => "resolved_by",
Self::DeferredTo => "deferred_to",
Self::Documents => "documents",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|role| role.name() == name)
}
/// The kind whose documents carry this field.
pub fn carrier(self) -> PipelineKind {
use PipelineKind as K;
match self {
Self::RaisedIn => K::Question,
Self::Answers => K::Ruling,
Self::Rulings | Self::Questions => K::CutSpec,
Self::CutSpec | Self::Forks => K::CutReport,
Self::CutReport | Self::Findings => K::Verdict,
Self::Verdict => K::Finding,
Self::Source => K::FollowUp,
Self::Subject | Self::SupersededBy | Self::ResolvedBy | Self::DeferredTo => K::Resolution,
Self::Documents => K::HandOff,
}
}
/// 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.
pub fn target_kinds(self) -> Vec<PipelineKind> {
use PipelineKind as K;
match self {
Self::Answers | Self::Questions | Self::Forks => vec![K::Question],
Self::Rulings => vec![K::Ruling],
Self::CutSpec => vec![K::CutSpec],
Self::CutReport => vec![K::CutReport],
Self::Findings => vec![K::Finding],
Self::Verdict => vec![K::Verdict],
Self::RaisedIn
| Self::Source
| Self::Subject
| Self::SupersededBy
| Self::ResolvedBy
| Self::DeferredTo
| Self::Documents => PipelineKind::ALL.to_vec(),
}
}
}
/// The kind segment of a full id, for the fields typed `Short` whose referent
/// may be of any kind.
pub(crate) fn kind_of_id(id: &str) -> Option<PipelineKind> {
let name = id.split(':').nth(1)?;
PipelineKind::ALL.iter().copied().find(|kind| kind.name() == name)
}
fn cited(kind: PipelineKind, id: &Short) -> PipelineRef {
PipelineRef { kind, id: id.clone() }
}
/// What a resolution's outcome names, by the field that names it.
pub(crate) fn outcome_citations(outcome: &ResolutionOutcome) -> Vec<(CitationRole, PipelineRef)> {
match outcome {
ResolutionOutcome::Superseded { by } => by.iter().map(|by| (CitationRole::SupersededBy, by.clone())).collect(),
ResolutionOutcome::Answered { by } => vec![(CitationRole::ResolvedBy, by.clone())],
ResolutionOutcome::Fixed { by: Some(by), .. } => vec![(CitationRole::ResolvedBy, by.clone())],
ResolutionOutcome::Deferred { to } => vec![(CitationRole::DeferredTo, to.clone())],
ResolutionOutcome::Fixed { by: None, .. } | ResolutionOutcome::Recorded { .. } | ResolutionOutcome::Withdrawn { .. } => {
vec![]
}
}
}
/// Every citation edge a document carries, derived from its leaf fields each
/// time it is asked and never stored: the one edge list. Admission's A7 reads
/// it to require the referents, and the read side's rows read it as their
/// references. `Fixed.commit`, `ForeignRef` precedents and the labels
/// (`depends_on`, `invariants`, `promise`) are not edges. A hand-off's
/// `documents` entry whose kind segment does not read cites nothing here;
/// admission refuses such an entry on the source side before it asks.
pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, PipelineRef)> {
use CitationRole as R;
use PipelineDocument as D;
use PipelineKind as K;
let mut edges = Vec::new();
match document {
D::Question(question) => edges.extend(question.raised_in.iter().map(|to| (R::RaisedIn, to.clone()))),
D::Ruling(ruling) => edges.extend(ruling.answers.iter().map(|to| (R::Answers, cited(K::Question, to)))),
D::CutSpec(spec) => {
edges.extend(spec.rulings.iter().map(|to| (R::Rulings, cited(K::Ruling, to))));
edges.extend(spec.questions.iter().map(|to| (R::Questions, cited(K::Question, to))));
}
D::CutReport(report) => {
edges.push((R::CutSpec, cited(K::CutSpec, &report.cut_spec)));
edges.extend(report.forks.iter().map(|to| (R::Forks, cited(K::Question, to))));
}
D::Verdict(verdict) => {
edges.push((R::CutReport, cited(K::CutReport, &verdict.cut_report)));
edges.extend(
verdict
.claims
.iter()
.flat_map(|claim| claim.findings.iter())
.map(|to| (R::Findings, cited(K::Finding, to))),
);
}
D::Finding(finding) => edges.push((R::Verdict, cited(K::Verdict, &finding.verdict))),
D::FollowUp(follow_up) => edges.push((R::Source, follow_up.source.clone())),
D::Resolution(resolution) => {
edges.push((R::Subject, resolution.subject.clone()));
edges.extend(outcome_citations(&resolution.outcome));
}
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::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) => {}
}
edges
}

View file

@ -35,6 +35,7 @@ pub mod wire;
#[cfg(test)]
pub(crate) mod fixtures;
pub use docs::CitationRole;
pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome};
pub use mind::{HuginnMindEpoch, Mind};
pub use query::{