From ad18c385dca90333cdafa510e3dd5ffec75a5431 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 15 Sep 2026 21:55:04 +0200 Subject: [PATCH] Add pipeline documents, store opener, writer lease and schemas Cut 3a of the Eureka pipeline state campaign (cut map D1-D3). - pipeline_documents.rs: the ten kinds as serde + JsonSchema values in one-slot DatabaseEntry wrappers (epiphany.pipeline..v1), the identity, provenance and writer-holder documents, UTF-8 byte bounds, semantic key derivation, and the pipeline write validator (bounds, then key recomputation). - pipeline_store.rs: PipelineStore::open (read-only, no lease), PipelineWriter::attach (fs2 lock in the git common dir, display-only holder file, binary/ignore checks), require_branch, PipelineRefusal, and PIPELINE_COMMIT_STORE on the one commit owner. The opener refuses foreign types, missing identity and foreign epochs before attaching. - schemars 1 is an unconditional core dependency (Q5-A); the ten published schemas are derived and checked byte-for-byte, with index.json entries. The profile and the prepare path carry expect(dead_code) naming Cut 3b as their consumer, so the attribute fails the build once admission uses them. Builds: yes. cargo test -p epiphany-core --lib: 171/171, 0 warnings. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 63 ++ epiphany-core/Cargo.toml | 3 +- epiphany-core/src/lib.rs | 4 + epiphany-core/src/pipeline_documents.rs | 547 +++++++++++++ epiphany-core/src/pipeline_store.rs | 771 ++++++++++++++++++ epiphany-core/src/reasoning_context.rs | 2 +- epiphany-core/src/repository_body_observer.rs | 2 +- schemas/cultnet/README.md | 12 + .../epiphany.pipeline.campaign.v1.schema.json | 70 ++ ...piphany.pipeline.cut_report.v1.schema.json | 317 +++++++ .../epiphany.pipeline.cut_spec.v1.schema.json | 337 ++++++++ .../epiphany.pipeline.finding.v1.schema.json | 217 +++++ ...epiphany.pipeline.follow_up.v1.schema.json | 110 +++ .../epiphany.pipeline.question.v1.schema.json | 117 +++ ...piphany.pipeline.resolution.v1.schema.json | 198 +++++ .../epiphany.pipeline.ruling.v1.schema.json | 124 +++ .../epiphany.pipeline.target.v1.schema.json | 101 +++ .../epiphany.pipeline.verdict.v1.schema.json | 129 +++ schemas/cultnet/index.json | 110 +++ 19 files changed, 3231 insertions(+), 3 deletions(-) create mode 100644 epiphany-core/src/pipeline_documents.rs create mode 100644 epiphany-core/src/pipeline_store.rs create mode 100644 schemas/cultnet/epiphany.pipeline.campaign.v1.schema.json create mode 100644 schemas/cultnet/epiphany.pipeline.cut_report.v1.schema.json create mode 100644 schemas/cultnet/epiphany.pipeline.cut_spec.v1.schema.json create mode 100644 schemas/cultnet/epiphany.pipeline.finding.v1.schema.json create mode 100644 schemas/cultnet/epiphany.pipeline.follow_up.v1.schema.json create mode 100644 schemas/cultnet/epiphany.pipeline.question.v1.schema.json create mode 100644 schemas/cultnet/epiphany.pipeline.resolution.v1.schema.json create mode 100644 schemas/cultnet/epiphany.pipeline.ruling.v1.schema.json create mode 100644 schemas/cultnet/epiphany.pipeline.target.v1.schema.json create mode 100644 schemas/cultnet/epiphany.pipeline.verdict.v1.schema.json diff --git a/Cargo.lock b/Cargo.lock index a685f386..41e31e08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -502,6 +502,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ed25519" version = "2.2.3" @@ -544,6 +550,7 @@ dependencies = [ "ignore", "libc", "rmp-serde", + "schemars", "semver", "serde", "serde_json", @@ -1557,6 +1564,26 @@ dependencies = [ "libc", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1810,6 +1837,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1883,6 +1935,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" diff --git a/epiphany-core/Cargo.toml b/epiphany-core/Cargo.toml index 187b276c..b64f2168 100644 --- a/epiphany-core/Cargo.toml +++ b/epiphany-core/Cargo.toml @@ -24,7 +24,8 @@ ghostlight-persona-projection = { git = "https://github.com/GameCult/Ghostlight. ed25519-dalek = "2" ignore = "0.4.23" rmp-serde = "1" -serde = { version = "1", features = ["derive"] } +schemars = "1" +serde ={ version = "1", features = ["derive"] } serde_json = "1" semver = { version = "1", features = ["serde"] } sha2 = "0.10" diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index 30dac8b8..e964d36b 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -17,6 +17,8 @@ mod persona_discord_permit; 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; @@ -126,6 +128,8 @@ pub use packaged_release::{ 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 new file mode 100644 index 00000000..3ff6da53 --- /dev/null +++ b/epiphany-core/src/pipeline_documents.rs @@ -0,0 +1,547 @@ +//! Eureka pipeline documents (cut map D1). +//! +//! Every kind is a plain `serde` + `JsonSchema` value inside a one-slot +//! `DatabaseEntry` wrapper, always prepared with `prepare_entry_named`, so the +//! stored payload is `[value]` and the value is the named map the published +//! schema describes. Keys are semantic: `pipeline_key` derives them from the +//! value, and the write validator refuses any envelope whose key differs. + +use crate::pipeline_store::PipelineRefusal; +use anyhow::Result; +use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const PIPELINE_SCHEMA_EPOCH: &str = "epiphany.pipeline.epoch.v1"; + +pub(crate) trait Bounded { + fn validate(&self, field: &str) -> Result<(), PipelineRefusal>; +} + +impl Bounded for Option { + fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { + self.as_ref().map_or(Ok(()), |value| value.validate(field)) + } +} + +fn bound(field: &str, limit: usize, actual: usize) -> Result<(), PipelineRefusal> { + if actual > limit { + return Err(PipelineRefusal::FieldBound { + field: field.into(), + limit: limit as u32, + actual: actual as u32, + }); + } + Ok(()) +} + +/// List maximums only. Minimum counts are admission rules with their own +/// refusals (Cut 3b), so a bounds pass never pre-empts them. +fn list(field: &str, items: &[T], max: usize) -> Result<(), PipelineRefusal> { + bound(field, max, items.len())?; + for (index, item) in items.iter().enumerate() { + item.validate(&format!("{field}[{index}]"))?; + } + Ok(()) +} + +fn format_error(field: &str, value: &str) -> PipelineRefusal { + PipelineRefusal::InvalidFormat { + field: field.into(), + value: value.into(), + } +} + +fn hex(field: &str, value: &str, lengths: std::ops::RangeInclusive) -> Result<(), PipelineRefusal> { + let lower_hex = value.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')); + if !lengths.contains(&value.len()) || !lower_hex { + return Err(format_error(field, value)); + } + Ok(()) +} + +/// `` and `` key segments: `[A-Za-z0-9._-]{1,64}`. +fn label<'a>(field: &str, value: &'a str) -> Result<&'a str, PipelineRefusal> { + let valid = value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); + if !valid || value.is_empty() || value.len() > 64 { + return Err(format_error(field, value)); + } + Ok(value) +} + +fn org_repo(field: &str, value: &str) -> Result<(), PipelineRefusal> { + match value.split_once('/') { + Some((org, repo)) if !org.is_empty() && !repo.is_empty() && !repo.contains('/') => Ok(()), + _ => Err(format_error(field, value)), + } +} + +macro_rules! bounded_text { + ($($(#[$doc:meta])* $name:ident = $limit:literal;)*) => {$( + $(#[$doc])* + #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] + #[serde(transparent)] + pub struct $name(pub String); + impl Bounded for $name { + fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { + bound(field, $limit, self.0.len()) + } + } + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self(value.into()) + } + } + )*}; +} + +bounded_text! { + /// Text of at most 200 UTF-8 bytes. + Short = 200; + /// Text of at most 1,000 UTF-8 bytes. + Line = 1000; + /// Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`. + Para = 4000; +} + +/// A git commit id: 7-40 lowercase hex characters. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct Sha(pub String); + +impl Bounded for Sha { + fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { + hex(field, &self.0, 7..=40) + } +} + +/// A calendar date, `YYYY-MM-DD`. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct Date(pub String); + +impl Bounded for Date { + fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { + match chrono::NaiveDate::parse_from_str(&self.0, "%Y-%m-%d") { + Ok(_) if self.0.len() == 10 => Ok(()), + _ => Err(format_error(field, &self.0)), + } + } +} + +macro_rules! bounded_struct { + ($($ty:ident { $($field:ident $([$max:literal])?),* $(,)? })*) => {$( + impl Bounded for $ty { + fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { + $(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)* + Ok(()) + } + } + )*}; +} + +macro_rules! bounded_field { + ($value:expr, $field:expr) => { + Bounded::validate(&$value, $field)? + }; + ($value:expr, $field:expr, $max:literal) => { + list($field, &$value, $max)? + }; +} + +macro_rules! value_types { + ($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty),* $(,)? })*) => {$( + $(#[$attr])* + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct $name { $(pub $field: $ty),* } + )*}; +} + +macro_rules! unit_enums { + ($($name:ident { $($variant:ident),* $(,)? })*) => {$( + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] + pub enum $name { $($variant),* } + )*}; +} + +unit_enums! { + EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture } + Faculty { SelfFaculty, Imagination, Hands, Soul, MindSteward, Eyes, Operator } + LandedNameKind { Module, Type, Function, Constant, Test, Schema, Binary, File } + ClaimOutcome { Holds, Falsified, Unproven } + FindingConfidence { Confirmed, Plausible } + FindingSeverity { Blocker, High, Medium, Low } +} + +value_types! { + pub struct PipelineRef { kind: PipelineKind, id: Short } + pub struct CodeLocation { path: Short, line: u32, end_line: Option } + pub struct CommitRange { base: Sha, head: Sha } + pub struct Evidence { kind: EvidenceKind, locator: Line, result: Line } + /// A citation of a document in another repo's pipeline store (D6). + pub struct ForeignRef { repo: Short, commit: Sha, kind: PipelineKind, id: Short, payload_sha256: String } + pub struct DocRef { path: Short, start_line: u32, end_line: u32, commit: Sha } + pub struct TargetInvariant { label: Short, statement: Line } + pub struct QuestionOption { label: Short, text: Line } + pub struct CutDelete { path: Short, lines: u32, note: Line } + pub struct FileChange { location: CodeLocation, change: Line } + pub struct AuthorityMap { + owner: Line, inputs: Vec, outputs: Vec, derived_state: Vec, + forbidden_writers: Vec, shared_paths: Vec, deletion_line: Line, + } + pub struct VerificationTest { name: Short, pins: Line } + pub struct NegativeCheck { pattern: Short, scope: Line } + pub struct CutVerification { + builds: Vec, tests: Vec, negative: Vec, operator: Vec, + } + pub struct SubtractionEstimate { lines_removed: u32, lines_added: u32, removed: Vec, added: Vec } + pub struct ReportCommit { sha: Sha, subject: Line, builds: bool } + pub struct MutationRecord { rule: Line, mutation: Line, failed_as_expected: bool } + pub struct Deviation { what: Line, why: Line } + pub struct StructuralDelta { + lines_added: u32, lines_removed: u32, + dependencies_added: Vec, dependencies_removed: Vec, + formats_added: Vec, formats_removed: Vec, + targets_added: Vec, targets_removed: Vec, + } + pub struct LandedName { name: Short, kind: LandedNameKind, location: CodeLocation } + pub struct VerdictClaim { claim: Line, outcome: ClaimOutcome, evidence: Vec, findings: Vec } + + pub struct PipelineCampaign { slug: Short, title: Short, repos: Vec, working_branch: Short, target_doc: DocRef } + pub struct PipelineTarget { + campaign: Short, revision: u32, invariants: Vec, not_in_scope: Vec, + canonical_implementations: Vec, doc: DocRef, + } + pub struct PipelineQuestion { + campaign: Short, label: Short, question: Para, options: Vec, recommended: Short, + depends: Vec, raised_in: Option, asked_on: Date, + } + pub struct PipelineRuling { + campaign: Short, label: Short, answers: Option, choice: Option, ruling: Para, + operator_quote: Option, ruled_on: Date, precedents: Vec, + } + pub struct PipelineCutSpec { + campaign: Short, cut: Short, revision: u32, title: Short, repo: Short, branch: Short, base: Sha, + depends_on: Vec, first: Vec, deletes: Vec, keeps_moves: Vec, + adds: Vec, file_changes: Vec, authority_map: Option, + verification: CutVerification, subtraction_estimate: SubtractionEstimate, + rulings: Vec, questions: Vec, + } + pub struct PipelineCutReport { + campaign: Short, cut_spec: Short, attempt: Short, repo: Short, branch: Short, + commits: Vec, range: CommitRange, verification: Vec, + mutations: Vec, deviations: Vec, forks: Vec, + structural_delta: StructuralDelta, landed_names: Vec, undone: Vec, + } + pub struct PipelineVerdict { campaign: Short, cut_report: Short, pass: Short, range: CommitRange, claims: Vec } + pub struct PipelineFinding { + campaign: Short, verdict: Short, label: Short, range: CommitRange, confidence: FindingConfidence, + severity: FindingSeverity, claim: Line, invariants: Vec, locations: Vec, + failure_scenario: Para, evidence: Vec, precedents: Vec, + } + pub struct PipelineFollowUp { + campaign: Short, label: Short, source: PipelineRef, repo: Short, locations: Vec, + item: Line, why_it_can_wait: Line, owner: Short, + } + pub struct PipelineResolution { subject: PipelineRef, outcome: ResolutionOutcome, rationale: Para, resolved_on: Date } + + /// Who declared an admission. Attribution only; no rule trusts it (D4). + pub struct PipelineProvenance { faculty: Faculty, agent: Short, session: Short, tool: Short } + /// Display-only record of the process holding a repo's writer lease (D3). + pub struct PipelineWriterHolder { pid: u32, host: Short, session: Short, attached_at: String } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum ResolutionOutcome { + Superseded { by: Short }, + Answered { by: Short }, + Fixed { by: Short }, + Deferred { to: Short }, + Recorded { reason: Line }, + Withdrawn { reason: Line }, +} + +impl Bounded for ResolutionOutcome { + fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { + match self { + Self::Superseded { by } | Self::Answered { by } | Self::Fixed { by } => by.validate(field), + Self::Deferred { to } => to.validate(field), + Self::Recorded { reason } | Self::Withdrawn { reason } => reason.validate(field), + } + } +} + +impl Bounded for ForeignRef { + fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { + org_repo(&format!("{at}.repo"), &self.repo.0)?; + hex(&format!("{at}.commit"), &self.commit.0, 40..=40)?; + self.id.validate(&format!("{at}.id"))?; + hex(&format!("{at}.payload_sha256"), &self.payload_sha256, 64..=64) + } +} + +impl Bounded for PipelineCampaign { + fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { + self.slug.validate(&format!("{at}.slug"))?; + self.title.validate(&format!("{at}.title"))?; + list(&format!("{at}.repos"), &self.repos, 8)?; + for (index, repo) in self.repos.iter().enumerate() { + org_repo(&format!("{at}.repos[{index}]"), &repo.0)?; + } + self.working_branch.validate(&format!("{at}.working_branch"))?; + self.target_doc.validate(&format!("{at}.target_doc")) + } +} + +bounded_struct! { + PipelineRef { id } + CodeLocation { path } + CommitRange { base, head } + Evidence { locator, result } + DocRef { path, commit } + TargetInvariant { label, statement } + QuestionOption { label, text } + CutDelete { path, note } + FileChange { location, change } + AuthorityMap { owner, inputs[16], outputs[16], derived_state[16], forbidden_writers[16], shared_paths[16], deletion_line } + VerificationTest { name, pins } + NegativeCheck { pattern, scope } + CutVerification { builds[64], tests[64], negative[64], operator[64] } + SubtractionEstimate { removed[64], added[64] } + ReportCommit { sha, subject } + MutationRecord { rule, mutation } + Deviation { what, why } + StructuralDelta { + dependencies_added[32], dependencies_removed[32], formats_added[32], formats_removed[32], + targets_added[32], targets_removed[32], + } + LandedName { name, location } + VerdictClaim { claim, evidence[8], findings[16] } + PipelineTarget { campaign, invariants[32], not_in_scope[32], canonical_implementations[16], doc } + PipelineQuestion { campaign, label, question, options[8], recommended, depends[8], raised_in, asked_on } + PipelineRuling { campaign, label, answers, choice, ruling, operator_quote, ruled_on, precedents[8] } + PipelineCutSpec { + campaign, cut, title, repo, branch, base, depends_on[8], first[16], deletes[64], keeps_moves[64], + adds[64], file_changes[256], authority_map, verification, subtraction_estimate, rulings[32], questions[16], + } + PipelineCutReport { + campaign, cut_spec, attempt, repo, branch, commits[128], range, verification[64], mutations[64], + deviations[32], forks[8], structural_delta, landed_names[128], undone[32], + } + PipelineVerdict { campaign, cut_report, pass, range, claims[64] } + PipelineFinding { + campaign, verdict, label, range, claim, invariants[8], locations[16], failure_scenario, + evidence[16], precedents[8], + } + PipelineFollowUp { campaign, label, source, repo, locations[16], item, why_it_can_wait, owner } + PipelineResolution { subject, outcome, rationale, resolved_on } + PipelineProvenance { agent, session, tool } + PipelineWriterHolder { host, session } +} + +macro_rules! pipeline_kinds { + ($($variant:ident($value:ident) => $document:ident, $name:literal, $type_id:tt, $schema:tt;)*) => { + $( + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = $type_id, schema = $schema)] + pub struct $document { + #[cultcache(key = 0)] + pub value: $value, + } + )* + + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] + pub enum PipelineKind { $($variant),* } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub enum PipelineDocument { $($variant($value)),* } + + impl PipelineKind { + pub const ALL: &'static [PipelineKind] = &[$(Self::$variant),*]; + + /// The kind segment of a document key. + pub fn name(self) -> &'static str { + match self { $(Self::$variant => $name),* } + } + + pub fn type_id(self) -> &'static str { + match self { $(Self::$variant => <$document as DatabaseEntry>::TYPE),* } + } + + #[cfg(test)] + pub(crate) fn derived_schema(self) -> schemars::Schema { + match self { $(Self::$variant => schemars::schema_for!($value)),* } + } + } + + impl PipelineDocument { + pub fn kind(&self) -> PipelineKind { + match self { $(Self::$variant(_) => PipelineKind::$variant),* } + } + + /// Field bounds (D1), in UTF-8 bytes and list maximums. + pub fn validate(&self) -> Result<(), PipelineRefusal> { + match self { $(Self::$variant(value) => value.validate($name)),* } + } + + #[cfg_attr(not(test), expect(dead_code, reason = "Cut 3b admission prepares writes through this"))] + pub(crate) fn prepare(&self, cache: &CultCache) -> Result { + let key = pipeline_key(self)?; + Ok(match self { + $(Self::$variant(value) => { + cache.prepare_entry_named(key, &$document { value: value.clone() })?.0 + })* + }) + } + + pub(crate) fn decode(envelope: &CultCacheEnvelope) -> Result { + let invalid = |error: rmp_serde::decode::Error| format_error("payload", &error.to_string()); + $(if envelope.r#type == <$document as DatabaseEntry>::TYPE { + let document: $document = rmp_serde::from_slice(&envelope.payload).map_err(invalid)?; + return Ok(Self::$variant(document.value)); + })* + Err(PipelineRefusal::ForeignStore { r#type: envelope.r#type.clone() }) + } + } + + /// Registers every type a pipeline store may hold: the ten kinds, the + /// store identity, admission provenance, and the commit receipt. + pub(crate) fn register_pipeline_document_types(cache: &mut CultCache) -> Result<()> { + $(cache.register_entry_type::<$document>()?;)* + cache.register_entry_type::()?; + cache.register_entry_type::()?; + cache.register_entry_type::()?; + Ok(()) + } + }; +} + +pipeline_kinds! { + Campaign(PipelineCampaign) => EpiphanyPipelineCampaignDocument, "campaign", + "epiphany.pipeline.campaign.v1", "EpiphanyPipelineCampaignDocument"; + Target(PipelineTarget) => EpiphanyPipelineTargetDocument, "target", + "epiphany.pipeline.target.v1", "EpiphanyPipelineTargetDocument"; + Question(PipelineQuestion) => EpiphanyPipelineQuestionDocument, "question", + "epiphany.pipeline.question.v1", "EpiphanyPipelineQuestionDocument"; + Ruling(PipelineRuling) => EpiphanyPipelineRulingDocument, "ruling", + "epiphany.pipeline.ruling.v1", "EpiphanyPipelineRulingDocument"; + CutSpec(PipelineCutSpec) => EpiphanyPipelineCutSpecDocument, "cut_spec", + "epiphany.pipeline.cut_spec.v1", "EpiphanyPipelineCutSpecDocument"; + CutReport(PipelineCutReport) => EpiphanyPipelineCutReportDocument, "cut_report", + "epiphany.pipeline.cut_report.v1", "EpiphanyPipelineCutReportDocument"; + Verdict(PipelineVerdict) => EpiphanyPipelineVerdictDocument, "verdict", + "epiphany.pipeline.verdict.v1", "EpiphanyPipelineVerdictDocument"; + Finding(PipelineFinding) => EpiphanyPipelineFindingDocument, "finding", + "epiphany.pipeline.finding.v1", "EpiphanyPipelineFindingDocument"; + FollowUp(PipelineFollowUp) => EpiphanyPipelineFollowUpDocument, "follow_up", + "epiphany.pipeline.follow_up.v1", "EpiphanyPipelineFollowUpDocument"; + Resolution(PipelineResolution) => EpiphanyPipelineResolutionDocument, "resolution", + "epiphany.pipeline.resolution.v1", "EpiphanyPipelineResolutionDocument"; +} + +/// The store's schema identity, keyed by its epoch string (D2). +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "epiphany.pipeline.identity.v1", schema = "EpiphanyPipelineIdentity")] +pub struct EpiphanyPipelineIdentity { + #[cultcache(key = 0)] + pub schema_epoch: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "epiphany.pipeline.provenance.v1", schema = "EpiphanyPipelineProvenance")] +pub struct EpiphanyPipelineProvenance { + #[cultcache(key = 0)] + pub value: PipelineProvenance, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "epiphany.pipeline.writer_holder.v1", schema = "EpiphanyPipelineWriterHolder")] +pub struct EpiphanyPipelineWriterHolder { + #[cultcache(key = 0)] + pub value: PipelineWriterHolder, +} + +/// The local segment of a parent id `::` in `campaign`. +fn parent_local<'a>( + field: &str, + id: &'a str, + campaign: &str, + kind: PipelineKind, +) -> Result<&'a str, PipelineRefusal> { + match id.splitn(3, ':').collect::>()[..] { + [owner, segment, local] if owner == campaign && segment == kind.name() => Ok(local), + _ => Err(format_error(field, id)), + } +} + +/// The cut label inside a parent local `cut-