Cut 8, Epiphany half: the leaf's write path goes live for the organ

`epiphany-pipeline` was the organ's type library with its prepare,
decode, register and validate path gated `cfg(test)` "until the organ
prepares writes (Cut 8)". This is Cut 8. The four are `pub` and
un-gated: `register_pipeline_document_types` registers the thirteen
kinds and nothing else, `PipelineDocument::prepare` and `::decode` are
the one door to the crate-private wrappers, and
`validate_pipeline_write_envelope` returns `PipelineRefusal` directly,
which drops the `downcast_ref` in the key test. `anyhow` and `rmp-serde`
move from dev to normal dependencies; both were already in the lock, so
the lock is unchanged. The test stand-in `ForeignDocument` stays
`cfg(test)` and is registered by the tests' `schema_cache`, no longer by
the live registrar.

`PIPELINE_SCHEMA_EPOCH` returns as one `pub const` beside the schemas it
names, with the README's evolution rule as its doc comment: additive
keeps it, a widened `PipelineKind` is additive, a breaking bump refuses
the old store. `every_kind_is_at_the_epochs_version` reads the version
off the constant and holds every type id to it, and counts the live
registrar's types against `PipelineKind::ALL`.

Verification: `cargo check -p epiphany-pipeline --lib --tests` and
`cargo test -p epiphany-pipeline --lib` (27 passed, 0 warnings);
`cargo check -p epiphany-core --lib --tests` untouched; `cfg(test)` in
lib.rs is now only `derived_schema`, `ForeignDocument` and `mod tests`;
`schemas/cultnet/` and `Cargo.lock` unchanged. Mutations
`tools/eureka-cut8-epiphany-mutations.psd1`: M0 green, E1 (epoch `.v2`),
E2 (one kind fewer registered), E3 (the stand-in registered again under
`cfg(test)`) all killed by the new test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-16 16:21:34 +02:00
commit a65c6420f6
3 changed files with 129 additions and 54 deletions

View file

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

View file

@ -25,26 +25,23 @@
//! hand-written impl that a later field can slip past. A `Vec` field must carry
//! a maximum, because `Vec<T>` 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<CultCacheEnvelope> {
/// Prepares the envelope the organ stores: keyed by `pipeline_key`,
/// payload `[value]` through `prepare_entry_named`.
pub fn prepare(&self, cache: &CultCache) -> Result<CultCacheEnvelope> {
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<Self, PipelineRefusal> {
/// 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<Self, PipelineRefusal> {
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::<ForeignDocument>()?;
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<String, PipelineRefus
Ok(format!("{root}:{kind}:{composed}"))
}
/// Bounds, formats, then key recomputation, for one envelope. Per-kind
/// admission rules belong to the organ's admission path. Its only caller is a
/// test until the organ validates writes through it (Cut 8), so it is
/// `cfg(test)` rather than a live path wearing a dead-code waiver.
#[cfg(test)]
fn validate_pipeline_write_envelope(envelope: &CultCacheEnvelope) -> 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<CultCache> {
let mut cache = CultCache::new();
register_pipeline_document_types(&mut cache)?;
cache.register_entry_type::<ForeignDocument>()?;
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::<PipelineRefusal>(),
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(())
}
}

View file

@ -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::<ForeignDocument>()?;
Ok(())
'@
}
)
}