Extract epiphany-pipeline, and add the three kinds a mind is keyed by
Ruling 1 keeps schema ownership in Epiphany; ruling 17 puts admission in
Huginn. Huginn must not take on epiphany-core -- about 41,000 lines and 295
lock packages -- to use about 1,000 lines of document types. So the documents
leave as a leaf library, and epiphany-core keeps no pipeline surface at all:
its consumption is campaign two.
`epiphany-core/src/pipeline_documents.rs` moves whole to
`epiphany-pipeline/src/lib.rs`, with its nine tests. Only three things changed
in the move: the module doc now describes a package rather than a cut map
section; `anyhow` joins `rmp-serde` behind `cfg(test)`, because the write path
has been test-only since 186dbc62 and the live library's errors are all
`PipelineRefusal`; and the decode refusal, which was pinned against
epiphany-core's Mind commit receipt, is pinned against a local `ForeignDocument`
carrying that receipt's type id, since the receipt belongs to the harness.
The package declares four dependencies. chrono parses a Date, cultcache-rs
supplies DatabaseEntry, schemars derives the published schemas (Q5), and serde
is the document format. anyhow, rmp-serde and serde_json are dev-dependencies:
every use of them is `cfg(test)`. `autobins = false`, as a leaf library must.
The workspace lock grows by exactly one entry, the member itself; no new
third-party package.
Deleted rather than re-exported: epiphany-core's `mod pipeline_documents;` and
`pub use pipeline_documents::*;`, and `schemars` from its manifest, whose only
user was this module. No shim, no duplicate type.
`runtime_spine_cache_refuses_a_pipeline_store` stays in epiphany-core, because
the rule is the spine registry's: it registers its own types and nothing else.
It now builds its foreign envelope from a `cfg(test)` stand-in carrying
`epiphany.pipeline.campaign.v1` rather than from the moved documents, so the
rule keeps its test without the package keeping a dependency.
D2's three new kinds arrive with the move, each with a live consumer in the
organ: `instance` is a mind's identity document, `stewardship` records the
repos an instance is assigned, and `hand_off` records a reassignment in both
minds. All three hang off an instance rather than a campaign, so they compose
their own keys instead of borrowing the `<campaign>:<kind>:<local>` tail. A
repo inside a key is escaped to `Org_Repo`, because `/` is not a Label byte.
Thirteen schemas now derive. The five that embed `PipelineKind` gained three
enum variants, which is additive, so Q2 keeps `epiphany.pipeline.epoch.v1`.
epiphany-pipeline: 12 tests, 0 warnings. epiphany-core: 163 tests -- 172 minus
the nine that moved -- 0 warnings. epiphany-state.exe is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
93463160b3
commit
7ab838f167
16 changed files with 509 additions and 36 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -550,7 +550,6 @@ dependencies = [
|
|||
"ignore",
|
||||
"libc",
|
||||
"rmp-serde",
|
||||
"schemars",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -591,6 +590,19 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epiphany-pipeline"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"cultcache-rs",
|
||||
"rmp-serde",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epiphany-release-bundle"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ members = [
|
|||
"epiphany-core",
|
||||
"epiphany-model-adapter",
|
||||
"epiphany-openai-runtime",
|
||||
"epiphany-pipeline",
|
||||
"epiphany-tool-adapter",
|
||||
"epiphany-tool-mcp-runtime",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ ghostlight-persona-projection = { git = "https://github.com/GameCult/Ghostlight.
|
|||
ed25519-dalek = "2"
|
||||
ignore = "0.4.23"
|
||||
rmp-serde = "1"
|
||||
schemars = "1"
|
||||
serde ={ version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ mod persona_discord_permit;
|
|||
mod persona_feedback_admission;
|
||||
mod persona_social_state;
|
||||
mod persona_turn;
|
||||
mod pipeline_documents;
|
||||
mod process_observation;
|
||||
mod public_source_identity;
|
||||
mod reasoning_context;
|
||||
|
|
@ -127,7 +126,6 @@ pub use packaged_release::{
|
|||
pub use persona_conversation::*;
|
||||
pub use persona_discord_crossing::*;
|
||||
pub use persona_discord_permit::*;
|
||||
pub use pipeline_documents::*;
|
||||
pub use persona_feedback_admission::{
|
||||
BIFROST_PERSONA_FEEDBACK_ADMISSION_SCHEMA_VERSION, BIFROST_PERSONA_FEEDBACK_DELIVERY_TYPE,
|
||||
BifrostPersonaFeedbackAdmission, LOCAL_PERSONA_FEEDBACK_SCHEMA_VERSION,
|
||||
|
|
|
|||
|
|
@ -8751,6 +8751,21 @@ pub(crate) mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// A stand-in for a document of the Eureka pipeline, which
|
||||
/// `epiphany-pipeline` owns and this package deliberately does not depend
|
||||
/// on. The rule under test is the spine registry's, not the document's:
|
||||
/// what the spine cache sees is a type id it never registered, and that is
|
||||
/// all this needs to carry.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "epiphany.pipeline.campaign.v1",
|
||||
schema = "EpiphanyPipelineCampaignDocument"
|
||||
)]
|
||||
struct ForeignPipelineDocument {
|
||||
#[cultcache(key = 0)]
|
||||
slug: String,
|
||||
}
|
||||
|
||||
/// Soul F4: the spine store and a pipeline store are different stores, and
|
||||
/// the spine cache refuses the latter *by type* — it registers its own
|
||||
/// types and nothing else. The epoch checks beside this one cannot stand in
|
||||
|
|
@ -8762,20 +8777,13 @@ pub(crate) mod tests {
|
|||
let temp = tempfile::tempdir()?;
|
||||
let store = temp.path().join("pipeline.cc");
|
||||
let mut pipeline = CultCache::new();
|
||||
crate::pipeline_documents::register_pipeline_document_types(&mut pipeline)?;
|
||||
let campaign = crate::PipelineDocument::Campaign(crate::PipelineCampaign {
|
||||
slug: "eureka-state".into(),
|
||||
title: "Eureka pipeline state".into(),
|
||||
repos: vec!["GameCult/Epiphany".into()],
|
||||
working_branch: "codex/eureka-pipeline-state".into(),
|
||||
target_doc: crate::DocRef {
|
||||
path: "notes/eureka-pipeline-state-target.md".into(),
|
||||
start_line: 1,
|
||||
end_line: 9,
|
||||
commit: "5f98228d".into(),
|
||||
},
|
||||
});
|
||||
let envelope = campaign.prepare(&pipeline)?;
|
||||
pipeline.register_entry_type::<ForeignPipelineDocument>()?;
|
||||
let envelope = pipeline
|
||||
.prepare_entry_named(
|
||||
"eureka-state",
|
||||
&ForeignPipelineDocument { slug: "eureka-state".into() },
|
||||
)?
|
||||
.0;
|
||||
assert!(
|
||||
envelope.r#type.starts_with("epiphany.pipeline."),
|
||||
"the sample is a pipeline document: {}",
|
||||
|
|
|
|||
25
epiphany-pipeline/Cargo.toml
Normal file
25
epiphany-pipeline/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "epiphany-pipeline"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
autobins = false
|
||||
license-file = "../LICENSE"
|
||||
|
||||
[lib]
|
||||
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.
|
||||
[dependencies]
|
||||
chrono = "0.4.44"
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" }
|
||||
schemars = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1"
|
||||
rmp-serde = "1"
|
||||
serde_json = "1"
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
//! Eureka pipeline documents (cut map D1).
|
||||
//! Eureka pipeline documents: the typed shape of a Eureka campaign's state.
|
||||
//!
|
||||
//! This is a leaf type library, and deliberately nothing else. It owns document
|
||||
//! shape, field bounds, formats, key derivation and the JSON schemas published
|
||||
//! under `schemas/cultnet`; it owns no storage, no admission, no process and no
|
||||
//! network. That is the whole reason it is a package: the memory organ depends
|
||||
//! on these types without depending on the harness that used to hold them.
|
||||
//!
|
||||
//! Every kind is a plain `serde` + `JsonSchema` value inside a one-slot
|
||||
//! `DatabaseEntry` wrapper, always prepared with `prepare_entry_named`, so the
|
||||
|
|
@ -16,10 +22,13 @@
|
|||
//! The wrappers are crate-private: outside code reaches the store through the
|
||||
//! admission path, never by registering or preparing a pipeline type itself.
|
||||
|
||||
use anyhow::Result;
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
// Envelopes are a test-only shape here until the organ prepares, decodes and
|
||||
// validates them (Cut 8); only the type ids survive into the live path.
|
||||
// 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)]
|
||||
use anyhow::Result;
|
||||
#[cfg(test)]
|
||||
use cultcache_rs::{CultCache, CultCacheEnvelope};
|
||||
use schemars::JsonSchema;
|
||||
|
|
@ -329,6 +338,20 @@ value_types! {
|
|||
item: Line, why_it_can_wait: Line, owner: Short,
|
||||
}
|
||||
pub struct PipelineResolution { subject: PipelineRef, outcome: ResolutionOutcome, rationale: Para, resolved_on: Date }
|
||||
|
||||
/// A mind's identity document. A store is canonical to exactly one
|
||||
/// instance, and this says which; identity lives in the state, not in a
|
||||
/// path.
|
||||
pub struct PipelineInstance { instance: Slug, display_name: Short, created_at: Date, host: Short }
|
||||
/// Stewardship over a repo, as an assignment recorded in a mind. One
|
||||
/// instance may steward several repos, so the repo is part of the key.
|
||||
pub struct PipelineStewardship { instance: Slug, repo: OrgRepo, assigned_on: Date, note: Line }
|
||||
/// A reassignment of stewardship, recorded in both minds. `documents` names
|
||||
/// what travels with it.
|
||||
pub struct PipelineHandOff {
|
||||
from_instance: Slug, to_instance: Slug, repo: OrgRepo, documents: Vec<Short>[256],
|
||||
reason: Para, handed_on: Date,
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to another document. The id is parsed as a full pipeline id of
|
||||
|
|
@ -444,19 +467,33 @@ macro_rules! pipeline_kinds {
|
|||
}
|
||||
}
|
||||
|
||||
/// Registers every type the document tests put in a cache: the ten
|
||||
/// kinds and the commit receipt. Test scaffolding until the organ
|
||||
/// registers its mind's types (Cut 8), which is why it is `cfg(test)`
|
||||
/// rather than a live path wearing a dead-code waiver.
|
||||
/// 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<()> {
|
||||
$(cache.register_entry_type::<$document>()?;)*
|
||||
cache.register_entry_type::<crate::EpiphanyMindCommitReceipt>()?;
|
||||
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.
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "epiphany.mind_commit_receipt.v1", schema = "ForeignDocument")]
|
||||
pub(crate) struct ForeignDocument {
|
||||
#[cultcache(key = 0)]
|
||||
pub(crate) marker: Short,
|
||||
}
|
||||
|
||||
pipeline_kinds! {
|
||||
Campaign(PipelineCampaign) => EpiphanyPipelineCampaignDocument, "campaign",
|
||||
"epiphany.pipeline.campaign.v1", "EpiphanyPipelineCampaignDocument";
|
||||
|
|
@ -478,6 +515,12 @@ pipeline_kinds! {
|
|||
"epiphany.pipeline.follow_up.v1", "EpiphanyPipelineFollowUpDocument";
|
||||
Resolution(PipelineResolution) => EpiphanyPipelineResolutionDocument, "resolution",
|
||||
"epiphany.pipeline.resolution.v1", "EpiphanyPipelineResolutionDocument";
|
||||
Instance(PipelineInstance) => EpiphanyPipelineInstanceDocument, "instance",
|
||||
"epiphany.pipeline.instance.v1", "EpiphanyPipelineInstanceDocument";
|
||||
Stewardship(PipelineStewardship) => EpiphanyPipelineStewardshipDocument, "stewardship",
|
||||
"epiphany.pipeline.stewardship.v1", "EpiphanyPipelineStewardshipDocument";
|
||||
HandOff(PipelineHandOff) => EpiphanyPipelineHandOffDocument, "hand_off",
|
||||
"epiphany.pipeline.hand_off.v1", "EpiphanyPipelineHandOffDocument";
|
||||
}
|
||||
|
||||
/// Parses a full document id. `<campaign>:<kind>:<local>` for every kind but
|
||||
|
|
@ -542,10 +585,43 @@ fn parent_cut<'a>(field: &str, local: &'a str, marker: char) -> Result<&'a str,
|
|||
Ok(cut)
|
||||
}
|
||||
|
||||
/// A repo as one key segment: the `OrgRepo` with its `/` replaced by `_`,
|
||||
/// because `/` is not a `Label` byte and a composed key must segment one way
|
||||
/// only. The caller checks the result against the key's segment rules, which is
|
||||
/// where an over-long repo name is refused.
|
||||
fn repo_segment(field: &str, repo: &OrgRepo) -> Result<String, PipelineRefusal> {
|
||||
org_repo_text(field, &repo.0)?;
|
||||
Ok(repo.0.replace('/', "_"))
|
||||
}
|
||||
|
||||
/// Derives a document's identity key (D1, "Keys: identity, not convenience").
|
||||
pub fn pipeline_key(document: &PipelineDocument) -> Result<String, PipelineRefusal> {
|
||||
use PipelineDocument as D;
|
||||
let (campaign, local) = match document {
|
||||
// An instance is keyed by its own slug, as a campaign is: it is a root,
|
||||
// not a document inside one.
|
||||
D::Instance(value) => {
|
||||
dotted_text("instance.instance", &value.instance.0)?;
|
||||
return Ok(value.instance.0.clone());
|
||||
}
|
||||
// Stewardship and hand-off hang off an instance, not a campaign, so
|
||||
// they compose their own key rather than falling through to the
|
||||
// `<campaign>:<kind>:<local>` tail below.
|
||||
D::Stewardship(value) => {
|
||||
dotted_text("stewardship.instance", &value.instance.0)?;
|
||||
let local = repo_segment("stewardship.repo", &value.repo)?;
|
||||
dotted_text("stewardship.key", &local)?;
|
||||
return Ok(format!("{}:stewardship:{local}", value.instance.0));
|
||||
}
|
||||
D::HandOff(value) => {
|
||||
dotted_text("hand_off.from_instance", &value.from_instance.0)?;
|
||||
dotted_text("hand_off.to_instance", &value.to_instance.0)?;
|
||||
let repo = repo_segment("hand_off.repo", &value.repo)?;
|
||||
value.handed_on.validate("hand_off.handed_on")?;
|
||||
let local = format!("{}.{repo}.{}", value.to_instance.0, value.handed_on.0);
|
||||
dotted_text("hand_off.key", &local)?;
|
||||
return Ok(format!("{}:hand_off:{local}", value.from_instance.0));
|
||||
}
|
||||
D::Campaign(value) => {
|
||||
dotted_text("campaign.slug", &value.slug.0)?;
|
||||
return Ok(value.slug.0.clone());
|
||||
|
|
@ -610,6 +686,7 @@ mod tests {
|
|||
use std::path::Path;
|
||||
|
||||
const CAMPAIGN: &str = "eureka-state";
|
||||
const INSTANCE: &str = "yggdrasil";
|
||||
|
||||
fn s(value: &str) -> Short {
|
||||
value.into()
|
||||
|
|
@ -640,7 +717,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn location() -> CodeLocation {
|
||||
CodeLocation { path: s("epiphany-core/src/pipeline_documents.rs"), line: 1, end_line: Some(9) }
|
||||
CodeLocation { path: s("epiphany-pipeline/src/lib.rs"), line: 1, end_line: Some(9) }
|
||||
}
|
||||
|
||||
fn evidence() -> Evidence {
|
||||
|
|
@ -719,7 +796,7 @@ mod tests {
|
|||
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") }],
|
||||
landed_names: vec![LandedName { name: s("PipelineDocument"), path: s("epiphany-pipeline/src/lib.rs") }],
|
||||
undone: vec!["admission".into()],
|
||||
}), format!("{CAMPAIGN}:cut_report:cut-3a.h1")),
|
||||
(D::Verdict(PipelineVerdict {
|
||||
|
|
@ -747,9 +824,39 @@ mod tests {
|
|||
outcome: ResolutionOutcome::Answered { by: id("ruling", "R8") },
|
||||
rationale: "Ruled A.".into(), resolved_on: date(),
|
||||
}), format!("resolution:{CAMPAIGN}:question:Q1")),
|
||||
// The mind's own three kinds. They are appended rather than
|
||||
// inserted because the helpers below index this list by position.
|
||||
(D::Instance(PipelineInstance {
|
||||
instance: slug(INSTANCE), display_name: s("Yggdrasil mind"), created_at: date(),
|
||||
host: s("yggdrasil"),
|
||||
}), INSTANCE.into()),
|
||||
(D::Stewardship(PipelineStewardship {
|
||||
instance: slug(INSTANCE), repo: repo(), assigned_on: date(),
|
||||
note: "The Eureka campaign repo.".into(),
|
||||
}), format!("{INSTANCE}:stewardship:GameCult_Epiphany")),
|
||||
(D::HandOff(PipelineHandOff {
|
||||
from_instance: slug(INSTANCE), to_instance: slug("thought-cage"), repo: repo(),
|
||||
documents: vec![s(CAMPAIGN), id("ruling", "R8")],
|
||||
reason: "The workstation mind takes the campaign.".into(), handed_on: date(),
|
||||
}), format!("{INSTANCE}:hand_off:thought-cage.GameCult_Epiphany.{}", date().0)),
|
||||
]
|
||||
}
|
||||
|
||||
fn instance_sample() -> PipelineInstance {
|
||||
let PipelineDocument::Instance(instance) = samples().remove(10).0 else { unreachable!() };
|
||||
instance
|
||||
}
|
||||
|
||||
fn stewardship_sample() -> PipelineStewardship {
|
||||
let PipelineDocument::Stewardship(stewardship) = samples().remove(11).0 else { unreachable!() };
|
||||
stewardship
|
||||
}
|
||||
|
||||
fn hand_off_sample() -> PipelineHandOff {
|
||||
let PipelineDocument::HandOff(hand_off) = samples().remove(12).0 else { unreachable!() };
|
||||
hand_off
|
||||
}
|
||||
|
||||
fn campaign_sample() -> PipelineDocument {
|
||||
samples().remove(0).0
|
||||
}
|
||||
|
|
@ -806,12 +913,12 @@ mod tests {
|
|||
/// test above pins the positive match; this pins the refusal, so an
|
||||
/// envelope belonging to another kind can never be decoded as whichever
|
||||
/// pipeline kind happens to parse its payload. The commit receipt is the
|
||||
/// sharp case: a pipeline store may legitimately hold one, and it is still
|
||||
/// not a document.
|
||||
/// sharp case: a mind's store may legitimately hold one, and it is still
|
||||
/// not a document; `ForeignDocument` stands in for it here.
|
||||
#[test]
|
||||
fn decode_refuses_an_envelope_of_a_foreign_type() -> Result<()> {
|
||||
let cache = schema_cache()?;
|
||||
let foreign = <crate::EpiphanyMindCommitReceipt as DatabaseEntry>::TYPE;
|
||||
let foreign = <ForeignDocument as DatabaseEntry>::TYPE;
|
||||
assert!(!foreign.starts_with("epiphany.pipeline."), "{foreign} is a foreign type id");
|
||||
let mut envelope = campaign_sample().prepare(&cache)?;
|
||||
envelope.r#type = foreign.into();
|
||||
|
|
@ -1003,6 +1110,122 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// The three kinds a mind is keyed by. The samples above already round-trip
|
||||
/// every kind; this pins the shapes D2 gives these three specifically: an
|
||||
/// instance is a root keyed by its own slug, and the other two hang off an
|
||||
/// instance rather than a campaign, so neither borrows the campaign tail.
|
||||
#[test]
|
||||
fn instance_stewardship_and_hand_off_round_trip() -> Result<()> {
|
||||
let cache = schema_cache()?;
|
||||
let instance = PipelineDocument::Instance(instance_sample());
|
||||
assert_eq!(pipeline_key(&instance), Ok(INSTANCE.to_string()));
|
||||
for document in [
|
||||
instance,
|
||||
PipelineDocument::Stewardship(stewardship_sample()),
|
||||
PipelineDocument::HandOff(hand_off_sample()),
|
||||
] {
|
||||
document.validate()?;
|
||||
let envelope = document.prepare(&cache)?;
|
||||
assert_eq!(PipelineDocument::decode(&envelope)?, document);
|
||||
let key = pipeline_key(&document)?;
|
||||
assert!(
|
||||
!key.starts_with(&format!("{CAMPAIGN}:")),
|
||||
"{:?} is keyed by its instance, not by a campaign: {key}",
|
||||
document.kind()
|
||||
);
|
||||
validate_pipeline_write_envelope(&envelope)?;
|
||||
}
|
||||
// A bounded list is still bounded: `documents` carries a maximum, as
|
||||
// every `Vec` field must.
|
||||
let mut wide = hand_off_sample();
|
||||
wide.documents = vec![s("x"); 257];
|
||||
assert_eq!(
|
||||
PipelineDocument::HandOff(wide).validate(),
|
||||
Err(PipelineRefusal::FieldBound { field: "hand_off.documents".into(), limit: 256, actual: 257 })
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The repo is one key segment, so its slash is escaped to `_`. Left
|
||||
/// unescaped it would both add a segment the reader cannot tell from a
|
||||
/// real one and put a byte in the key that no `Label` may carry.
|
||||
#[test]
|
||||
fn stewardship_key_escapes_the_repo_slash() {
|
||||
let stewardship = stewardship_sample();
|
||||
assert_eq!(stewardship.repo, OrgRepo("GameCult/Epiphany".into()));
|
||||
let key = pipeline_key(&PipelineDocument::Stewardship(stewardship.clone()));
|
||||
assert_eq!(key, Ok(format!("{INSTANCE}:stewardship:GameCult_Epiphany")));
|
||||
assert!(!key.unwrap().contains('/'), "no key segment carries a slash");
|
||||
|
||||
// The escape is not a cosmetic substitution: two repos that differ only
|
||||
// by the escaped byte still key apart.
|
||||
let mut underscored = stewardship.clone();
|
||||
underscored.repo = OrgRepo("GameCult_Epiphany/thing".into());
|
||||
assert_eq!(
|
||||
pipeline_key(&PipelineDocument::Stewardship(underscored)),
|
||||
Ok(format!("{INSTANCE}:stewardship:GameCult_Epiphany_thing"))
|
||||
);
|
||||
|
||||
let mut no_org = stewardship.clone();
|
||||
no_org.repo = OrgRepo("Epiphany".into());
|
||||
assert_eq!(
|
||||
pipeline_key(&PipelineDocument::Stewardship(no_org)),
|
||||
Err(PipelineRefusal::InvalidFormat { field: "stewardship.repo".into(), value: "Epiphany".into() })
|
||||
);
|
||||
|
||||
// An escaped repo is still bound by the key's segment rules.
|
||||
let mut long = stewardship;
|
||||
long.repo = OrgRepo(format!("GameCult/{}", "a".repeat(60)));
|
||||
assert!(
|
||||
matches!(
|
||||
pipeline_key(&PipelineDocument::Stewardship(long)),
|
||||
Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "stewardship.key"
|
||||
),
|
||||
"a repo segment longer than a key segment is refused"
|
||||
);
|
||||
}
|
||||
|
||||
/// A hand-off is recorded in both minds, so both instances are in the key:
|
||||
/// the sender owns the first segment and the receiver the local. Changing
|
||||
/// either one moves the document.
|
||||
#[test]
|
||||
fn hand_off_names_both_instances() {
|
||||
let key = |hand_off: PipelineHandOff| pipeline_key(&PipelineDocument::HandOff(hand_off));
|
||||
let base = key(hand_off_sample()).expect("the sample keys");
|
||||
assert_eq!(base, format!("{INSTANCE}:hand_off:thought-cage.GameCult_Epiphany.2026-09-15"));
|
||||
|
||||
let mut other_sender = hand_off_sample();
|
||||
other_sender.from_instance = slug("thought-cage");
|
||||
assert_ne!(key(other_sender), Ok(base.clone()), "the sender is in the key");
|
||||
|
||||
let mut other_receiver = hand_off_sample();
|
||||
other_receiver.to_instance = slug("mimir");
|
||||
assert_ne!(key(other_receiver), Ok(base.clone()), "the receiver is in the key");
|
||||
|
||||
// Two hand-offs of the same repo between the same pair on different
|
||||
// days are different documents.
|
||||
let mut later = hand_off_sample();
|
||||
later.handed_on = Date("2026-09-16".into());
|
||||
assert_ne!(key(later), Ok(base), "the date is in the key");
|
||||
|
||||
// Both instance slugs are validated as key segments, not merely bounded.
|
||||
let mut bad_receiver = hand_off_sample();
|
||||
bad_receiver.to_instance = Slug("thought cage".into());
|
||||
assert_eq!(
|
||||
key(bad_receiver),
|
||||
Err(PipelineRefusal::InvalidFormat {
|
||||
field: "hand_off.to_instance".into(),
|
||||
value: "thought cage".into(),
|
||||
})
|
||||
);
|
||||
let mut bad_date = hand_off_sample();
|
||||
bad_date.handed_on = Date("2026-9-15".into());
|
||||
assert!(
|
||||
matches!(key(bad_date), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "hand_off.handed_on"),
|
||||
"a malformed date does not compose a key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_published_schemas_match_derivation() -> Result<()> {
|
||||
let published = Path::new(env!("CARGO_MANIFEST_DIR")).join("../schemas/cultnet");
|
||||
|
|
@ -150,7 +150,10 @@
|
|||
"Verdict",
|
||||
"Finding",
|
||||
"FollowUp",
|
||||
"Resolution"
|
||||
"Resolution",
|
||||
"Instance",
|
||||
"Stewardship",
|
||||
"HandOff"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@
|
|||
"Verdict",
|
||||
"Finding",
|
||||
"FollowUp",
|
||||
"Resolution"
|
||||
"Resolution",
|
||||
"Instance",
|
||||
"Stewardship",
|
||||
"HandOff"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
66
schemas/cultnet/epiphany.pipeline.hand_off.v1.schema.json
Normal file
66
schemas/cultnet/epiphany.pipeline.hand_off.v1.schema.json
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"$defs": {
|
||||
"Date": {
|
||||
"description": "A calendar date, `YYYY-MM-DD`.",
|
||||
"maxLength": 10,
|
||||
"type": "string"
|
||||
},
|
||||
"OrgRepo": {
|
||||
"description": "A GitHub repository as `Org/Repo`.",
|
||||
"maxLength": 200,
|
||||
"type": "string"
|
||||
},
|
||||
"Para": {
|
||||
"description": "Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`.",
|
||||
"maxLength": 4000,
|
||||
"type": "string"
|
||||
},
|
||||
"Short": {
|
||||
"description": "Text of at most 200 UTF-8 bytes.",
|
||||
"maxLength": 200,
|
||||
"type": "string"
|
||||
},
|
||||
"Slug": {
|
||||
"description": "A campaign slug or key segment: label parts joined by `.`.",
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.hand_off.v1.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "A reassignment of stewardship, recorded in both minds. `documents` names\nwhat travels with it.",
|
||||
"properties": {
|
||||
"documents": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"maxItems": 256,
|
||||
"type": "array"
|
||||
},
|
||||
"from_instance": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"handed_on": {
|
||||
"$ref": "#/$defs/Date"
|
||||
},
|
||||
"reason": {
|
||||
"$ref": "#/$defs/Para"
|
||||
},
|
||||
"repo": {
|
||||
"$ref": "#/$defs/OrgRepo"
|
||||
},
|
||||
"to_instance": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"from_instance",
|
||||
"to_instance",
|
||||
"repo",
|
||||
"documents",
|
||||
"reason",
|
||||
"handed_on"
|
||||
],
|
||||
"title": "PipelineHandOff",
|
||||
"type": "object"
|
||||
}
|
||||
44
schemas/cultnet/epiphany.pipeline.instance.v1.schema.json
Normal file
44
schemas/cultnet/epiphany.pipeline.instance.v1.schema.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"$defs": {
|
||||
"Date": {
|
||||
"description": "A calendar date, `YYYY-MM-DD`.",
|
||||
"maxLength": 10,
|
||||
"type": "string"
|
||||
},
|
||||
"Short": {
|
||||
"description": "Text of at most 200 UTF-8 bytes.",
|
||||
"maxLength": 200,
|
||||
"type": "string"
|
||||
},
|
||||
"Slug": {
|
||||
"description": "A campaign slug or key segment: label parts joined by `.`.",
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.instance.v1.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "A mind's identity document. A store is canonical to exactly one\ninstance, and this says which; identity lives in the state, not in a\npath.",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"$ref": "#/$defs/Date"
|
||||
},
|
||||
"display_name": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"host": {
|
||||
"$ref": "#/$defs/Short"
|
||||
},
|
||||
"instance": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"instance",
|
||||
"display_name",
|
||||
"created_at",
|
||||
"host"
|
||||
],
|
||||
"title": "PipelineInstance",
|
||||
"type": "object"
|
||||
}
|
||||
|
|
@ -31,7 +31,10 @@
|
|||
"Verdict",
|
||||
"Finding",
|
||||
"FollowUp",
|
||||
"Resolution"
|
||||
"Resolution",
|
||||
"Instance",
|
||||
"Stewardship",
|
||||
"HandOff"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@
|
|||
"Verdict",
|
||||
"Finding",
|
||||
"FollowUp",
|
||||
"Resolution"
|
||||
"Resolution",
|
||||
"Instance",
|
||||
"Stewardship",
|
||||
"HandOff"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@
|
|||
"Verdict",
|
||||
"Finding",
|
||||
"FollowUp",
|
||||
"Resolution"
|
||||
"Resolution",
|
||||
"Instance",
|
||||
"Stewardship",
|
||||
"HandOff"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
49
schemas/cultnet/epiphany.pipeline.stewardship.v1.schema.json
Normal file
49
schemas/cultnet/epiphany.pipeline.stewardship.v1.schema.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"$defs": {
|
||||
"Date": {
|
||||
"description": "A calendar date, `YYYY-MM-DD`.",
|
||||
"maxLength": 10,
|
||||
"type": "string"
|
||||
},
|
||||
"Line": {
|
||||
"description": "Text of at most 1,000 UTF-8 bytes.",
|
||||
"maxLength": 1000,
|
||||
"type": "string"
|
||||
},
|
||||
"OrgRepo": {
|
||||
"description": "A GitHub repository as `Org/Repo`.",
|
||||
"maxLength": 200,
|
||||
"type": "string"
|
||||
},
|
||||
"Slug": {
|
||||
"description": "A campaign slug or key segment: label parts joined by `.`.",
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.stewardship.v1.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "Stewardship over a repo, as an assignment recorded in a mind. One\ninstance may steward several repos, so the repo is part of the key.",
|
||||
"properties": {
|
||||
"assigned_on": {
|
||||
"$ref": "#/$defs/Date"
|
||||
},
|
||||
"instance": {
|
||||
"$ref": "#/$defs/Slug"
|
||||
},
|
||||
"note": {
|
||||
"$ref": "#/$defs/Line"
|
||||
},
|
||||
"repo": {
|
||||
"$ref": "#/$defs/OrgRepo"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"instance",
|
||||
"repo",
|
||||
"assigned_on",
|
||||
"note"
|
||||
],
|
||||
"title": "PipelineStewardship",
|
||||
"type": "object"
|
||||
}
|
||||
|
|
@ -132,6 +132,39 @@
|
|||
"documentType": "epiphany.pipeline.resolution.v1",
|
||||
"title": "Epiphany Pipeline Resolution v1",
|
||||
"path": "epiphany.pipeline.resolution.v1.schema.json"
|
||||
},
|
||||
{
|
||||
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.instance.v1.schema.json",
|
||||
"kind": "document_payload",
|
||||
"wireContracts": [
|
||||
"cultnet.schema.v0"
|
||||
],
|
||||
"schemaVersion": "epiphany.pipeline.instance.v1",
|
||||
"documentType": "epiphany.pipeline.instance.v1",
|
||||
"title": "Epiphany Pipeline Instance v1",
|
||||
"path": "epiphany.pipeline.instance.v1.schema.json"
|
||||
},
|
||||
{
|
||||
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.stewardship.v1.schema.json",
|
||||
"kind": "document_payload",
|
||||
"wireContracts": [
|
||||
"cultnet.schema.v0"
|
||||
],
|
||||
"schemaVersion": "epiphany.pipeline.stewardship.v1",
|
||||
"documentType": "epiphany.pipeline.stewardship.v1",
|
||||
"title": "Epiphany Pipeline Stewardship v1",
|
||||
"path": "epiphany.pipeline.stewardship.v1.schema.json"
|
||||
},
|
||||
{
|
||||
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.hand_off.v1.schema.json",
|
||||
"kind": "document_payload",
|
||||
"wireContracts": [
|
||||
"cultnet.schema.v0"
|
||||
],
|
||||
"schemaVersion": "epiphany.pipeline.hand_off.v1",
|
||||
"documentType": "epiphany.pipeline.hand_off.v1",
|
||||
"title": "Epiphany Pipeline HandOff v1",
|
||||
"path": "epiphany.pipeline.hand_off.v1.schema.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue