diff --git a/Cargo.lock b/Cargo.lock index 41e31e08..d1a39575 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index df1b6ac2..2d59e523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "epiphany-core", "epiphany-model-adapter", "epiphany-openai-runtime", + "epiphany-pipeline", "epiphany-tool-adapter", "epiphany-tool-mcp-runtime", ] diff --git a/epiphany-core/Cargo.toml b/epiphany-core/Cargo.toml index b64f2168..f9d7b41a 100644 --- a/epiphany-core/Cargo.toml +++ b/epiphany-core/Cargo.toml @@ -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"] } diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index 25578357..30dac8b8 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -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, diff --git a/epiphany-core/src/runtime_spine.rs b/epiphany-core/src/runtime_spine.rs index bba56585..af9e626a 100644 --- a/epiphany-core/src/runtime_spine.rs +++ b/epiphany-core/src/runtime_spine.rs @@ -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::()?; + 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: {}", diff --git a/epiphany-pipeline/Cargo.toml b/epiphany-pipeline/Cargo.toml new file mode 100644 index 00000000..d1a34126 --- /dev/null +++ b/epiphany-pipeline/Cargo.toml @@ -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" diff --git a/epiphany-core/src/pipeline_documents.rs b/epiphany-pipeline/src/lib.rs similarity index 79% rename from epiphany-core/src/pipeline_documents.rs rename to epiphany-pipeline/src/lib.rs index 12d7f973..d92b9cb3 100644 --- a/epiphany-core/src/pipeline_documents.rs +++ b/epiphany-pipeline/src/lib.rs @@ -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[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::()?; + 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. +#[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. `::` 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 { + 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 { 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 + // `::` 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 = ::TYPE; + let foreign = ::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"); diff --git a/schemas/cultnet/epiphany.pipeline.finding.v1.schema.json b/schemas/cultnet/epiphany.pipeline.finding.v1.schema.json index d9323930..0b500b4d 100644 --- a/schemas/cultnet/epiphany.pipeline.finding.v1.schema.json +++ b/schemas/cultnet/epiphany.pipeline.finding.v1.schema.json @@ -150,7 +150,10 @@ "Verdict", "Finding", "FollowUp", - "Resolution" + "Resolution", + "Instance", + "Stewardship", + "HandOff" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.follow_up.v1.schema.json b/schemas/cultnet/epiphany.pipeline.follow_up.v1.schema.json index 559999e0..9f512afe 100644 --- a/schemas/cultnet/epiphany.pipeline.follow_up.v1.schema.json +++ b/schemas/cultnet/epiphany.pipeline.follow_up.v1.schema.json @@ -51,7 +51,10 @@ "Verdict", "Finding", "FollowUp", - "Resolution" + "Resolution", + "Instance", + "Stewardship", + "HandOff" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.hand_off.v1.schema.json b/schemas/cultnet/epiphany.pipeline.hand_off.v1.schema.json new file mode 100644 index 00000000..cfb19618 --- /dev/null +++ b/schemas/cultnet/epiphany.pipeline.hand_off.v1.schema.json @@ -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" +} diff --git a/schemas/cultnet/epiphany.pipeline.instance.v1.schema.json b/schemas/cultnet/epiphany.pipeline.instance.v1.schema.json new file mode 100644 index 00000000..1bd482a2 --- /dev/null +++ b/schemas/cultnet/epiphany.pipeline.instance.v1.schema.json @@ -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" +} diff --git a/schemas/cultnet/epiphany.pipeline.question.v1.schema.json b/schemas/cultnet/epiphany.pipeline.question.v1.schema.json index 243399a8..388530de 100644 --- a/schemas/cultnet/epiphany.pipeline.question.v1.schema.json +++ b/schemas/cultnet/epiphany.pipeline.question.v1.schema.json @@ -31,7 +31,10 @@ "Verdict", "Finding", "FollowUp", - "Resolution" + "Resolution", + "Instance", + "Stewardship", + "HandOff" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.resolution.v1.schema.json b/schemas/cultnet/epiphany.pipeline.resolution.v1.schema.json index 09ffb153..76c46d01 100644 --- a/schemas/cultnet/epiphany.pipeline.resolution.v1.schema.json +++ b/schemas/cultnet/epiphany.pipeline.resolution.v1.schema.json @@ -26,7 +26,10 @@ "Verdict", "Finding", "FollowUp", - "Resolution" + "Resolution", + "Instance", + "Stewardship", + "HandOff" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.ruling.v1.schema.json b/schemas/cultnet/epiphany.pipeline.ruling.v1.schema.json index 2765786f..4600ae09 100644 --- a/schemas/cultnet/epiphany.pipeline.ruling.v1.schema.json +++ b/schemas/cultnet/epiphany.pipeline.ruling.v1.schema.json @@ -64,7 +64,10 @@ "Verdict", "Finding", "FollowUp", - "Resolution" + "Resolution", + "Instance", + "Stewardship", + "HandOff" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.stewardship.v1.schema.json b/schemas/cultnet/epiphany.pipeline.stewardship.v1.schema.json new file mode 100644 index 00000000..041da5f4 --- /dev/null +++ b/schemas/cultnet/epiphany.pipeline.stewardship.v1.schema.json @@ -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" +} diff --git a/schemas/cultnet/index.json b/schemas/cultnet/index.json index 0f137032..1741ce16 100644 --- a/schemas/cultnet/index.json +++ b/schemas/cultnet/index.json @@ -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" } ] }