Merge eureka-substrate/leaf-read-anchors: CodeLocation.symbol and CutSpec.reads, additive at epoch v2

Soul held (eureka-substrate:verdict:cut-leaf-read-anchors.s1): every
pre-existing golden line byte-identical, 24/24 mutants and 23 hand
mutants on the macro's serde attributes killed. Findings deferred to
leaf-refusal-hygiene and huginn-upgrade-daemon-first. Not deployed:
the live daemon must be upgraded first (ops-read-anchors-upgrade).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-09 22:50:36 +02:00
commit 441b1fc637
16 changed files with 435 additions and 17 deletions

View file

@ -15,7 +15,8 @@ like the rest of the workspace.
Committed (runs in `cargo test -p eureka-pipeline`): `encoded_envelopes_match_the_committed_golden`
asserts key, cultcache type id, schema name and payload bytes of every kind
and every encoding-relevant variant against `golden/envelopes.txt`. A diff
there is a wire change; do not regenerate it to make the test pass.
there is a wire change; do not regenerate it to make the test pass. An additive
field adds golden lines and never changes an existing one.
Opt-in, against a real store: `stored_documents_read_back` decodes, validates
and keys every stored pipeline document and asserts that it re-encodes to the

View file

@ -9,6 +9,7 @@
# live mind store (eureka-body:verdict:cut-leaf-into-huginn.s1) and to
# Epiphany ef956865 modulo that rename (verdict ...s2). Never regenerate to make
# a failing test pass: a diff here is a wire change and needs its own ruling.
# cut_spec.reads-and-symbol and finding.location-symbol were appended after 44c010c added CodeLocation.symbol and PipelineCutSpec.reads (ruling context-pack-read-anchors); no earlier line changed.
sample.campaign eureka-state:campaign:self epiphany.pipeline.campaign.v2 EpiphanyPipelineCampaignDocument 9185a4736c7567ac657572656b612d7374617465a57469746c65b5457572656b6120706970656c696e65207374617465a57265706f7391b147616d6543756c742f4570697068616e79ae776f726b696e675f6272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465aa7461726765745f646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
sample.target eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9186a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
sample.question eureka-state:question:Q1 epiphany.pipeline.question.v2 EpiphanyPipelineQuestionDocument 9189a863616d706169676eac657572656b612d7374617465a56c6162656ca25131a57469746c65b357686f206f776e73207468652073746174653fa87175657374696f6eb357686f206f776e73207468652073746174653fa76f7074696f6e739282a56c6162656ca141a474657874ab616e20696e7374616e636582a56c6162656ca142a474657874a661207265706fab7265636f6d6d656e646564a141a7646570656e647391a6437574203361a97261697365645f696e82a46b696e64a743757453706563a26964bf657572656b612d73746174653a6375745f737065633a6375742d33612e7231a861736b65645f6f6eaa323032362d30392d3135
@ -40,3 +41,5 @@ finding.plausible-blocker-preexisting-kinds eureka-state:finding:cut-3a.s2.F4 ep
finding.severity-medium eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a64d656469756da5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
finding.severity-low eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a34c6f77a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
ruling.authority-mind eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a44d696e64
cut_spec.reads-and-symbol eureka-state:cut_spec:cut-3a.r1 epiphany.pipeline.cut_spec.v2 EpiphanyPipelineCutSpecDocument 91de0013a863616d706169676eac657572656b612d7374617465a3637574a23361a87265766973696f6e01a57469746c65b2506970656c696e6520646f63756d656e7473a47265706fb147616d6543756c742f4570697068616e79a66272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465a462617365a83566393832323864aa646570656e64735f6f6e91a132a5666972737491ae526561642074686520737065632ea764656c657465739183a470617468a66f6c642e7273a56c696e657303a46e6f7465a464656164ab6b656570735f6d6f76657391ac636f6d6d6974206f776e6572a46164647391b5706970656c696e655f646f63756d656e74732e7273ac66696c655f6368616e6765739182a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab76616c75655f7479706573a66368616e6765a3616464a572656164739282a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e650aa8656e645f6c696e6514a673796d626f6caf506970656c696e6543757453706563a3776879b274686520646f63756d656e7420736861706582a86c6f636174696f6e83a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e651ea8656e645f6c696e65c0a3776879a86f6e65206c696e65ad617574686f726974795f6d617087a56f776e6572a4636f7265a6696e7075747391af747970656420646f63756d656e7473a76f75747075747391a9656e76656c6f706573ad646572697665645f737461746591ac64657269766564206b657973b1666f7262696464656e5f7772697465727391a34d4350ac7368617265645f706174687391a961646d697373696f6ead64656c6574696f6e5f6c696e65a36e2f61ac766572696669636174696f6e84a66275696c647391ab636172676f20636865636ba574657374739182a46e616d65a46b657973a470696e73af6f6e652064657269766564206b6579a86e656761746976659182a77061747465726ea75665633c75383ea573636f7065a9646f63756d656e7473a86f70657261746f7291a46e6f6e65a8657374696d61746588ab6c696e65735f6164646564cd0384ad6c696e65735f72656d6f76656400b2646570656e64656e636965735f616464656491a8736368656d617273b4646570656e64656e636965735f72656d6f76656490ad666f726d6174735f616464656491b66570697068616e792e706970656c696e652e2a2e7631af666f726d6174735f72656d6f76656490ad746172676574735f616464656490af746172676574735f72656d6f76656490a772756c696e677391b6657572656b612d73746174653a72756c696e673a5238a97175657374696f6e7391b8657572656b612d73746174653a7175657374696f6e3a5131
finding.location-symbol eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a448696768a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739184a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab2631313533303930343938b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564

