diff --git a/.gitattributes b/.gitattributes index 65218452..5448eda1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1 @@ -/.epiphany/pipeline/pipeline.cc binary /schemas/cultnet/epiphany.pipeline.*.schema.json text eol=lf diff --git a/.gitignore b/.gitignore index fec0ff89..8b4f2e4c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,3 @@ state/*.ccmp state/*.ccmp.lock state/*.projection.json vendor/cultmesh-rs/target/ -/.epiphany/pipeline/*.lock diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index e964d36b..25578357 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -18,7 +18,6 @@ mod persona_feedback_admission; mod persona_social_state; mod persona_turn; mod pipeline_documents; -mod pipeline_store; mod process_observation; mod public_source_identity; mod reasoning_context; @@ -129,7 +128,6 @@ pub use persona_conversation::*; pub use persona_discord_crossing::*; pub use persona_discord_permit::*; pub use pipeline_documents::*; -pub use pipeline_store::{PIPELINE_STORE_PATH, PipelineRefusal, PipelineStore, PipelineWriter}; pub use persona_feedback_admission::{ BIFROST_PERSONA_FEEDBACK_ADMISSION_SCHEMA_VERSION, BIFROST_PERSONA_FEEDBACK_DELIVERY_TYPE, BifrostPersonaFeedbackAdmission, LOCAL_PERSONA_FEEDBACK_SCHEMA_VERSION, diff --git a/epiphany-core/src/pipeline_documents.rs b/epiphany-core/src/pipeline_documents.rs index d4692185..ffb8816c 100644 --- a/epiphany-core/src/pipeline_documents.rs +++ b/epiphany-core/src/pipeline_documents.rs @@ -16,7 +16,6 @@ //! The wrappers are crate-private: outside code reaches the store through the //! admission path, never by registering or preparing a pipeline type itself. -use crate::pipeline_store::PipelineRefusal; use anyhow::Result; use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry}; use schemars::JsonSchema; @@ -24,6 +23,27 @@ use serde::{Deserialize, Serialize}; pub const PIPELINE_SCHEMA_EPOCH: &str = "epiphany.pipeline.epoch.v1"; +/// Typed refusals of the pipeline documents. Bounds, formats and key identity +/// are the document half (D2); `ForeignEpoch` and `ForeignStore` are raised by +/// the decode and envelope-validation paths that still live here, and D2 hands +/// them to the organ when admission moves there. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PipelineRefusal { + FieldBound { field: String, limit: u32, actual: u32 }, + InvalidFormat { field: String, value: String }, + InvalidIdentity { kind: PipelineKind, key: String, expected: String }, + ForeignEpoch { found: String, expected: String }, + ForeignStore { r#type: String }, +} + +impl std::fmt::Display for PipelineRefusal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "pipeline refusal: {self:?}") + } +} + +impl std::error::Error for PipelineRefusal {} + pub(crate) trait Bounded { fn validate(&self, field: &str) -> Result<(), PipelineRefusal>; } @@ -328,26 +348,6 @@ impl Bounded for PipelineRef { } } -/// Display-only record of the process holding a repo's writer lease (D3). The -/// creation token is the process incarnation, so a recycled pid cannot make a -/// dead holder look current. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct PipelineWriterHolder { - pub pid: u32, - pub creation_token: u64, - pub host: Short, - pub session: Short, - pub attached_at: Short, -} - -impl Bounded for PipelineWriterHolder { - fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { - self.host.validate(&format!("{at}.host"))?; - self.session.validate(&format!("{at}.session"))?; - self.attached_at.validate(&format!("{at}.attached_at")) - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum ResolutionOutcome { Superseded { by: Short }, @@ -419,7 +419,7 @@ macro_rules! pipeline_kinds { match self { $(Self::$variant(value) => value.validate($name)),* } } - #[cfg_attr(not(test), expect(dead_code, reason = "Cut 3b admission prepares writes through this"))] + #[cfg_attr(not(test), expect(dead_code, reason = "the organ's admission prepares writes through this"))] pub(crate) fn prepare(&self, cache: &CultCache) -> Result { let key = pipeline_key(self)?; Ok(match self { @@ -441,6 +441,7 @@ macro_rules! pipeline_kinds { /// Registers every type a pipeline store may hold: the ten kinds, the /// store identity, admission provenance, and the commit receipt. + #[cfg_attr(not(test), expect(dead_code, reason = "the organ registers its mind's types through this"))] pub(crate) fn register_pipeline_document_types(cache: &mut CultCache) -> Result<()> { $(cache.register_entry_type::<$document>()?;)* cache.register_entry_type::()?; @@ -489,13 +490,6 @@ pub(crate) struct EpiphanyPipelineProvenance { pub(crate) value: PipelineProvenance, } -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "epiphany.pipeline.writer_holder.v1", schema = "EpiphanyPipelineWriterHolder")] -pub(crate) struct EpiphanyPipelineWriterHolder { - #[cultcache(key = 0)] - pub(crate) value: PipelineWriterHolder, -} - /// Parses a full document id. `::` for every kind but /// `campaign`, which is keyed by its slug alone. The segment count is exact, /// the kind segment must match `kind`, and both the campaign and the local @@ -599,25 +593,9 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result Result<()> { - for write in writes { - validate_pipeline_write_envelope(write)?; - } - let identity = |envelope: &CultCacheEnvelope| envelope.r#type == EpiphanyPipelineIdentity::TYPE; - if !cache.snapshot_envelopes().iter().any(identity) && !writes.iter().any(identity) { - return Err(PipelineRefusal::MissingIdentity.into()); - } - Ok(()) -} - /// Bounds, formats, then key recomputation, for one envelope. Per-kind -/// admission rules live in Cut 3b. +/// admission rules belong to the organ's admission path. +#[cfg_attr(not(test), expect(dead_code, reason = "the organ's admission validates every write through this"))] fn validate_pipeline_write_envelope(envelope: &CultCacheEnvelope) -> Result<()> { if envelope.r#type == EpiphanyPipelineIdentity::TYPE { let identity: EpiphanyPipelineIdentity = rmp_serde::from_slice(&envelope.payload)?; @@ -643,3 +621,418 @@ fn validate_pipeline_write_envelope(envelope: &CultCacheEnvelope) -> Result<()> } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + use std::path::Path; + + const CAMPAIGN: &str = "eureka-state"; + + fn s(value: &str) -> Short { + value.into() + } + + fn l(value: &str) -> Label { + value.into() + } + + fn slug(value: &str) -> Slug { + value.into() + } + + fn id(kind: &str, local: &str) -> Short { + Short(format!("{CAMPAIGN}:{kind}:{local}")) + } + + fn sha() -> Sha { + Sha("5f98228d".into()) + } + + fn date() -> Date { + Date("2026-09-15".into()) + } + + fn range() -> CommitRange { + CommitRange { base: sha(), head: sha() } + } + + fn location() -> CodeLocation { + CodeLocation { path: s("epiphany-core/src/pipeline_documents.rs"), line: 1, end_line: Some(9) } + } + + fn evidence() -> Evidence { + Evidence { kind: EvidenceKind::Test, locator: "cargo test".into(), result: "ok".into() } + } + + fn doc_ref() -> DocRef { + DocRef { path: s("notes/eureka-pipeline-state-target.md"), start_line: 1, end_line: 9, commit: sha() } + } + + /// One valid document of every kind, with its expected key. + fn samples() -> Vec<(PipelineDocument, String)> { + use PipelineDocument as D; + let repo = || OrgRepo("GameCult/Epiphany".into()); + let branch = || s("codex/eureka-pipeline-state"); + vec![ + (D::Campaign(PipelineCampaign { + slug: slug(CAMPAIGN), title: s("Eureka pipeline state"), repos: vec![repo()], + working_branch: branch(), target_doc: doc_ref(), + }), CAMPAIGN.into()), + (D::Target(PipelineTarget { + campaign: slug(CAMPAIGN), revision: 2, + invariants: vec![TargetInvariant { label: l("mind-admits"), statement: "Only admission writes.".into() }], + not_in_scope: vec!["Eve browsing".into()], canonical_implementations: vec!["CultLib".into()], doc: doc_ref(), + }), format!("{CAMPAIGN}:target:r2")), + (D::Question(PipelineQuestion { + campaign: slug(CAMPAIGN), label: l("Q1"), question: "Who owns the state?".into(), + options: vec![ + QuestionOption { label: l("A"), text: "an instance".into() }, + QuestionOption { label: l("B"), text: "a repo".into() }, + ], + recommended: l("A"), depends: vec!["Cut 3a".into()], + raised_in: Some(PipelineRef { kind: PipelineKind::CutSpec, id: id("cut_spec", "cut-3a.r1") }), + asked_on: date(), + }), format!("{CAMPAIGN}:question:Q1")), + (D::Ruling(PipelineRuling { + campaign: slug(CAMPAIGN), label: l("R8"), answers: Some(id("question", "Q1")), choice: Some(l("A")), + ruling: "An instance owns its mind.".into(), operator_quote: Some("all recommendations, go ahead".into()), + ruled_on: date(), + precedents: vec![ForeignRef { + repo: OrgRepo("GameCult/Aetheria".into()), commit: FullSha("a".repeat(40)), kind: PipelineKind::Ruling, + id: s("cultcache:ruling:R1"), payload_sha256: Sha256Hex("b".repeat(64)), + }], + }), format!("{CAMPAIGN}:ruling:R8")), + (D::CutSpec(PipelineCutSpec { + campaign: slug(CAMPAIGN), cut: l("3a"), revision: 1, title: s("Pipeline documents"), repo: repo(), + branch: branch(), base: sha(), depends_on: vec![s("2")], first: vec!["Read the spec.".into()], + deletes: vec![CutDelete { path: s("old.rs"), lines: 3, note: "dead".into() }], + keeps_moves: vec!["commit owner".into()], adds: vec!["pipeline_documents.rs".into()], + file_changes: vec![FileChange { location: location(), change: "add".into() }], + authority_map: Some(AuthorityMap { + owner: "core".into(), inputs: vec!["typed documents".into()], outputs: vec!["envelopes".into()], + derived_state: vec!["derived keys".into()], forbidden_writers: vec!["MCP".into()], + shared_paths: vec!["admission".into()], deletion_line: "n/a".into(), + }), + verification: CutVerification { + builds: vec!["cargo check".into()], + tests: vec![VerificationTest { name: s("keys"), pins: "one derived key".into() }], + negative: vec![NegativeCheck { pattern: s("Vec"), scope: "documents".into() }], + operator: vec!["none".into()], + }, + subtraction_estimate: SubtractionEstimate { + lines_removed: 0, lines_added: 900, removed: vec![], added: vec![s("schemars")], + }, + rulings: vec![id("ruling", "R8")], questions: vec![id("question", "Q1")], + }), format!("{CAMPAIGN}:cut_spec:cut-3a.r1")), + (D::CutReport(PipelineCutReport { + campaign: slug(CAMPAIGN), cut_spec: id("cut_spec", "cut-3a.r1"), attempt: 1, repo: repo(), branch: branch(), + commits: vec![ReportCommit { sha: sha(), subject: "Add the pipeline documents".into(), builds: true }], + range: range(), verification: vec![evidence()], + mutations: vec![MutationRecord { rule: "key derivation".into(), mutation: "drop the marker rule".into(), failed_as_expected: true }], + deviations: vec![Deviation { what: "names".into(), why: "glob exports".into() }], + forks: vec![id("question", "Q1")], + structural_delta: StructuralDelta { + lines_added: 900, lines_removed: 0, dependencies_added: vec![s("schemars")], + dependencies_removed: vec![], formats_added: vec![s("epiphany.pipeline.*.v1")], + formats_removed: vec![], targets_added: vec![], targets_removed: vec![], + }, + landed_names: vec![LandedName { name: s("PipelineDocument"), path: s("epiphany-core/src/pipeline_documents.rs") }], + undone: vec!["admission".into()], + }), format!("{CAMPAIGN}:cut_report:cut-3a.h1")), + (D::Verdict(PipelineVerdict { + campaign: slug(CAMPAIGN), cut_report: id("cut_report", "cut-3a.h1"), pass: 2, range: range(), + claims: vec![VerdictClaim { + claim: "A composed key has one source.".into(), outcome: ClaimOutcome::Falsified, + evidence: vec![evidence()], findings: vec![id("finding", "cut-3a.s2.F4")], + }], + }), format!("{CAMPAIGN}:verdict:cut-3a.s2")), + (D::Finding(PipelineFinding { + campaign: slug(CAMPAIGN), verdict: id("verdict", "cut-3a.s2"), label: l("F4"), range: range(), + confidence: FindingConfidence::Confirmed, severity: FindingSeverity::High, + claim: "A dotted label composes two keys.".into(), invariants: vec![l("mind-admits")], + locations: vec![location()], failure_scenario: "Two documents claim one key.".into(), + evidence: vec![evidence()], precedents: vec![], + }), format!("{CAMPAIGN}:finding:cut-3a.s2.F4")), + (D::FollowUp(PipelineFollowUp { + campaign: slug(CAMPAIGN), label: l("FU-4"), + source: PipelineRef { kind: PipelineKind::Finding, id: id("finding", "cut-3a.s2.F4") }, + repo: repo(), locations: vec![location()], item: "Per-kind admission rules.".into(), + why_it_can_wait: "The organ owns admission.".into(), owner: s("Hands"), + }), format!("{CAMPAIGN}:follow_up:FU-4")), + (D::Resolution(PipelineResolution { + subject: PipelineRef { kind: PipelineKind::Question, id: id("question", "Q1") }, + outcome: ResolutionOutcome::Answered { by: id("ruling", "R8") }, + rationale: "Ruled A.".into(), resolved_on: date(), + }), format!("resolution:{CAMPAIGN}:question:Q1")), + ] + } + + fn campaign_sample() -> PipelineDocument { + samples().remove(0).0 + } + + fn report_sample() -> PipelineCutReport { + let PipelineDocument::CutReport(report) = samples().remove(5).0 else { unreachable!() }; + report + } + + fn verdict_sample() -> PipelineVerdict { + let PipelineDocument::Verdict(verdict) = samples().remove(6).0 else { unreachable!() }; + verdict + } + + fn finding_sample() -> PipelineFinding { + let PipelineDocument::Finding(finding) = samples().remove(7).0 else { unreachable!() }; + finding + } + + fn resolution_sample() -> PipelineResolution { + let PipelineDocument::Resolution(resolution) = samples().remove(9).0 else { unreachable!() }; + resolution + } + + fn schema_cache() -> Result { + let mut cache = CultCache::new(); + register_pipeline_document_types(&mut cache)?; + Ok(cache) + } + + #[test] + fn every_pipeline_kind_round_trips_through_named_slot_zero() -> Result<()> { + let cache = schema_cache()?; + let samples = samples(); + let kinds = samples.iter().map(|(document, _)| document.kind()).collect::>(); + assert_eq!(kinds.len(), PipelineKind::ALL.len(), "every kind has a sample"); + for (document, _) in samples { + document.validate()?; + let envelope = document.prepare(&cache)?; + assert_eq!(envelope.r#type, document.kind().type_id()); + assert_eq!(envelope.payload[0], 0x91, "{:?} payload is a one-element array", document.kind()); + assert!( + matches!(envelope.payload[1], 0x80..=0x8f | 0xde | 0xdf), + "{:?} slot 0 is a named map", + document.kind() + ); + assert_eq!(PipelineDocument::decode(&envelope)?, document); + Ok::<_, anyhow::Error>(())?; + } + Ok(()) + } + + #[test] + fn bounds_refuse_in_utf8_bytes() { + let PipelineDocument::Campaign(mut campaign) = campaign_sample() else { unreachable!() }; + campaign.title = Short("é".repeat(100)); + assert_eq!(PipelineDocument::Campaign(campaign.clone()).validate(), Ok(())); + campaign.title = Short(format!("{}a", "é".repeat(100))); + assert_eq!( + PipelineDocument::Campaign(campaign.clone()).validate(), + Err(PipelineRefusal::FieldBound { field: "campaign.title".into(), limit: 200, actual: 201 }) + ); + campaign.title = s("ok"); + campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()); 9]; + assert_eq!( + PipelineDocument::Campaign(campaign).validate(), + Err(PipelineRefusal::FieldBound { field: "campaign.repos".into(), limit: 8, actual: 9 }) + ); + } + + #[test] + fn repo_fields_must_be_org_slash_repo() { + let mut report = report_sample(); + report.repo = OrgRepo("Epiphany".into()); + assert_eq!( + PipelineDocument::CutReport(report).validate(), + Err(PipelineRefusal::InvalidFormat { field: "cut_report.repo".into(), value: "Epiphany".into() }) + ); + let PipelineDocument::CutSpec(mut spec) = samples().remove(4).0 else { unreachable!() }; + spec.repo = OrgRepo("GameCult/Epiphany/extra".into()); + assert!(matches!( + PipelineDocument::CutSpec(spec).validate(), + Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "cut_spec.repo" + )); + let PipelineDocument::FollowUp(mut follow_up) = samples().remove(8).0 else { unreachable!() }; + follow_up.repo = OrgRepo("/Epiphany".into()); + assert!(matches!( + PipelineDocument::FollowUp(follow_up).validate(), + Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "follow_up.repo" + )); + } + + #[test] + fn keys_are_derived_and_mismatch_refuses() -> Result<()> { + let cache = schema_cache()?; + for (document, key) in samples() { + assert_eq!(pipeline_key(&document), Ok(key.clone())); + let mut envelope = document.prepare(&cache)?; + envelope.key = format!("{key}-forged"); + let error = validate_pipeline_write_envelope(&envelope).unwrap_err(); + assert_eq!( + error.downcast_ref::(), + Some(&PipelineRefusal::InvalidIdentity { kind: document.kind(), key: envelope.key, expected: key }) + ); + } + let mut resolution = resolution_sample(); + let answered = pipeline_key(&PipelineDocument::Resolution(resolution.clone())); + resolution.outcome = ResolutionOutcome::Withdrawn { reason: "moot".into() }; + assert_eq!( + pipeline_key(&PipelineDocument::Resolution(resolution)), + answered, + "a subject has one resolution key whatever the outcome" + ); + Ok(()) + } + + /// F2: numeric attempt and pass, and a dot-free finding label, mean two + /// different documents can never compose one key. These are Soul's pairs. + #[test] + fn composed_keys_cannot_collide() { + let key = |document: PipelineDocument| pipeline_key(&document); + + let mut wide_label = finding_sample(); + wide_label.label = l("F1.G"); + assert!( + matches!(key(PipelineDocument::Finding(wide_label)), Err(PipelineRefusal::InvalidFormat { .. })), + "a finding label carrying a dot is refused, not silently composed" + ); + let mut deep_verdict = finding_sample(); + deep_verdict.verdict = id("verdict", "cut-3a.s1.F1"); + deep_verdict.label = l("G"); + assert!( + matches!(key(PipelineDocument::Finding(deep_verdict)), Err(PipelineRefusal::InvalidFormat { .. })), + "a verdict local that is not cut-