From b4f88d298085d25abcbf6bf58f74f7b57d3cdc10 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 15 Sep 2026 22:55:00 +0200 Subject: [PATCH] Close Soul's Cut 3a findings and land rulings 10-12 One store per clone, numeric attempt and pass, and identity enforced by the profile that owns the commit. The three files change together because the profile's validator signature, the documents that implement it and the store that installs it are one interlocking contract; no smaller split builds. Ruling 10, one store per clone. `main_work_tree` resolves the clone's main working tree from any worktree through the git common dir, and both `PipelineStore::open` and `PipelineWriter::attach` go through it, so a caller in a worktree reads and admits into the main tree's store. A bare clone or any layout without one main tree refuses `NoMainWorkTree`; there is no worktree-only mode. `require_branch` binds to the main tree, so a campaign whose branch is checked out only in a linked worktree is refused rather than quietly given a second store. Ruling 11, numeric labels. `attempt` and `pass` are `u32`, keyed `cut-.h` and `cut-.s`. Ruling 12, identity on first write. `TypedCommitStore` now validates a whole batch against the image it will be applied to (`validate_writes`), replacing the per-envelope hook rather than adding a second one. The pipeline profile refuses a first write that does not carry the identity, so the single owner can never admit a store its own opener refuses (F1). F2: a finding label carries no dot and parent locals parse strictly, so two documents cannot compose one key. Soul's collision pairs are tested. F3: parent ids are parsed with an exact segment count, a campaign match, a kind match, a validated local, and a marker followed by digits. F4: a resolution's subject id is validated as a full id of its declared kind, by `PipelineRef` itself, so kind and id cannot disagree anywhere. F5: the ten document wrappers, the identity, provenance and holder wrappers and their value fields are crate-private, and the crate's only commit path to a pipeline store is a `cfg(test)` helper taking `&PipelineWriter`. Binding the owner to the lease is Cut 3b. F6: covered by ruling 10, with the previous claim corrected: a worktree does not keep its own store, and a second attach from any tree is refused. F7: the holder record is written only under the lock and removed on drop, and a refusal names a holder only when it is plausibly current, matching host and live process incarnation. A record left by a crash names nobody. F8: the git checks read the committed blob at HEAD, so they answer the same on every machine. Attribute and exclude files are nulled on every git child. The working tree and `.git/info/exclude` deliberately do not count, because the rule is about what the repository commits. Low items: `value_types!` is the single field list, emitting each struct and its `Bounded` impl from the same tokens, and format rules moved into the field types (`Label`, `Slug`, `OrgRepo`, `FullSha`, `Sha256Hex`), so a field cannot be declared without being validated and a list without a cap does not compile. That applies the `Org/Repo` check to every repo field. `LandedNameKind` is deleted and `LandedName` is a name and a path. The schemas carry `maxLength` and `maxItems`. Builds clean with no warnings; `cargo test -p epiphany-core --lib` passes 179/179. Co-Authored-By: Claude Opus 5 --- epiphany-core/src/pipeline_documents.rs | 434 +++++++----- epiphany-core/src/pipeline_store.rs | 637 +++++++++++++++--- epiphany-core/src/reasoning_context.rs | 27 +- .../epiphany.pipeline.campaign.v1.schema.json | 17 +- ...piphany.pipeline.cut_report.v1.schema.json | 85 +-- .../epiphany.pipeline.cut_spec.v1.schema.json | 43 +- .../epiphany.pipeline.finding.v1.schema.json | 45 +- ...epiphany.pipeline.follow_up.v1.schema.json | 25 +- .../epiphany.pipeline.question.v1.schema.json | 25 +- ...piphany.pipeline.resolution.v1.schema.json | 5 + .../epiphany.pipeline.ruling.v1.schema.json | 41 +- .../epiphany.pipeline.target.v1.schema.json | 20 +- .../epiphany.pipeline.verdict.v1.schema.json | 17 +- 13 files changed, 1069 insertions(+), 352 deletions(-) diff --git a/epiphany-core/src/pipeline_documents.rs b/epiphany-core/src/pipeline_documents.rs index 3ff6da53..d4692185 100644 --- a/epiphany-core/src/pipeline_documents.rs +++ b/epiphany-core/src/pipeline_documents.rs @@ -5,6 +5,16 @@ //! stored payload is `[value]` and the value is the named map the published //! schema describes. Keys are semantic: `pipeline_key` derives them from the //! value, and the write validator refuses any envelope whose key differs. +//! +//! **Validation follows the type.** `value_types!` is the single field list: it +//! emits the struct and its `Bounded` impl from the same tokens, so a field +//! cannot be declared without being validated. Format rules live in the field's +//! type (`Label`, `Slug`, `OrgRepo`, `Sha`, `Sha256Hex`, `Date`), never in a +//! hand-written impl that a later field can slip past. A `Vec` field must carry +//! a maximum, because `Vec` has no `Bounded` impl of its own. +//! +//! The wrappers are crate-private: outside code reaches the store through the +//! admission path, never by registering or preparing a pipeline type itself. use crate::pipeline_store::PipelineRefusal; use anyhow::Result; @@ -24,6 +34,18 @@ impl Bounded for Option { } } +macro_rules! unbounded_scalars { + ($($ty:ty),* $(,)?) => {$( + impl Bounded for $ty { + fn validate(&self, _field: &str) -> Result<(), PipelineRefusal> { + Ok(()) + } + } + )*}; +} + +unbounded_scalars!(u32, u64, bool); + fn bound(field: &str, limit: usize, actual: usize) -> Result<(), PipelineRefusal> { if actual > limit { return Err(PipelineRefusal::FieldBound { @@ -60,33 +82,53 @@ fn hex(field: &str, value: &str, lengths: std::ops::RangeInclusive) -> Re Ok(()) } -/// `` and `` key segments: `[A-Za-z0-9._-]{1,64}`. -fn label<'a>(field: &str, value: &'a str) -> Result<&'a str, PipelineRefusal> { +/// A key label: `[A-Za-z0-9_-]{1,64}`. Dots are excluded so that a composed key +/// segments unambiguously; see `parent_cut` and the finding key. +fn label_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { let valid = value .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); if !valid || value.is_empty() || value.len() > 64 { return Err(format_error(field, value)); } - Ok(value) + Ok(()) } -fn org_repo(field: &str, value: &str) -> Result<(), PipelineRefusal> { +/// A dotted key segment: label parts joined by `.`, at most 64 bytes. Every +/// part must be a label, so `..`, a leading or trailing dot, and an empty part +/// are all refused. +fn dotted_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { + if value.is_empty() || value.len() > 64 { + return Err(format_error(field, value)); + } + for part in value.split('.') { + label_text(field, part)?; + } + Ok(()) +} + +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('/') => Ok(()), + Some((org, repo)) + if !org.is_empty() && !repo.is_empty() && !repo.contains('/') && value.len() <= 200 => + { + Ok(()) + } _ => Err(format_error(field, value)), } } macro_rules! bounded_text { - ($($(#[$doc:meta])* $name:ident = $limit:literal;)*) => {$( + ($($(#[$doc:meta])* $name:ident = $limit:literal, $check:expr;)*) => {$( $(#[$doc])* #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] + #[schemars(extend("maxLength" = $limit))] pub struct $name(pub String); impl Bounded for $name { fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { - bound(field, $limit, self.0.len()) + let check: fn(&str, &str) -> Result<(), PipelineRefusal> = $check; + check(field, &self.0) } } impl From<&str> for $name { @@ -97,29 +139,35 @@ macro_rules! bounded_text { )*}; } -bounded_text! { - /// Text of at most 200 UTF-8 bytes. - Short = 200; - /// Text of at most 1,000 UTF-8 bytes. - Line = 1000; - /// Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`. - Para = 4000; +fn within(limit: usize) -> impl Fn(&str, &str) -> Result<(), PipelineRefusal> { + move |field: &str, value: &str| bound(field, limit, value.len()) } -/// A git commit id: 7-40 lowercase hex characters. -#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] -#[serde(transparent)] -pub struct Sha(pub String); - -impl Bounded for Sha { - fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { - hex(field, &self.0, 7..=40) - } +bounded_text! { + /// Text of at most 200 UTF-8 bytes. + Short = 200, |field, value| within(200)(field, value); + /// 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`. + Para = 4000, |field, value| within(4000)(field, value); + /// A key label: `[A-Za-z0-9_-]{1,64}`. + Label = 64, label_text; + /// A campaign slug or key segment: label parts joined by `.`. + Slug = 64, dotted_text; + /// A GitHub repository as `Org/Repo`. + OrgRepo = 200, org_repo_text; + /// A git commit id: 7-40 lowercase hex characters. + Sha = 40, |field, value| hex(field, value, 7..=40); + /// A full git commit id: exactly 40 lowercase hex characters. + FullSha = 40, |field, value| hex(field, value, 40..=40); + /// A SHA-256 digest: exactly 64 lowercase hex characters. + Sha256Hex = 64, |field, value| hex(field, value, 64..=64); } /// A calendar date, `YYYY-MM-DD`. #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] +#[schemars(extend("maxLength" = 10))] pub struct Date(pub String); impl Bounded for Date { @@ -131,17 +179,6 @@ impl Bounded for Date { } } -macro_rules! bounded_struct { - ($($ty:ident { $($field:ident $([$max:literal])?),* $(,)? })*) => {$( - impl Bounded for $ty { - fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { - $(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)* - Ok(()) - } - } - )*}; -} - macro_rules! bounded_field { ($value:expr, $field:expr) => { Bounded::validate(&$value, $field)? @@ -151,11 +188,25 @@ macro_rules! bounded_field { }; } +/// The single field list: one invocation emits the value struct and its +/// `Bounded` impl, so a field can never be left out of validation. macro_rules! value_types { - ($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty),* $(,)? })*) => {$( + ($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? })*) => {$( $(#[$attr])* #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] - pub struct $name { $(pub $field: $ty),* } + pub struct $name { + $( + $(#[schemars(extend("maxItems" = $max))])? + pub $field: $ty + ),* + } + + impl Bounded for $name { + fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { + $(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)* + Ok(()) + } + } )*}; } @@ -163,94 +214,138 @@ macro_rules! unit_enums { ($($name:ident { $($variant:ident),* $(,)? })*) => {$( #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] pub enum $name { $($variant),* } + impl Bounded for $name { + fn validate(&self, _field: &str) -> Result<(), PipelineRefusal> { + Ok(()) + } + } )*}; } unit_enums! { EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture } Faculty { SelfFaculty, Imagination, Hands, Soul, MindSteward, Eyes, Operator } - LandedNameKind { Module, Type, Function, Constant, Test, Schema, Binary, File } ClaimOutcome { Holds, Falsified, Unproven } FindingConfidence { Confirmed, Plausible } FindingSeverity { Blocker, High, Medium, Low } } value_types! { - pub struct PipelineRef { kind: PipelineKind, id: Short } pub struct CodeLocation { path: Short, line: u32, end_line: Option } pub struct CommitRange { base: Sha, head: Sha } pub struct Evidence { kind: EvidenceKind, locator: Line, result: Line } /// A citation of a document in another repo's pipeline store (D6). - pub struct ForeignRef { repo: Short, commit: Sha, kind: PipelineKind, id: Short, payload_sha256: String } + pub struct ForeignRef { repo: OrgRepo, commit: FullSha, kind: PipelineKind, id: Short, payload_sha256: Sha256Hex } pub struct DocRef { path: Short, start_line: u32, end_line: u32, commit: Sha } - pub struct TargetInvariant { label: Short, statement: Line } - pub struct QuestionOption { label: Short, text: Line } + pub struct TargetInvariant { label: Label, statement: Line } + pub struct QuestionOption { label: Label, text: Line } pub struct CutDelete { path: Short, lines: u32, note: Line } pub struct FileChange { location: CodeLocation, change: Line } pub struct AuthorityMap { - owner: Line, inputs: Vec, outputs: Vec, derived_state: Vec, - forbidden_writers: Vec, shared_paths: Vec, deletion_line: Line, + owner: Line, inputs: Vec[16], outputs: Vec[16], derived_state: Vec[16], + forbidden_writers: Vec[16], shared_paths: Vec[16], deletion_line: Line, } pub struct VerificationTest { name: Short, pins: Line } pub struct NegativeCheck { pattern: Short, scope: Line } pub struct CutVerification { - builds: Vec, tests: Vec, negative: Vec, operator: Vec, + builds: Vec[64], tests: Vec[64], + negative: Vec[64], operator: Vec[64], + } + // D1 tables no maximum for these lists, so they take the shared list + // default of 64 rather than a number invented for this field alone. + pub struct SubtractionEstimate { + lines_removed: u32, lines_added: u32, removed: Vec[64], added: Vec[64], } - pub struct SubtractionEstimate { lines_removed: u32, lines_added: u32, removed: Vec, added: Vec } pub struct ReportCommit { sha: Sha, subject: Line, builds: bool } pub struct MutationRecord { rule: Line, mutation: Line, failed_as_expected: bool } pub struct Deviation { what: Line, why: Line } + // As for `SubtractionEstimate`: D1 tables no maximum, so these lists take + // the shared list default. pub struct StructuralDelta { lines_added: u32, lines_removed: u32, - dependencies_added: Vec, dependencies_removed: Vec, - formats_added: Vec, formats_removed: Vec, - targets_added: Vec, targets_removed: Vec, + dependencies_added: Vec[64], dependencies_removed: Vec[64], + formats_added: Vec[64], formats_removed: Vec[64], + targets_added: Vec[64], targets_removed: Vec[64], } - pub struct LandedName { name: Short, kind: LandedNameKind, location: CodeLocation } - pub struct VerdictClaim { claim: Line, outcome: ClaimOutcome, evidence: Vec, findings: Vec } + /// A name the cut landed, and where it lives. + pub struct LandedName { name: Short, path: Short } + pub struct VerdictClaim { claim: Line, outcome: ClaimOutcome, evidence: Vec[8], findings: Vec[16] } - pub struct PipelineCampaign { slug: Short, title: Short, repos: Vec, working_branch: Short, target_doc: DocRef } + pub struct PipelineCampaign { slug: Slug, title: Short, repos: Vec[8], working_branch: Short, target_doc: DocRef } pub struct PipelineTarget { - campaign: Short, revision: u32, invariants: Vec, not_in_scope: Vec, - canonical_implementations: Vec, doc: DocRef, + campaign: Slug, revision: u32, invariants: Vec[32], not_in_scope: Vec[32], + canonical_implementations: Vec[16], doc: DocRef, } pub struct PipelineQuestion { - campaign: Short, label: Short, question: Para, options: Vec, recommended: Short, - depends: Vec, raised_in: Option, asked_on: Date, + campaign: Slug, label: Label, question: Para, options: Vec[8], recommended: Label, + depends: Vec[8], raised_in: Option, asked_on: Date, } pub struct PipelineRuling { - campaign: Short, label: Short, answers: Option, choice: Option, ruling: Para, - operator_quote: Option, ruled_on: Date, precedents: Vec, + campaign: Slug, label: Label, answers: Option, choice: Option