View file

@ -27,7 +27,9 @@
//! 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<T>` has no `Bounded` impl of its own.
//! a maximum, because `Vec<T>` has no `Bounded` impl of its own. A field added
//! at the epoch carries the `= absent` marker, which defaults it on read and
//! omits it on write when empty.
//!
//! The wrappers are crate-private: outside code registers, prepares and
//! decodes them through `register_pipeline_document_types`,
@ -82,6 +84,24 @@ impl<T: Bounded> Bounded for Option<T> {
}
}
/// A value with an empty spelling. The `= absent` marker on a `value_types!`
/// field requires it, and the field is then omitted on write when it is absent.
pub(crate) trait Absent {
fn absent(&self) -> bool;
}
impl<T> Absent for Option<T> {
fn absent(&self) -> bool {
self.is_none()
}
}
impl<T> Absent for Vec<T> {
fn absent(&self) -> bool {
self.is_empty()
}
}
macro_rules! unbounded_scalars {
($($ty:ty),* $(,)?) => {$(
impl Bounded for $ty {
@ -238,6 +258,14 @@ fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
within(200)(field, value)
}
/// A symbol is 1 to 128 bytes with no whitespace and no control character.
fn symbol_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
if value.is_empty() || value.chars().any(|character| character.is_whitespace() || character.is_control()) {
return Err(format_error(field, value));
}
within(128)(field, value)
}
bounded_text! {
/// Text of at most 200 UTF-8 bytes.
Short = 200, |field, value| within(200)(field, value);
@ -249,6 +277,11 @@ bounded_text! {
Title = 200, title_text;
/// Text of at most 1,000 UTF-8 bytes.
Line = 1000, |field, value| within(1000)(field, value);
/// A name the location's lines must contain as written there (an
/// identifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no
/// whitespace and no control character.
#[schemars(extend("minLength" = 1))]
Symbol = 128, symbol_text;
/// 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}`.
@ -415,19 +448,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 $([$max:literal])?),* $(,)? } $(=> $extra:path)?)*) => {$(
($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])? $(= $marker:ident)?),* $(,)? } $(=> $extra:path)?)*) => {$(
$(#[$attr])*
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct $name {
$(
$(#[schemars(extend("maxItems" = $max))])?
// `= absent`: defaulted on read, omitted on write when absent.
// The `cfg_attr(any(), ..)` is a no-op that binds `$marker` to the group.
$(#[serde(default, skip_serializing_if = "Absent::absent")] #[cfg_attr(any(), $marker)])?
pub $field: $ty
),*
}
impl Bounded for $name {
fn validate(&self, at: &str) -> Result<(), PipelineRefusal> {
$(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)*
$(
$(let _: fn(&$ty) -> bool = <$ty as Absent>::$marker;)?
bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);
)*
$($extra(self, at)?;)?
Ok(())
}
@ -472,6 +511,33 @@ fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<
Ok(())
}
/// The most lines one read anchor may span.
const READ_WINDOW_MAX_LINES: u32 = 100;
/// A location starts at line 1 or later, ends no earlier than it starts, and a
/// read anchor spans at most `READ_WINDOW_MAX_LINES`. The bounds are on
/// `ReadAnchor` alone: `CodeLocation` elsewhere keeps its stored shape.
fn read_window_is_bounded(anchor: &ReadAnchor, at: &str) -> Result<(), PipelineRefusal> {
let location = &anchor.location;
if location.line < 1 {
return Err(format_error(&format!("{at}.location.line"), &location.line.to_string()));
}
if let Some(end_line) = location.end_line {
if end_line < location.line {
return Err(format_error(&format!("{at}.location.end_line"), &end_line.to_string()));
}
let span = end_line - location.line + 1;
if span > READ_WINDOW_MAX_LINES {
return Err(PipelineRefusal::FieldBound {
field: format!("{at}.location.end_line"),
limit: READ_WINDOW_MAX_LINES,
actual: span,
});
}
}
Ok(())
}
/// A run claims work, never runs, rulings or campaigns: its claims are cut
/// specs, findings and follow-ups, the three kinds a Self takes on. And its
/// budget is a canonical decimal (`0`, `12`, `7.5`: no sign, no leading or
@ -509,7 +575,7 @@ unit_enums! {
}
value_types! {
pub struct CodeLocation { path: Short, line: u32, end_line: Option<u32> }
pub struct CodeLocation { path: Short, line: u32, end_line: Option<u32>, symbol: Option<Symbol> = absent }
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).
@ -519,6 +585,8 @@ value_types! {
pub struct QuestionOption { label: Label, text: Line }
pub struct CutDelete { path: Short, lines: u32, note: Line }
pub struct FileChange { location: CodeLocation, change: Line }
/// Code Hands must see but not change, in the spec's repo at the spec's base.
pub struct ReadAnchor { location: CodeLocation, why: Short } => read_window_is_bounded
pub struct AuthorityMap {
owner: Line, inputs: Vec<Line>[16], outputs: Vec<Line>[16], derived_state: Vec<Line>[16],
forbidden_writers: Vec<Line>[16], shared_paths: Vec<Line>[16], deletion_line: Line,
@ -572,7 +640,7 @@ value_types! {
pub struct PipelineCutSpec {
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>,
adds: Vec<Line>[64], file_changes: Vec<FileChange>[256], reads: Vec<ReadAnchor>[16] = absent, authority_map: Option<AuthorityMap>,
verification: CutVerification, estimate: StructuralDelta,
rulings: Vec<Short>[32], questions: Vec<Short>[16],
}
@ -737,6 +805,7 @@ macro_rules! pipeline_kinds {
/// shape, not a document's.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
#[allow(clippy::large_enum_variant, reason = "a document is a transient wrapper around one value; boxing a variant would change every construction site")]
pub enum PipelineDocument { $($variant($value)),* }
impl PipelineKind {
@ -967,7 +1036,8 @@ fn local_max(kind: PipelineKind) -> usize {
/// The schema epoch every pipeline store is written at, owned here with the
/// schemas it names. Evolution is additive and keeps it: a new named field
/// with a serde default, or a widened `PipelineKind`, since each reader ships
/// with the `value_types!` `= absent` marker (a replayed batch re-encodes byte
/// for byte, because an absent field is not written), or a widened `PipelineKind`, since each reader ships
/// with the variants it knows and refuses an unknown kind on the kind, not on
/// the epoch. A breaking change bumps it, so that a store written at the old
/// one can be refused by whoever opens it; this crate owns no store and
@ -1105,7 +1175,7 @@ pub fn validate_pipeline_write_envelope(envelope: &CultCacheEnvelope) -> Result<
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
const CAMPAIGN: &str = "eureka-state";
@ -1144,7 +1214,7 @@ mod tests {
}
fn location() -> CodeLocation {
CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 1, end_line: Some(9) }
CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 1, end_line: Some(9), symbol: None }
}
fn evidence() -> Evidence {
@ -1196,6 +1266,7 @@ mod tests {
deletes: vec![CutDelete { path: s("old.rs"), lines: 3, note: "dead".into() }],
keeps_moves: vec!["commit owner".into()], adds: vec!["pipeline_documents.rs".into()],
file_changes: vec![FileChange { location: location(), change: "add".into() }],
reads: vec![],
authority_map: Some(AuthorityMap {
owner: "core".into(), inputs: vec!["typed documents".into()], outputs: vec!["envelopes".into()],
derived_state: vec!["derived keys".into()], forbidden_writers: vec!["MCP".into()],
@ -1425,6 +1496,16 @@ mod tests {
out.push((format!("finding.severity-{name}"), D::Finding(f)));
}
out.push(("ruling.authority-mind".into(), D::Ruling(mind_ruling())));
let PipelineDocument::CutSpec(mut spec) = samples().remove(4).0 else { unreachable!() };
spec.file_changes[0].location.symbol = Some("value_types".into());
spec.reads = vec![
ReadAnchor { location: CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 10, end_line: Some(20), symbol: Some("PipelineCutSpec".into()) }, why: "the document shape".into() },
ReadAnchor { location: CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 30, end_line: None, symbol: None }, why: "one line".into() },
];
out.push(("cut_spec.reads-and-symbol".into(), D::CutSpec(spec)));
let mut f = finding_sample();
f.locations[0].symbol = Some("&1153090498".into());
out.push(("finding.location-symbol".into(), D::Finding(f)));
out
}
@ -2964,6 +3045,133 @@ mod tests {
);
}
fn read_anchor(line: u32, end_line: Option<u32>, symbol: Option<&str>) -> ReadAnchor {
ReadAnchor {
location: CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line, end_line, symbol: symbol.map(Symbol::from) },
why: "read".into(),
}
}
fn cut_spec_sample() -> PipelineCutSpec {
let PipelineDocument::CutSpec(spec) = samples().remove(4).0 else { unreachable!() };
spec
}
/// Every committed golden line decodes, from its committed payload bytes,
/// to the corpus document it was derived from. The pre-field lines carry no
/// `reads` or `symbol` key, so they decode to empty and `None` only
/// because both fields default on read.
#[test]
fn golden_payloads_decode_to_their_documents() -> Result<()> {
let cache = schema_cache()?;
let golden = include_str!("../golden/envelopes.txt");
let lines: BTreeMap<&str, &str> = golden
.lines()
.filter(|line| !line.starts_with('#') && !line.is_empty())
.filter_map(|line| Some((line.split(' ').next()?, line.rsplit(' ').next()?)))
.collect();
let corpus = golden_documents();
assert_eq!(lines.len(), corpus.len());
for (label, document) in corpus {
let hex = lines[label.as_str()];
let mut envelope = document.prepare(&cache)?;
envelope.payload = (0..hex.len()).step_by(2).map(|at| u8::from_str_radix(&hex[at..at + 2], 16).unwrap()).collect();
assert_eq!(PipelineDocument::decode(&envelope)?, document, "{label} decodes to its document");
}
Ok(())
}
/// The MCP door: JSON with no `reads` key and locations with no `symbol`
/// key is the pre-field shape, and it decodes.
#[test]
fn pre_field_json_decodes() -> Result<()> {
let json = serde_json::to_value(PipelineDocument::CutSpec(cut_spec_sample()))?;
assert!(json["value"].get("reads").is_none(), "an empty reads is not written");
assert!(json["value"]["file_changes"][0]["location"].get("symbol").is_none(), "an absent symbol is not written");
let PipelineDocument::CutSpec(spec) = serde_json::from_value::<PipelineDocument>(json)? else { unreachable!() };
assert!(spec.reads.is_empty());
assert_eq!(spec.file_changes[0].location.symbol, None);
Ok(())
}
#[test]
fn symbol_and_reads_round_trip() -> Result<()> {
let mut spec = cut_spec_sample();
spec.reads = (0..16).map(|index| read_anchor(index + 1, Some(index + 100), Some("value_types"))).collect();
let mut finding = finding_sample();
finding.locations[0].symbol = Some("&1153090498".into());
for document in [PipelineDocument::CutSpec(spec), PipelineDocument::Finding(finding)] {
document.validate()?;
let json = serde_json::to_value(&document)?;
assert_eq!(serde_json::from_value::<PipelineDocument>(json.clone())?, document);
let packed = rmp_serde::to_vec_named(&document)?;
assert_eq!(rmp_serde::from_slice::<PipelineDocument>(&packed)?, document);
assert!(packed.windows(6).any(|window| window == b"symbol"), "the symbol key is written");
assert_eq!(packed.windows(5).any(|window| window == b"reads"), matches!(document, PipelineDocument::CutSpec(_)));
assert!(json.to_string().contains("\"symbol\""));
}
Ok(())
}
#[test]
fn read_and_symbol_bounds() {
let with_reads = |reads: Vec<ReadAnchor>| {
let mut spec = cut_spec_sample();
spec.reads = reads;
PipelineDocument::CutSpec(spec).validate()
};
let with_symbol = |symbol: &str| {
let mut finding = finding_sample();
finding.locations[0].symbol = Some(symbol.into());
PipelineDocument::Finding(finding).validate()
};
assert_eq!(with_reads((0..16).map(|_| read_anchor(1, Some(100), None)).collect()), Ok(()));
assert_eq!(
with_reads((0..17).map(|_| read_anchor(1, Some(100), None)).collect()),
Err(PipelineRefusal::FieldBound { field: "cut_spec.reads".into(), limit: 16, actual: 17 })
);
assert_eq!(
with_reads(vec![read_anchor(5, Some(4), None)]),
Err(PipelineRefusal::InvalidFormat { field: "cut_spec.reads[0].location.end_line".into(), value: "4".into() })
);
assert_eq!(
with_reads(vec![read_anchor(0, None, None)]),
Err(PipelineRefusal::InvalidFormat { field: "cut_spec.reads[0].location.line".into(), value: "0".into() })
);
assert_eq!(with_reads(vec![read_anchor(7, Some(7), None)]), Ok(()), "a one-line span");
assert_eq!(with_reads(vec![read_anchor(10, Some(109), None)]), Ok(()), "a span of 100");
assert_eq!(
with_reads(vec![read_anchor(10, Some(110), None)]),
Err(PipelineRefusal::FieldBound { field: "cut_spec.reads[0].location.end_line".into(), limit: 100, actual: 101 })
);
assert_eq!(with_symbol(&"s".repeat(128)), Ok(()));
assert_eq!(
with_symbol(&"s".repeat(129)),
Err(PipelineRefusal::FieldBound { field: "finding.locations[0].symbol".into(), limit: 128, actual: 129 })
);
for bad in ["a b", "a\tb", "", "a\u{0}b", "a\u{3000}b"] {
assert_eq!(
with_symbol(bad),
Err(PipelineRefusal::InvalidFormat { field: "finding.locations[0].symbol".into(), value: bad.into() }),
"{bad:?}"
);
}
// A location outside a read anchor keeps its stored shape.
let mut finding = finding_sample();
finding.locations[0].line = 0;
finding.locations[0].end_line = Some(0);
assert_eq!(PipelineDocument::Finding(finding).validate(), Ok(()));
}
/// The marker needs an `Absent` spelling; the two impls say what absent is.
#[test]
fn absent_is_none_and_empty() {
assert!(Absent::absent(&None::<u32>));
assert!(!Absent::absent(&Some(0u32)));
assert!(Absent::absent(&Vec::<u32>::new()));
assert!(!Absent::absent(&vec![0u32]));
}
/// Cut 8: the leaf owns the serialisation of the shapes it owns, so a wire
/// carrying a document or a refusal reads back the same value rather than
/// a mirror definition kept in step by hand in the reader's repo. Both

View file

@ -56,10 +56,11 @@ fn cut_spec(cut: &str, file_changes: usize) -> PipelineDocument {
adds: lines(64),
file_changes: (0..file_changes)
.map(|n| FileChange {
location: CodeLocation { path: short(n), line: 1, end_line: Some(9) },
location: CodeLocation { path: short(n), line: 1, end_line: Some(9), symbol: None },
change: line(n),
})
.collect(),
reads: vec![],
authority_map: Some(AuthorityMap {
owner: line(0),
inputs: lines(16),

View file

@ -353,11 +353,12 @@ pub(crate) mod tests {
/// roughly a megabyte of field content, which is what a real-sized answer
/// looks like at the leaf's bounds rather than at a fixture's convenience.
/// `depends_on`, `rulings` and `questions` stay empty because each is a
/// reference admission resolves.
/// reference admission resolves. `reads` and location symbols stay absent:
/// `FITTING_CHANGES` is calibrated to `MAX_RESPONSE_BYTES` without them.
pub(crate) fn cut_spec(cut: &str, file_changes: usize) -> PipelineDocument {
let lines = |count: usize| (0..count).map(line).collect::<Vec<_>>();
let shorts = |count: usize| (0..count).map(short).collect::<Vec<_>>();
let location = |n: usize| CodeLocation { path: short(n), line: 1, end_line: Some(9) };
let location = |n: usize| CodeLocation { path: short(n), line: 1, end_line: Some(9), symbol: None };
PipelineDocument::CutSpec(PipelineCutSpec {
campaign: slug(CAMPAIGN),
cut: cut.into(),
@ -372,6 +373,7 @@ pub(crate) mod tests {
keeps_moves: lines(64),
adds: lines(64),
file_changes: (0..file_changes).map(|n| FileChange { location: location(n), change: line(n) }).collect(),
reads: vec![],
authority_map: Some(AuthorityMap {
owner: line(0),
inputs: lines(16),

View file

@ -603,7 +603,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool {
use PipelineKind as K;
use ResolutionOutcome as O;
let all = |by: &[PipelineRef], kind: K| by.iter().all(|supersessor| supersessor.kind == kind);
let fits = match (subject, outcome) {
match (subject, outcome) {
(K::Target, O::Superseded { by }) => all(by, K::Target),
(K::Question, O::Answered { by }) => by.kind == K::Ruling,
(K::Question, O::Withdrawn { .. }) => true,
@ -621,8 +621,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool {
(K::Run, O::Recorded { .. } | O::Withdrawn { .. }) => true,
(K::Resolution, O::Withdrawn { .. }) => true,
_ => false,
};
fits
}
}
/// The resolution row: the sequence, the in-force subject, the matrix, the
@ -863,6 +862,42 @@ mod tests {
assert_eq!(receipts[0].provenance.tool, s("admit tool"));
}
/// The read bounds are the leaf's: admission applies the leaf's validation
/// and adds none. Sixteen anchors of a hundred lines admit and read back;
/// a seventeenth refuses at `cut_spec.reads` and nothing is written.
#[test]
fn reads_admit_at_the_bound_and_refuse_over_it() {
let anchors = |count: usize| {
(0..count)
.map(|n| eureka_pipeline::ReadAnchor {
location: eureka_pipeline::CodeLocation {
path: s("crates/eureka-pipeline/src/lib.rs"),
line: 1,
end_line: Some(100),
symbol: Some(format!("symbol_{n}").as_str().into()),
},
why: s("read"),
})
.collect::<Vec<_>>()
};
let mut mind = seeded();
let mut spec = cut_spec("1", 1);
spec.reads = anchors(16);
committed(admit(&mut mind, vec![D::CutSpec(spec.clone())]));
let held = mind.view(&r(K::CutSpec, &id("cut_spec", "cut-1.r1"))).unwrap().expect("the spec is held");
assert_eq!(held.document, D::CutSpec(spec));
let mut over = cut_spec("2", 1);
over.reads = anchors(17);
let receipts = mind.receipts().unwrap().len();
assert_eq!(
refusal(admit(&mut mind, vec![D::CutSpec(over)])),
MindRefusal::Document(PipelineRefusal::FieldBound { field: "cut_spec.reads".into(), limit: 16, actual: 17 })
);
assert_eq!(mind.receipts().unwrap().len(), receipts);
assert_eq!(mind.view(&r(K::CutSpec, &id("cut_spec", "cut-2.r1"))).unwrap(), None, "nothing is written");
}
/// The leaf's `Title` rule is private, so this holds the provenance rule to
/// it: the two accept and refuse the same text.
#[test]

View file

@ -85,7 +85,7 @@ fn range() -> CommitRange {
}
fn location() -> CodeLocation {
CodeLocation { path: s("crates/huginn-mind/src/admission.rs"), line: 1, end_line: None }
CodeLocation { path: s("crates/huginn-mind/src/admission.rs"), line: 1, end_line: None, symbol: None }
}
pub(crate) fn evidence() -> Evidence {
@ -203,6 +203,7 @@ pub(crate) fn cut_spec(cut: &str, revision: u32) -> PipelineCutSpec {
keeps_moves: vec![],
adds: vec![],
file_changes: vec![],
reads: vec![],
authority_map: None::<AuthorityMap>,
verification: CutVerification { builds: vec![], tests: vec![], negative: vec![], operator: vec![] },
estimate: delta(),

View file

@ -155,6 +155,7 @@ impl HuginnCommitReceipt {
/// How a commit ended. An exact replay is decided by admission before the
/// commit is attempted, so it is not an exit of the primitive.
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::large_enum_variant, reason = "one outcome per commit, returned once and matched at once")]
pub(crate) enum CommitOutcome {
/// The batch and its receipt landed whole.
Committed(HuginnCommitReceipt),

View file

@ -102,6 +102,7 @@ impl HuginnMindRequest {
/// transport failure: the read side carries `Refused`, the write side carries
/// the outcome's own `Refused`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[allow(clippy::large_enum_variant, reason = "one answer per request; a document view is the answer a reader asked for")]
pub enum HuginnMindResponse {
Whoami(MindStatus),
Admit(PipelineAdmissionOutcome),

View file

@ -19,6 +19,6 @@ schemas are derived from the value types in `crates/eureka-pipeline` and checked
byte for byte by its `pipeline_published_schemas_match_derivation` test. Each
document payload is `[value]`, a one-element MessagePack array whose element is
the named map the schema describes. Text bounds are UTF-8 bytes and enforced by
admission, not by the schema. A new named field with a serde default keeps
admission, not by the schema. A new named field marked `= absent` (defaulted on read, omitted on write when empty) keeps
`epiphany.pipeline.epoch.v2`; a breaking change bumps the epoch, and the new
binary refuses the old store.

View file

@ -17,6 +17,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -268,6 +278,12 @@
"targets_removed"
],
"type": "object"
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"maxLength": 128,
"minLength": 1,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.cut_report.v2.schema.json",

View file

@ -72,6 +72,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -185,6 +195,22 @@
"maxLength": 140,
"type": "string"
},
"ReadAnchor": {
"description": "Code Hands must see but not change, in the spec's repo at the spec's base.",
"properties": {
"location": {
"$ref": "#/$defs/CodeLocation"
},
"why": {
"$ref": "#/$defs/Short"
}
},
"required": [
"location",
"why"
],
"type": "object"
},
"Sha": {
"description": "A git commit id: 7-40 lowercase hex characters.",
"maxLength": 40,
@ -267,6 +293,12 @@
],
"type": "object"
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"Title": {
"description": "A title: 1 to 200 UTF-8 bytes, enforced by admission; JSON Schema\nlength counts characters. Holds at least one alphanumeric character,\nno control character, and no bidi control character (`U+061C`,\n`U+200E`, `U+200F`, `U+202A`-`U+202E`, `U+2066`-`U+2069`).",
"maxLength": 200,
@ -366,6 +398,13 @@
"maxItems": 16,
"type": "array"
},
"reads": {
"items": {
"$ref": "#/$defs/ReadAnchor"
},
"maxItems": 16,
"type": "array"
},
"repo": {
"$ref": "#/$defs/OrgRepo"
},

View file

@ -17,6 +17,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -184,6 +194,12 @@
"description": "A campaign slug or key segment: label parts joined by `.`.",
"maxLength": 64,
"type": "string"
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"maxLength": 128,
"minLength": 1,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.finding.v2.schema.json",

View file

@ -17,6 +17,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -84,6 +94,12 @@
"description": "A campaign slug or key segment: label parts joined by `.`.",
"maxLength": 64,
"type": "string"
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"maxLength": 128,
"minLength": 1,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.follow_up.v2.schema.json",

View file

@ -162,6 +162,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -724,6 +734,13 @@
},
"maxItems": 16
},
"reads": {
"type": "array",
"items": {
"$ref": "#/$defs/ReadAnchor"
},
"maxItems": 16
},
"repo": {
"$ref": "#/$defs/OrgRepo"
},
@ -1579,6 +1596,22 @@
"text"
]
},
"ReadAnchor": {
"description": "Code Hands must see but not change, in the spec's repo at the spec's base.",
"type": "object",
"properties": {
"location": {
"$ref": "#/$defs/CodeLocation"
},
"why": {
"$ref": "#/$defs/Short"
}
},
"required": [
"location",
"why"
]
},
"ReportCommit": {
"type": "object",
"properties": {
@ -1866,6 +1899,12 @@
"targets_removed"
]
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"type": "string",
"maxLength": 128,
"minLength": 1
},
"TargetInvariant": {
"type": "object",
"properties": {

View file

@ -214,6 +214,16 @@
},
"path": {
"$ref": "#/$defs/Short"
},
"symbol": {
"anyOf": [
{
"$ref": "#/$defs/Symbol"
},
{
"type": "null"
}
]
}
},
"required": [
@ -1872,6 +1882,13 @@
},
"maxItems": 16
},
"reads": {
"type": "array",
"items": {
"$ref": "#/$defs/ReadAnchor"
},
"maxItems": 16
},
"repo": {
"$ref": "#/$defs/OrgRepo"
},
@ -3676,6 +3693,22 @@
"text"
]
},
"ReadAnchor": {
"description": "Code Hands must see but not change, in the spec's repo at the spec's base.",
"type": "object",
"properties": {
"location": {
"$ref": "#/$defs/CodeLocation"
},
"why": {
"$ref": "#/$defs/Short"
}
},
"required": [
"location",
"why"
]
},
"ReportCommit": {
"type": "object",
"properties": {
@ -3945,6 +3978,12 @@
"targets_removed"
]
},
"Symbol": {
"description": "A name the location's lines must contain as written there (an\nidentifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no\nwhitespace and no control character.",
"type": "string",
"maxLength": 128,
"minLength": 1
},
"TargetInvariant": {
"type": "object",
"properties": {