diff --git a/epiphany-pipeline/Cargo.toml b/epiphany-pipeline/Cargo.toml index d1a34126..05be2812 100644 --- a/epiphany-pipeline/Cargo.toml +++ b/epiphany-pipeline/Cargo.toml @@ -10,16 +10,18 @@ license-file = "../LICENSE" name = "epiphany_pipeline" path = "src/lib.rs" -# A leaf type library. Every dependency here is one the document types need in -# a non-test build; the write path's crates are dev-dependencies, because the -# path itself is `cfg(test)` until the organ owns admission. +# A leaf type library. Every dependency here is one the document types or the +# write path need in a non-test build: the organ prepares, decodes and +# validates envelopes through this crate, so `anyhow` (the cache's error type) +# and `rmp-serde` (the payload decode) are normal dependencies. Only the +# schema-publication check needs JSON. [dependencies] +anyhow = "1" chrono = "0.4.44" cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" } +rmp-serde = "1" schemars = "1" serde = { version = "1", features = ["derive"] } [dev-dependencies] -anyhow = "1" -rmp-serde = "1" serde_json = "1" diff --git a/epiphany-pipeline/src/lib.rs b/epiphany-pipeline/src/lib.rs index a8f443b5..ffcd92bd 100644 --- a/epiphany-pipeline/src/lib.rs +++ b/epiphany-pipeline/src/lib.rs @@ -25,26 +25,23 @@ //! hand-written impl that a later field can slip past. A `Vec` field must carry //! a maximum, because `Vec` has no `Bounded` impl of its own. //! -//! The wrappers are crate-private: outside code reaches the store through the -//! admission path, never by registering or preparing a pipeline type itself. +//! The wrappers are crate-private: outside code registers, prepares and +//! decodes them through `register_pipeline_document_types`, +//! `PipelineDocument::prepare` and `PipelineDocument::decode`, and validates a +//! write through `validate_pipeline_write_envelope`. -use cultcache_rs::DatabaseEntry; -// Envelopes are a test-only shape here until the organ prepares, decodes and -// validates them; only the type ids survive into the live path. `anyhow` and -// `rmp-serde` come with them, which is why both are dev-dependencies: the live -// library's errors are all `PipelineRefusal`. -#[cfg(test)] +// `anyhow` is the cache's own error type, which `prepare` and the registrar +// pass through; every refusal this crate decides is a `PipelineRefusal`. use anyhow::Result; -#[cfg(test)] -use cultcache_rs::{CultCache, CultCacheEnvelope}; +use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// Typed refusals of the pipeline documents. Bounds, formats and key identity -/// are the document half (D2); `ForeignStore` is raised by the decode path that -/// still lives here, and D2 hands it to the organ when admission moves there. -/// Identity, the schema epoch and the `ForeignEpoch` refusal are the organ's -/// (D2), and Cut 8 writes them there against a store a test can construct. +/// Typed refusals of the pipeline documents: bounds, formats and key identity +/// (D2's document half), and `ForeignStore`, raised by `decode` for an +/// envelope of any other type. The organ's refusals wrap this enum; the +/// store's identity and the schema epoch are refused there, against a store a +/// test can construct. #[derive(Clone, Debug, PartialEq, Eq)] pub enum PipelineRefusal { FieldBound { field: String, limit: u32, actual: u32 }, @@ -472,12 +469,9 @@ macro_rules! pipeline_kinds { match self { $(Self::$variant(value) => value.validate($name)),* } } - /// Test scaffolding until the organ prepares writes (Cut 8). Every - /// caller is a test, and the only cache carrying these types is the - /// `cfg(test)` registrar beside it, so this is `cfg(test)` rather - /// than a live path wearing a dead-code waiver. - #[cfg(test)] - pub(crate) fn prepare(&self, cache: &CultCache) -> Result { + /// Prepares the envelope the organ stores: keyed by `pipeline_key`, + /// payload `[value]` through `prepare_entry_named`. + pub fn prepare(&self, cache: &CultCache) -> Result { let key = pipeline_key(self)?; Ok(match self { $(Self::$variant(value) => { @@ -486,10 +480,10 @@ macro_rules! pipeline_kinds { }) } - /// Test-only with its only caller, the write validator below, until - /// the organ decodes admitted envelopes (Cut 8). - #[cfg(test)] - pub(crate) fn decode(envelope: &CultCacheEnvelope) -> Result { + /// Decodes a stored envelope, type-matched both ways: an envelope of + /// any other type is `ForeignStore`, never the kind that happens to + /// parse its payload. + pub 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)?; @@ -499,25 +493,23 @@ macro_rules! pipeline_kinds { } } - /// Registers every type the document tests put in a cache: each kind - /// and one foreign document. Test scaffolding until the organ registers - /// its mind's types, which is why it is `cfg(test)` rather than a live - /// path wearing a dead-code waiver. - #[cfg(test)] - pub(crate) fn register_pipeline_document_types(cache: &mut CultCache) -> Result<()> { + /// Registers every pipeline kind in a cache, and nothing else: the one + /// door to the crate-private wrappers, so the organ registers exactly + /// what this crate publishes. + pub fn register_pipeline_document_types(cache: &mut CultCache) -> Result<()> { $(cache.register_entry_type::<$document>()?;)* - cache.register_entry_type::()?; Ok(()) } }; } /// A document that is not a pipeline document, for the decode refusal and the -/// spine-registry rules to be pinned against something real. The organ's Mind -/// commit receipt is the case that matters: a mind's store legitimately holds -/// one beside pipeline documents, and it is still not a document this library -/// may decode. That type belongs to the harness, not here, so the rule is -/// pinned against a stand-in carrying its type id. +/// registrar's count to be pinned against something real. It stands in for +/// the commit receipt of any organ: a mind's store legitimately holds one +/// beside pipeline documents, and it is still not a document this library may +/// decode. Huginn pins the same rule against its real receipt type; here the +/// stand-in carries a receipt-shaped type id and is registered only by the +/// tests' own cache, never by the live registrar. #[cfg(test)] #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "epiphany.mind_commit_receipt.v1", schema = "ForeignDocument")] @@ -643,6 +635,14 @@ const ROOT_LOCAL: &str = "self"; /// from this name rather than restating the number. const LOCAL_MAX: usize = 64; +/// The schema epoch every pipeline store is written at, owned here with the +/// schemas it names. Evolution is additive and keeps it: a new named field +/// with a serde default, or a widened `PipelineKind`, since each reader ships +/// with the variants it knows and refuses an unknown kind on the kind, not on +/// the epoch. A breaking change bumps it, and the organ refuses a store +/// written at the old one. +pub const PIPELINE_SCHEMA_EPOCH: &str = "epiphany.pipeline.epoch.v1"; + /// Composes and validates a local: every part is a `Label`, and the join is /// bounded whole. Both rules live here because this is the only way a local is /// built; `pipeline_key` has no other path to a key string. Parts are a slice, @@ -736,12 +736,10 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result Result<()> { +/// Bounds, formats, then key recomputation, on the envelope that will be +/// stored, whoever prepared it. Per-kind and cross-document rules belong to +/// the organ's admission path, which calls this first and never re-derives it. +pub fn validate_pipeline_write_envelope(envelope: &CultCacheEnvelope) -> Result<(), PipelineRefusal> { let document = PipelineDocument::decode(envelope)?; document.validate()?; let expected = pipeline_key(&document)?; @@ -750,8 +748,7 @@ fn validate_pipeline_write_envelope(envelope: &CultCacheEnvelope) -> Result<()> kind: document.kind(), key: envelope.key.clone(), expected, - } - .into()); + }); } Ok(()) } @@ -974,9 +971,12 @@ mod tests { resolution } + /// The live registrar plus the foreign stand-in, which only the tests + /// register. fn schema_cache() -> Result { let mut cache = CultCache::new(); register_pipeline_document_types(&mut cache)?; + cache.register_entry_type::()?; Ok(cache) } @@ -1069,10 +1069,9 @@ mod tests { 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 }) + validate_pipeline_write_envelope(&envelope), + Err(PipelineRefusal::InvalidIdentity { kind: document.kind(), key: envelope.key, expected: key }) ); } let mut resolution = resolution_sample(); @@ -1964,4 +1963,32 @@ mod tests { assert!(stale.is_empty(), "published pipeline schemas differ from the Rust derivation; derived copies: {stale:?}"); Ok(()) } + + /// The epoch is the version every kind is published at: its version + /// segment is read from the constant, not restated, and every type id + /// carries the same one, so a bumped epoch fails here until the kinds + /// move with it. The live registrar registers the kinds and nothing else, + /// so the foreign stand-in is not among them. + #[test] + fn every_kind_is_at_the_epochs_version() -> Result<()> { + let (name, version) = PIPELINE_SCHEMA_EPOCH.rsplit_once('.').expect("the epoch carries a version"); + assert_eq!(name, "epiphany.pipeline.epoch"); + assert!( + version.len() > 1 && version.starts_with('v') && version[1..].bytes().all(|byte| byte.is_ascii_digit()), + "{version:?} is not a version segment" + ); + for kind in PipelineKind::ALL { + let type_id = kind.type_id(); + assert!(type_id.ends_with(&format!(".{version}")), "{type_id} is not at the epoch's version {version}"); + } + + let mut cache = CultCache::new(); + register_pipeline_document_types(&mut cache)?; + let registered = cache.registered_entry_types(); + assert_eq!(registered.len(), PipelineKind::ALL.len(), "the live registrar registers exactly the kinds: {registered:?}"); + for kind in PipelineKind::ALL { + assert!(registered.iter().any(|name| name == kind.type_id()), "{kind:?} is registered"); + } + Ok(()) + } } diff --git a/tools/eureka-cut8-epiphany-mutations.psd1 b/tools/eureka-cut8-epiphany-mutations.psd1 new file mode 100644 index 00000000..a5faae39 --- /dev/null +++ b/tools/eureka-cut8-epiphany-mutations.psd1 @@ -0,0 +1,46 @@ +# Eureka Cut 8 mutation suite, the Epiphany half: the epoch constant and the +# live registrar. Entries only; the harness is tools/eureka-mutations.ps1. +# +# powershell -File tools/eureka-mutations.ps1 ` +# -Entries tools/eureka-cut8-epiphany-mutations.psd1 ` +# -Target epiphany-pipeline/src/lib.rs ` +# -Test 'cargo test -p epiphany-pipeline --lib' +# +# All three are killed by `every_kind_is_at_the_epochs_version`: it reads the +# version segment off the constant and holds every type id to it, so a wrong +# epoch dies whether it is `.v2` or `.v1x`; and it counts what the live +# registrar registers against `PipelineKind::ALL`, so one type fewer and one +# foreign type more both die on the count. +@{ + Mutations = @( + @{ + Id = 'E1' + Rule = 'The epoch names the version every kind is published at; a bumped epoch fails until the kinds move with it.' + Test = 'tests::every_kind_is_at_the_epochs_version' + Old = 'pub const PIPELINE_SCHEMA_EPOCH: &str = "epiphany.pipeline.epoch.v1";' + New = 'pub const PIPELINE_SCHEMA_EPOCH: &str = "epiphany.pipeline.epoch.v2";' + }, + @{ + Id = 'E2' + Rule = 'The live registrar registers every kind: one fewer is refused on the count.' + Test = 'tests::every_kind_is_at_the_epochs_version' + Old = ' $(cache.register_entry_type::<$document>()?;)*' + New = ' let mut first = true; $(if !first { cache.register_entry_type::<$document>()?; } first = false;)*' + }, + @{ + Id = 'E3' + Rule = 'The live registrar registers nothing but the kinds: the foreign stand-in registered again under cfg(test) is refused on the count.' + Test = 'tests::every_kind_is_at_the_epochs_version' + Old = @' + $(cache.register_entry_type::<$document>()?;)* + Ok(()) +'@ + New = @' + $(cache.register_entry_type::<$document>()?;)* + #[cfg(test)] + cache.register_entry_type::()?; + Ok(()) +'@ + } + ) +}