Cut RS-L: the leaf gains Title, moves to epoch v2, and two grammar doors

Q-RS1 ruled B: a new bounded text type, Title (1-200 bytes, never empty),
replaces Short on all four leaf titles -- campaign, cut spec, and the two
new fields on question and ruling. The epoch moves anyway in this cut, so
tightening the two existing titles now avoids a second epoch bump later
just to make a title required.

PIPELINE_SCHEMA_EPOCH and all thirteen type ids move to .v2. The thirteen
schema files are renamed and regenerated, and index.json/README.md follow.

Two public doors join Slug::validate_slug on the same pattern:
OrgRepo::validate_org_repo and Label::validate_label, each delegating to
the grammar its own document field is already held to. RS-3 will read the
organ's repo and cut aliases through these.

Tests: the_org_repo_and_label_doors_are_the_grammar and
question_and_ruling_carry_a_title (37 total, up from 35). Mutation suite
tools/eureka-readside-leaf-mutations.psd1 (L1/L1L, L2/L2L, L3/L3L) verified
killed against M0.

No other consumer in this workspace depends on epiphany-pipeline as a
Cargo dependency; epiphany-core's runtime_spine stand-in hardcodes a
pipeline-shaped type id deliberately (RS-L's negative grep excludes it) and
is unaffected since its test only checks the "epiphany.pipeline." prefix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 12:37:13 +02:00
commit d74e36aa06
17 changed files with 404 additions and 108 deletions

View file

@ -190,9 +190,22 @@ fn within(limit: usize) -> impl Fn(&str, &str) -> Result<(), PipelineRefusal> {
move |field: &str, value: &str| bound(field, limit, value.len())
}
fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
if value.is_empty() {
return Err(format_error(field, value));
}
within(200)(field, value)
}
bounded_text! {
/// Text of at most 200 UTF-8 bytes.
Short = 200, |field, value| within(200)(field, value);
/// A title: 1 to 200 UTF-8 bytes, never empty. All four leaf titles
/// (campaign, cut spec, question and ruling) carry this type, ruled B
/// under Q-RS1: the epoch moves in this cut regardless, so tightening the
/// two titles that were `Short` costs nothing extra and avoids a second
/// epoch bump later just to make a title required.
Title = 200, title_text;
/// Text of at most 1,000 UTF-8 bytes.
Line = 1000, |field, value| within(1000)(field, value);
/// Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`.
@ -226,6 +239,30 @@ impl Slug {
}
}
impl OrgRepo {
/// The public door onto the `org_repo` grammar, for a caller outside this
/// crate that holds a bare `OrgRepo` (the organ's `repo` alias, RS-3) and
/// no way to reach `Bounded`. On the pattern of `Slug::validate_slug`: it
/// delegates to `org_repo_text` through `Bounded::validate`, so the
/// grammar keeps one owner and a declared name checked this way refuses
/// exactly what a document's own `OrgRepo` field refuses.
pub fn validate_org_repo(&self) -> Result<(), PipelineRefusal> {
Bounded::validate(self, "org_repo")
}
}
impl Label {
/// The public door onto the label grammar, for a caller outside this
/// crate that holds a bare `Label` (the organ's `cut` alias, RS-3) and no
/// way to reach `Bounded`. On the pattern of `Slug::validate_slug`: it
/// delegates to `label_text` through `Bounded::validate`, so the grammar
/// keeps one owner and a declared name checked this way refuses exactly
/// what a document's own `Label` field refuses.
pub fn validate_label(&self) -> Result<(), PipelineRefusal> {
Bounded::validate(self, "label")
}
}
/// A calendar date, `YYYY-MM-DD`.
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
@ -341,21 +378,21 @@ value_types! {
promise: Option<Label>, mutations: Vec<Label>[8],
}
pub struct PipelineCampaign { slug: Slug, title: Short, repos: Vec<OrgRepo>[8], working_branch: Short, target_doc: DocRef }
pub struct PipelineCampaign { slug: Slug, title: Title, repos: Vec<OrgRepo>[8], working_branch: Short, target_doc: DocRef }
pub struct PipelineTarget {
campaign: Slug, revision: u32, invariants: Vec<TargetInvariant>[32], not_in_scope: Vec<Line>[32],
canonical_implementations: Vec<Line>[16], doc: DocRef,
}
pub struct PipelineQuestion {
campaign: Slug, label: Label, question: Para, options: Vec<QuestionOption>[8], recommended: Label,
campaign: Slug, label: Label, title: Title, question: Para, options: Vec<QuestionOption>[8], recommended: Label,
depends: Vec<Line>[8], raised_in: Option<PipelineRef>, asked_on: Date,
}
pub struct PipelineRuling {
campaign: Slug, label: Label, answers: Option<Short>, choice: Option<Label>, ruling: Para,
campaign: Slug, label: Label, title: Title, answers: Option<Short>, choice: Option<Label>, ruling: Para,
operator_quote: Option<Para>, ruled_on: Date, precedents: Vec<ForeignRef>[8], authority: RulingAuthority,
}
pub struct PipelineCutSpec {
campaign: Slug, cut: Label, revision: u32, title: Short, repo: OrgRepo, branch: Short, base: Sha,
campaign: Slug, cut: Label, revision: u32, title: Title, repo: OrgRepo, branch: Short, base: Sha,
depends_on: Vec<Short>[8], first: Vec<Line>[16], deletes: Vec<CutDelete>[64], keeps_moves: Vec<Line>[64],
adds: Vec<Line>[64], file_changes: Vec<FileChange>[256], authority_map: Option<AuthorityMap>,
verification: CutVerification, estimate: StructuralDelta,
@ -595,31 +632,31 @@ pub(crate) struct ForeignDocument {
pipeline_kinds! {
Campaign(PipelineCampaign) => EpiphanyPipelineCampaignDocument, "campaign",
"epiphany.pipeline.campaign.v1", "EpiphanyPipelineCampaignDocument";
"epiphany.pipeline.campaign.v2", "EpiphanyPipelineCampaignDocument";
Target(PipelineTarget) => EpiphanyPipelineTargetDocument, "target",
"epiphany.pipeline.target.v1", "EpiphanyPipelineTargetDocument";
"epiphany.pipeline.target.v2", "EpiphanyPipelineTargetDocument";
Question(PipelineQuestion) => EpiphanyPipelineQuestionDocument, "question",
"epiphany.pipeline.question.v1", "EpiphanyPipelineQuestionDocument";
"epiphany.pipeline.question.v2", "EpiphanyPipelineQuestionDocument";
Ruling(PipelineRuling) => EpiphanyPipelineRulingDocument, "ruling",
"epiphany.pipeline.ruling.v1", "EpiphanyPipelineRulingDocument";
"epiphany.pipeline.ruling.v2", "EpiphanyPipelineRulingDocument";
CutSpec(PipelineCutSpec) => EpiphanyPipelineCutSpecDocument, "cut_spec",
"epiphany.pipeline.cut_spec.v1", "EpiphanyPipelineCutSpecDocument";
"epiphany.pipeline.cut_spec.v2", "EpiphanyPipelineCutSpecDocument";
CutReport(PipelineCutReport) => EpiphanyPipelineCutReportDocument, "cut_report",
"epiphany.pipeline.cut_report.v1", "EpiphanyPipelineCutReportDocument";
"epiphany.pipeline.cut_report.v2", "EpiphanyPipelineCutReportDocument";
Verdict(PipelineVerdict) => EpiphanyPipelineVerdictDocument, "verdict",
"epiphany.pipeline.verdict.v1", "EpiphanyPipelineVerdictDocument";
"epiphany.pipeline.verdict.v2", "EpiphanyPipelineVerdictDocument";
Finding(PipelineFinding) => EpiphanyPipelineFindingDocument, "finding",
"epiphany.pipeline.finding.v1", "EpiphanyPipelineFindingDocument";
"epiphany.pipeline.finding.v2", "EpiphanyPipelineFindingDocument";
FollowUp(PipelineFollowUp) => EpiphanyPipelineFollowUpDocument, "follow_up",
"epiphany.pipeline.follow_up.v1", "EpiphanyPipelineFollowUpDocument";
"epiphany.pipeline.follow_up.v2", "EpiphanyPipelineFollowUpDocument";
Resolution(PipelineResolution) => EpiphanyPipelineResolutionDocument, "resolution",
"epiphany.pipeline.resolution.v1", "EpiphanyPipelineResolutionDocument";
"epiphany.pipeline.resolution.v2", "EpiphanyPipelineResolutionDocument";
Instance(PipelineInstance) => EpiphanyPipelineInstanceDocument, "instance",
"epiphany.pipeline.instance.v1", "EpiphanyPipelineInstanceDocument";
"epiphany.pipeline.instance.v2", "EpiphanyPipelineInstanceDocument";
Stewardship(PipelineStewardship) => EpiphanyPipelineStewardshipDocument, "stewardship",
"epiphany.pipeline.stewardship.v1", "EpiphanyPipelineStewardshipDocument";
"epiphany.pipeline.stewardship.v2", "EpiphanyPipelineStewardshipDocument";
HandOff(PipelineHandOff) => EpiphanyPipelineHandOffDocument, "hand_off",
"epiphany.pipeline.hand_off.v1", "EpiphanyPipelineHandOffDocument";
"epiphany.pipeline.hand_off.v2", "EpiphanyPipelineHandOffDocument";
}
/// Parses a full document id: the grammar read backwards. A key and an id are
@ -717,7 +754,7 @@ const LOCAL_MAX: usize = 64;
/// the epoch. A breaking change bumps it, so that a store written at the old
/// one can be refused by whoever opens it; this crate owns no store and
/// refuses none.
pub const PIPELINE_SCHEMA_EPOCH: &str = "epiphany.pipeline.epoch.v1";
pub const PIPELINE_SCHEMA_EPOCH: &str = "epiphany.pipeline.epoch.v2";
/// 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
@ -848,6 +885,10 @@ mod tests {
value.into()
}
fn t(value: &str) -> Title {
value.into()
}
fn l(value: &str) -> Label {
value.into()
}
@ -891,7 +932,7 @@ mod tests {
let branch = || s("codex/eureka-pipeline-state");
vec![
(D::Campaign(PipelineCampaign {
slug: slug(CAMPAIGN), title: s("Eureka pipeline state"), repos: vec![repo()],
slug: slug(CAMPAIGN), title: t("Eureka pipeline state"), repos: vec![repo()],
working_branch: branch(), target_doc: doc_ref(),
}), format!("{CAMPAIGN}:campaign:self")),
(D::Target(PipelineTarget {
@ -900,7 +941,7 @@ mod tests {
not_in_scope: vec!["Eve browsing".into()], canonical_implementations: vec!["CultLib".into()], doc: doc_ref(),
}), format!("{CAMPAIGN}:target:r2")),
(D::Question(PipelineQuestion {
campaign: slug(CAMPAIGN), label: l("Q1"), question: "Who owns the state?".into(),
campaign: slug(CAMPAIGN), label: l("Q1"), title: t("Who owns the state?"), question: "Who owns the state?".into(),
options: vec![
QuestionOption { label: l("A"), text: "an instance".into() },
QuestionOption { label: l("B"), text: "a repo".into() },
@ -910,7 +951,7 @@ mod tests {
asked_on: date(),
}), format!("{CAMPAIGN}:question:Q1")),
(D::Ruling(PipelineRuling {
campaign: slug(CAMPAIGN), label: l("R8"), answers: Some(id("question", "Q1")), choice: Some(l("A")),
campaign: slug(CAMPAIGN), label: l("R8"), title: t("An instance owns its mind"), answers: Some(id("question", "Q1")), choice: Some(l("A")),
ruling: "An instance owns its mind.".into(), operator_quote: Some("all recommendations, go ahead".into()),
ruled_on: date(),
precedents: vec![ForeignRef {
@ -920,7 +961,7 @@ mod tests {
authority: RulingAuthority::Operator,
}), format!("{CAMPAIGN}:ruling:R8")),
(D::CutSpec(PipelineCutSpec {
campaign: slug(CAMPAIGN), cut: l("3a"), revision: 1, title: s("Pipeline documents"), repo: repo(),
campaign: slug(CAMPAIGN), cut: l("3a"), revision: 1, title: t("Pipeline documents"), repo: repo(),
branch: branch(), base: sha(), depends_on: vec![s("2")], first: vec!["Read the spec.".into()],
deletes: vec![CutDelete { path: s("old.rs"), lines: 3, note: "dead".into() }],
keeps_moves: vec!["commit owner".into()], adds: vec!["pipeline_documents.rs".into()],
@ -1033,6 +1074,16 @@ mod tests {
samples().remove(0).0
}
fn question_sample() -> PipelineQuestion {
let PipelineDocument::Question(question) = samples().remove(2).0 else { unreachable!() };
question
}
fn ruling_sample() -> PipelineRuling {
let PipelineDocument::Ruling(ruling) = samples().remove(3).0 else { unreachable!() };
ruling
}
fn report_sample() -> PipelineCutReport {
let PipelineDocument::CutReport(report) = samples().remove(5).0 else { unreachable!() };
report
@ -1107,14 +1158,14 @@ mod tests {
#[test]
fn bounds_refuse_in_utf8_bytes() {
let PipelineDocument::Campaign(mut campaign) = campaign_sample() else { unreachable!() };
campaign.title = Short("é".repeat(100));
campaign.title = Title("é".repeat(100));
assert_eq!(PipelineDocument::Campaign(campaign.clone()).validate(), Ok(()));
campaign.title = Short(format!("{}a", "é".repeat(100)));
campaign.title = Title(format!("{}a", "é".repeat(100)));
assert_eq!(
PipelineDocument::Campaign(campaign.clone()).validate(),
Err(PipelineRefusal::FieldBound { field: "campaign.title".into(), limit: 200, actual: 201 })
);
campaign.title = s("ok");
campaign.title = t("ok");
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()); 9];
assert_eq!(
PipelineDocument::Campaign(campaign).validate(),
@ -2360,7 +2411,7 @@ mod tests {
"wireContracts": ["cultnet.schema.v0"],
"schemaVersion": kind.type_id(),
"documentType": kind.type_id(),
"title": format!("Epiphany Pipeline {kind:?} v1"),
"title": format!("Epiphany Pipeline {kind:?} v2"),
"path": file,
});
assert!(
@ -2517,4 +2568,84 @@ mod tests {
// non-alphanumeric character that happens to be caught by coincidence.
refused("a\u{0}b", "NUL is not a label byte");
}
/// The two doors added beside `Slug::validate_slug` (RS-L), on its
/// pattern: each delegates to the grammar its own document field already
/// carries, so a declared name checked through the door and a field of
/// the same type refuse the same inputs. `OrgRepo` is the `repo` alias's
/// domain and `Label` is the `cut` alias's (RS-3's vocabulary table).
#[test]
fn the_org_repo_and_label_doors_are_the_grammar() {
assert_eq!(OrgRepo::from("GameCult/Epiphany").validate_org_repo(), Ok(()));
let repo_refused = |value: &str, why: &str| {
let result = OrgRepo::from(value).validate_org_repo();
assert!(
matches!(&result, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "org_repo"),
"{why}: {value:?} was not refused, got {result:?}"
);
};
repo_refused("GameCult", "no slash at all");
repo_refused("/Repo", "an empty org before the slash");
repo_refused("GameCult/", "an empty repo after the slash");
repo_refused("a/b/c", "a second slash makes the repo half ambiguous");
let org_at_201 = format!("{}/{}", "a".repeat(99), "a".repeat(101));
assert_eq!(org_at_201.len(), 201, "one byte past the 200-byte org_repo bound");
repo_refused(&org_at_201, "201 bytes overall is refused");
assert_eq!(Label::from("cut-10").validate_label(), Ok(()));
let label_refused = |value: &str, why: &str| {
let result = Label::from(value).validate_label();
assert!(
matches!(&result, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "label"),
"{why}: {value:?} was not refused, got {result:?}"
);
};
label_refused("", "the empty string has no label");
label_refused("a.b", "a dot is not a label byte");
label_refused(&"a".repeat(65), "65 bytes is past the 64-byte label bound");
label_refused("\u{e9}", "a non-ascii byte is not a label byte");
}
/// Q-RS1, ruled B: `question` and `ruling` gain a required `title`, the
/// new `Title` type shared with the two titles that move off `Short`
/// (campaign, cut spec). Each kind round-trips through `prepare`/`decode`
/// with its title intact, an empty title is refused for both, and the
/// bound is pinned at its edge: 200 bytes accepted, 201 refused (L3 dies
/// on the removal of the non-empty check, not on the length bound, since
/// `Title`'s length bound is shared with every other `bounded_text!`
/// member and already covered by `bounds_refuse_in_utf8_bytes`).
#[test]
fn question_and_ruling_carry_a_title() -> Result<()> {
let cache = schema_cache()?;
let mut question = question_sample();
question.title = t("Who owns the state?");
let envelope = PipelineDocument::Question(question.clone()).prepare(&cache)?;
assert_eq!(PipelineDocument::decode(&envelope)?, PipelineDocument::Question(question.clone()));
let mut ruling = ruling_sample();
ruling.title = t("An instance owns its mind");
let envelope = PipelineDocument::Ruling(ruling.clone()).prepare(&cache)?;
assert_eq!(PipelineDocument::decode(&envelope)?, PipelineDocument::Ruling(ruling.clone()));
question.title = Title(String::new());
assert_eq!(
PipelineDocument::Question(question.clone()).validate(),
Err(PipelineRefusal::InvalidFormat { field: "question.title".into(), value: String::new() })
);
ruling.title = Title(String::new());
assert_eq!(
PipelineDocument::Ruling(ruling.clone()).validate(),
Err(PipelineRefusal::InvalidFormat { field: "ruling.title".into(), value: String::new() })
);
question.title = Title("a".repeat(200));
assert_eq!(PipelineDocument::Question(question.clone()).validate(), Ok(()));
question.title = Title("a".repeat(201));
assert_eq!(
PipelineDocument::Question(question).validate(),
Err(PipelineRefusal::FieldBound { field: "question.title".into(), limit: 200, actual: 201 })
);
Ok(())
}
}

View file

@ -25,7 +25,7 @@ hand-maintained JSON registry.
- `gamecult.persona_state.v0`: portable public Persona state.
- `epiphany.work_organ_state.v0`: portable state for lean work organs.
- `epiphany.pipeline.*.v1`: Eureka pipeline state (campaign, target, question,
- `epiphany.pipeline.*.v2`: Eureka pipeline state (campaign, target, question,
ruling, cut spec, cut report, verdict, finding, follow-up, resolution, plus
instance, stewardship and hand-off), stored in an instance's mind and owned by
the Huginn memory organ. A store is canonical to exactly one instance, so the
@ -38,7 +38,7 @@ contracts cross to the Huginn organ and its `eureka-state` client. The files are
derived from the Rust value types and checked byte-for-byte by
`epiphany-pipeline`'s `pipeline_published_schemas_match_derivation`; text bounds are UTF-8 bytes and
enforced by admission, not by the schema. Evolution is additive: a new named
field with a serde default keeps `epiphany.pipeline.epoch.v1`, and so does
field with a serde default keeps `epiphany.pipeline.epoch.v2`, and so does
widening an enum such as `PipelineKind`, because these readers are ours and each
ships with the variants it knows -- one that meets a kind it has never heard of
refuses it on the kind, not on the epoch. A breaking change bumps the epoch, and

View file

@ -46,9 +46,14 @@
"description": "A campaign slug or key segment: label parts joined by `.`.",
"maxLength": 64,
"type": "string"
},
"Title": {
"description": "A title: 1 to 200 UTF-8 bytes, never empty. All four leaf titles\n(campaign, cut spec, question and ruling) carry this type, ruled B\nunder Q-RS1: the epoch moves in this cut regardless, so tightening the\ntwo titles that were `Short` costs nothing extra and avoids a second\nepoch bump later just to make a title required.",
"maxLength": 200,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.campaign.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.campaign.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"repos": {
@ -65,7 +70,7 @@
"$ref": "#/$defs/DocRef"
},
"title": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/Title"
},
"working_branch": {
"$ref": "#/$defs/Short"

View file

@ -270,7 +270,7 @@
"type": "object"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_report.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_report.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"attempt": {

View file

@ -267,6 +267,11 @@
],
"type": "object"
},
"Title": {
"description": "A title: 1 to 200 UTF-8 bytes, never empty. All four leaf titles\n(campaign, cut spec, question and ruling) carry this type, ruled B\nunder Q-RS1: the epoch moves in this cut regardless, so tightening the\ntwo titles that were `Short` costs nothing extra and avoids a second\nepoch bump later just to make a title required.",
"maxLength": 200,
"type": "string"
},
"VerificationTest": {
"properties": {
"name": {
@ -283,7 +288,7 @@
"type": "object"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_spec.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_spec.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"adds": {
@ -376,7 +381,7 @@
"type": "array"
},
"title": {
"$ref": "#/$defs/Short"
"$ref": "#/$defs/Title"
},
"verification": {
"$ref": "#/$defs/CutVerification"

View file

@ -185,7 +185,7 @@
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.finding.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.finding.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"campaign": {

View file

@ -85,7 +85,7 @@
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.follow_up.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.follow_up.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"campaign": {

View file

@ -26,7 +26,7 @@
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.hand_off.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.hand_off.v2.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": {

View file

@ -16,7 +16,7 @@
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.instance.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.instance.v2.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": {

View file

@ -78,9 +78,14 @@
"description": "A campaign slug or key segment: label parts joined by `.`.",
"maxLength": 64,
"type": "string"
},
"Title": {
"description": "A title: 1 to 200 UTF-8 bytes, never empty. All four leaf titles\n(campaign, cut spec, question and ruling) carry this type, ruled B\nunder Q-RS1: the epoch moves in this cut regardless, so tightening the\ntwo titles that were `Short` costs nothing extra and avoids a second\nepoch bump later just to make a title required.",
"maxLength": 200,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.question.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.question.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"asked_on": {
@ -121,11 +126,15 @@
},
"recommended": {
"$ref": "#/$defs/Label"
},
"title": {
"$ref": "#/$defs/Title"
}
},
"required": [
"campaign",
"label",
"title",
"question",
"options",
"recommended",

View file

@ -200,7 +200,7 @@
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.resolution.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.resolution.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "The record of how a subject was closed and by what. `sequence` is per\nsubject and set by the writer, `1` for the first, and it is the last\npart of the key, so a subject's resolutions share a prefix and a\nwithdrawn one is kept under its subject rather than overwritten.\nWhether a sequence is the previous plus one, and whether an earlier\nresolution still stands, are admission's rules: this crate refuses\nneither `0` nor a gap, exactly as it refuses neither `revision: 0` nor\na `revision` with no predecessor.",
"properties": {

View file

@ -93,9 +93,14 @@
"description": "A campaign slug or key segment: label parts joined by `.`.",
"maxLength": 64,
"type": "string"
},
"Title": {
"description": "A title: 1 to 200 UTF-8 bytes, never empty. All four leaf titles\n(campaign, cut spec, question and ruling) carry this type, ruled B\nunder Q-RS1: the epoch moves in this cut regardless, so tightening the\ntwo titles that were `Short` costs nothing extra and avoids a second\nepoch bump later just to make a title required.",
"maxLength": 200,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.ruling.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.ruling.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"answers": {
@ -149,11 +154,15 @@
},
"ruling": {
"$ref": "#/$defs/Para"
},
"title": {
"$ref": "#/$defs/Title"
}
},
"required": [
"campaign",
"label",
"title",
"ruling",
"ruled_on",
"precedents",

View file

@ -21,7 +21,7 @@
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.stewardship.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.stewardship.v2.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.\n`sequence` is per `(instance, repo)`, set by the writer, `1` for the\nfirst, so a repo transferred away and back is two records under one\nprefix rather than one document overwriting the other. `assigned_on`\nis a field, not a key part. As on a resolution, whether the sequence\nis the previous plus one, and which assignment is in force, are\nadmission's rules and not this crate's.",
"properties": {

View file

@ -68,7 +68,7 @@
"type": "object"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.target.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.target.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"campaign": {

View file

@ -129,7 +129,7 @@
"type": "object"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.verdict.v1.schema.json",
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.verdict.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"campaign": {

View file

@ -24,147 +24,147 @@
"path": "epiphany.work_organ_state.v0.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.campaign.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.campaign.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.campaign.v1",
"documentType": "epiphany.pipeline.campaign.v1",
"title": "Epiphany Pipeline Campaign v1",
"path": "epiphany.pipeline.campaign.v1.schema.json"
"schemaVersion": "epiphany.pipeline.campaign.v2",
"documentType": "epiphany.pipeline.campaign.v2",
"title": "Epiphany Pipeline Campaign v2",
"path": "epiphany.pipeline.campaign.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.target.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.target.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.target.v1",
"documentType": "epiphany.pipeline.target.v1",
"title": "Epiphany Pipeline Target v1",
"path": "epiphany.pipeline.target.v1.schema.json"
"schemaVersion": "epiphany.pipeline.target.v2",
"documentType": "epiphany.pipeline.target.v2",
"title": "Epiphany Pipeline Target v2",
"path": "epiphany.pipeline.target.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.question.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.question.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.question.v1",
"documentType": "epiphany.pipeline.question.v1",
"title": "Epiphany Pipeline Question v1",
"path": "epiphany.pipeline.question.v1.schema.json"
"schemaVersion": "epiphany.pipeline.question.v2",
"documentType": "epiphany.pipeline.question.v2",
"title": "Epiphany Pipeline Question v2",
"path": "epiphany.pipeline.question.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.ruling.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.ruling.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.ruling.v1",
"documentType": "epiphany.pipeline.ruling.v1",
"title": "Epiphany Pipeline Ruling v1",
"path": "epiphany.pipeline.ruling.v1.schema.json"
"schemaVersion": "epiphany.pipeline.ruling.v2",
"documentType": "epiphany.pipeline.ruling.v2",
"title": "Epiphany Pipeline Ruling v2",
"path": "epiphany.pipeline.ruling.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_spec.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_spec.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.cut_spec.v1",
"documentType": "epiphany.pipeline.cut_spec.v1",
"title": "Epiphany Pipeline CutSpec v1",
"path": "epiphany.pipeline.cut_spec.v1.schema.json"
"schemaVersion": "epiphany.pipeline.cut_spec.v2",
"documentType": "epiphany.pipeline.cut_spec.v2",
"title": "Epiphany Pipeline CutSpec v2",
"path": "epiphany.pipeline.cut_spec.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_report.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_report.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.cut_report.v1",
"documentType": "epiphany.pipeline.cut_report.v1",
"title": "Epiphany Pipeline CutReport v1",
"path": "epiphany.pipeline.cut_report.v1.schema.json"
"schemaVersion": "epiphany.pipeline.cut_report.v2",
"documentType": "epiphany.pipeline.cut_report.v2",
"title": "Epiphany Pipeline CutReport v2",
"path": "epiphany.pipeline.cut_report.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.verdict.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.verdict.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.verdict.v1",
"documentType": "epiphany.pipeline.verdict.v1",
"title": "Epiphany Pipeline Verdict v1",
"path": "epiphany.pipeline.verdict.v1.schema.json"
"schemaVersion": "epiphany.pipeline.verdict.v2",
"documentType": "epiphany.pipeline.verdict.v2",
"title": "Epiphany Pipeline Verdict v2",
"path": "epiphany.pipeline.verdict.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.finding.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.finding.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.finding.v1",
"documentType": "epiphany.pipeline.finding.v1",
"title": "Epiphany Pipeline Finding v1",
"path": "epiphany.pipeline.finding.v1.schema.json"
"schemaVersion": "epiphany.pipeline.finding.v2",
"documentType": "epiphany.pipeline.finding.v2",
"title": "Epiphany Pipeline Finding v2",
"path": "epiphany.pipeline.finding.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.follow_up.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.follow_up.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.follow_up.v1",
"documentType": "epiphany.pipeline.follow_up.v1",
"title": "Epiphany Pipeline FollowUp v1",
"path": "epiphany.pipeline.follow_up.v1.schema.json"
"schemaVersion": "epiphany.pipeline.follow_up.v2",
"documentType": "epiphany.pipeline.follow_up.v2",
"title": "Epiphany Pipeline FollowUp v2",
"path": "epiphany.pipeline.follow_up.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.resolution.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.resolution.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.resolution.v1",
"documentType": "epiphany.pipeline.resolution.v1",
"title": "Epiphany Pipeline Resolution v1",
"path": "epiphany.pipeline.resolution.v1.schema.json"
"schemaVersion": "epiphany.pipeline.resolution.v2",
"documentType": "epiphany.pipeline.resolution.v2",
"title": "Epiphany Pipeline Resolution v2",
"path": "epiphany.pipeline.resolution.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.instance.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.instance.v2.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"
"schemaVersion": "epiphany.pipeline.instance.v2",
"documentType": "epiphany.pipeline.instance.v2",
"title": "Epiphany Pipeline Instance v2",
"path": "epiphany.pipeline.instance.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.stewardship.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.stewardship.v2.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"
"schemaVersion": "epiphany.pipeline.stewardship.v2",
"documentType": "epiphany.pipeline.stewardship.v2",
"title": "Epiphany Pipeline Stewardship v2",
"path": "epiphany.pipeline.stewardship.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.hand_off.v1.schema.json",
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.hand_off.v2.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"
"schemaVersion": "epiphany.pipeline.hand_off.v2",
"documentType": "epiphany.pipeline.hand_off.v2",
"title": "Epiphany Pipeline HandOff v2",
"path": "epiphany.pipeline.hand_off.v2.schema.json"
}
]
}

View file

@ -0,0 +1,137 @@
# Eureka read-side cut, Cut RS-L: the leaf's title, the epoch, and the two new
# grammar doors. Entries only; the harness is tools/eureka-mutations.ps1.
#
# powershell -File tools/eureka-mutations.ps1 `
# -Entries tools/eureka-readside-leaf-mutations.psd1 `
# -Target epiphany-pipeline/src/lib.rs `
# -Test 'cargo test -p epiphany-pipeline --lib'
#
# M0 is the harness's own built-in control and is not an entry here.
#
# L1 and L1L are `OrgRepo::validate_org_repo`, on `Slug::validate_slug`'s
# pattern, and both are killed by `the_org_repo_and_label_doors_are_the_grammar`:
# a door that answers Ok to everything dies on "GameCult" alone, and the
# loosened grammar that accepts any string carrying a slash dies on "a/b/c"
# and on the 201-byte fixture, both of which carry exactly one slash.
#
# L2 and L2L are `Label::validate_label`, on the same pattern, and both are
# killed by the same test: a door that answers Ok to everything dies on the
# empty string, and a door delegating to the slug grammar instead of the
# label grammar dies on "a.b", which `dotted_text` accepts as two labels
# joined by a dot and `label_text` refuses outright because a label carries
# no dot.
#
# L3 and L3L are the `Title` type's non-empty and 200-byte bounds, both
# killed by `question_and_ruling_carry_a_title`: removing the non-empty check
# dies on the empty-title case, and widening the bound to 201 bytes dies on
# the pinned 201-byte refusal.
@{
Mutations = @(
@{
Id = 'L1'
Rule = 'The public door onto the org_repo grammar is the grammar, not a formality: a door that answers Ok to every repo string leaves a caller holding a bare OrgRepo (the read side vocabulary''s repo alias, RS-3) unable to tell a malformed one from a missing document.'
Test = 'tests::the_org_repo_and_label_doors_are_the_grammar'
Old = @'
pub fn validate_org_repo(&self) -> Result<(), PipelineRefusal> {
Bounded::validate(self, "org_repo")
}
'@
New = @'
pub fn validate_org_repo(&self) -> Result<(), PipelineRefusal> {
Ok(())
}
'@
},
@{
Id = 'L1L'
Rule = 'The door delegates to the one grammar, org_repo_text whole: a loosening that accepts any string carrying a slash still depends on the input, but admits a second slash and an over-long value the real grammar refuses.'
Test = 'tests::the_org_repo_and_label_doors_are_the_grammar'
Old = @'
fn org_repo_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
match value.split_once('/') {
Some((org, repo))
if !org.is_empty() && !repo.is_empty() && !repo.contains('/') && value.len() <= 200 =>
{
Ok(())
}
_ => Err(format_error(field, value)),
}
}
'@
New = @'
fn org_repo_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
if value.contains('/') { Ok(()) } else { Err(format_error(field, value)) }
}
'@
},
@{
Id = 'L2'
Rule = 'The public door onto the label grammar is the grammar, not a formality: a door that answers Ok to every label string leaves a caller holding a bare Label (the read side vocabulary''s cut alias, RS-3) unable to tell a malformed one from a missing document.'
Test = 'tests::the_org_repo_and_label_doors_are_the_grammar'
Old = @'
pub fn validate_label(&self) -> Result<(), PipelineRefusal> {
Bounded::validate(self, "label")
}
'@
New = @'
pub fn validate_label(&self) -> Result<(), PipelineRefusal> {
Ok(())
}
'@
},
@{
Id = 'L2L'
Rule = 'The door delegates to the label grammar and not to some other one: a door wired to dotted_text instead of label_text admits a dotted name a label never allows, since a label composes no key of its own and carries no dot.'
Test = 'tests::the_org_repo_and_label_doors_are_the_grammar'
Old = @'
pub fn validate_label(&self) -> Result<(), PipelineRefusal> {
Bounded::validate(self, "label")
}
'@
New = @'
pub fn validate_label(&self) -> Result<(), PipelineRefusal> {
dotted_text("label", &self.0)
}
'@
},
@{
Id = 'L3'
Rule = 'Q-RS1 (ruled B): a title is never empty. Removing the non-empty check leaves only the 200-byte bound, so a bare leaf with an empty campaign, cut spec, question or ruling title would validate.'
Test = 'tests::question_and_ruling_carry_a_title'
Old = @'
fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
if value.is_empty() {
return Err(format_error(field, value));
}
within(200)(field, value)
}
'@
New = @'
fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
within(200)(field, value)
}
'@
},
@{
Id = 'L3L'
Rule = 'The title bound is exactly 200 bytes, not 201: a loosening that still depends on the input''s length dies on the pinned 201-byte case, which the real bound refuses and the widened one would accept.'
Test = 'tests::question_and_ruling_carry_a_title'
Old = @'
fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
if value.is_empty() {
return Err(format_error(field, value));
}
within(200)(field, value)
}
'@
New = @'
fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
if value.is_empty() {
return Err(format_error(field, value));
}
within(201)(field, value)
}
'@
}
)
}