From 4cccb45c5e51c2f54cc0af992273ebd651d2982e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 12:53:12 +0200 Subject: [PATCH] Read a resolution back by the key it has Soul G2: F3's defect survived for `Resolution`, unpinned in both directions. A resolution's key is `resolution:`, and the reader demanded `:resolution:`. The two are disjoint, so `pipeline_id` refused every id a resolution actually has and accepted one no resolution can ever carry: a document that can be written but never referenced, and a reference that names nothing. `PipelineRef` takes any kind, so both are reachable -- a question's `raised_in`, a follow-up's `source`, a resolution's own `subject`. The reader now accepts what `pipeline_key` derives: a `resolution:` prefix, then the subject's id parsed as an id of the kind that id names itself as. `declared_kind` reads that kind from the id's own kind segment, or calls a bare slug a root. A key and an id are the same string, and now no kind is excused from that. `keys_read_back_as_ids_of_their_kind` loses its resolution special case: the loop covers every kind with no exception, which is the pin the last batch's doc-comment curiosity should have been. Mutations, each run against the committed suite and reversed after: - Soul's survivor, `| PipelineKind::Resolution` on the root case: now fails `a_resolution_is_named_by_the_key_it_has` and `keys_read_back_as_ids_of_their_kind`. It compiles with an unreachable pattern warning, because the resolution case is an arm of the same match. - the resolution case falls through to the campaign tail (the landed behaviour): the same two tests fail. - a generous reader that takes the prefix when it is there and falls through when it is not: the negative direction fails on its own, `eureka-state:resolution:R8` still being accepted. Co-Authored-By: Claude Opus 5 --- epiphany-pipeline/src/lib.rs | 105 ++++++++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/epiphany-pipeline/src/lib.rs b/epiphany-pipeline/src/lib.rs index 2387b0da..935b7ed7 100644 --- a/epiphany-pipeline/src/lib.rs +++ b/epiphany-pipeline/src/lib.rs @@ -523,19 +523,50 @@ pipeline_kinds! { "epiphany.pipeline.hand_off.v1", "EpiphanyPipelineHandOffDocument"; } -/// Parses a full document id. `::` for every kind but -/// the two roots, `campaign` and `instance`, which are keyed by their slug -/// alone and so read back the same way. The segment count is exact, the kind -/// segment must match `kind`, and both the campaign and the local segment are +/// The kind an id names itself as: the kind segment of `::`, +/// or `Campaign` for a bare slug, since the two roots share one id rule. A +/// resolution's id is its subject's, and this is how that subject is read back. +fn declared_kind(id: &str) -> Option { + let mut segments = id.split(':'); + match (segments.next(), segments.next(), segments.next(), segments.next()) { + (Some(_), None, ..) => Some(PipelineKind::Campaign), + (Some(_), Some(name), Some(_), None) => { + PipelineKind::ALL.iter().copied().find(|kind| kind.name() == name) + } + _ => None, + } +} + +/// Parses a full document id. A key and an id are the same string, so this +/// accepts exactly what `pipeline_key` derives: `::` for +/// every kind but the roots and a resolution. The segment count is exact, the +/// kind segment must match `kind`, and both the root and the local segment are /// validated, so `..`, an empty part, spaces and trailing junk are all refused. fn pipeline_id<'a>( field: &str, id: &'a str, kind: PipelineKind, ) -> Result<(&'a str, &'a str), PipelineRefusal> { - if matches!(kind, PipelineKind::Campaign | PipelineKind::Instance) { - dotted_text(field, id)?; - return Ok((id, id)); + match kind { + // The two roots are keyed by their slug alone, and so read back the + // same way. + PipelineKind::Campaign | PipelineKind::Instance => { + dotted_text(field, id)?; + return Ok((id, id)); + } + // A resolution is keyed by its subject, so its id is its subject's id + // behind a `resolution:` prefix. That is the only id a resolution ever + // has: `:resolution:` is an id no resolution can + // carry, and accepting it would name a document that cannot exist. + // `PipelineRef` takes any kind, so a resolution is reachable as a + // question's `raised_in`, a follow-up's `source` and a resolution's own + // `subject`. + PipelineKind::Resolution => { + let subject = id.strip_prefix("resolution:").ok_or_else(|| format_error(field, id))?; + let subject_kind = declared_kind(subject).ok_or_else(|| format_error(field, id))?; + return pipeline_id(field, subject, subject_kind); + } + _ => {} } let mut segments = id.split(':'); let (Some(campaign), Some(name), Some(local), None) = ( @@ -1215,6 +1246,50 @@ mod tests { ); } + /// Soul G2: a resolution's key is its subject's id behind a `resolution:` + /// prefix, so that -- and only that -- is the id a resolution has. The + /// reader demanded `:resolution:` instead, which is + /// disjoint from it in both directions: the id it accepted names a document + /// that cannot exist, and the id every resolution does have was refused, so + /// nothing could reference one. `PipelineRef` takes any kind, so this is + /// reachable; a follow-up sourced from a resolution is the live path. + #[test] + fn a_resolution_is_named_by_the_key_it_has() { + let sourced = |id: &str| { + let PipelineDocument::FollowUp(mut follow_up) = samples().remove(8).0 else { unreachable!() }; + follow_up.source = PipelineRef { kind: PipelineKind::Resolution, id: Short(id.into()) }; + PipelineDocument::FollowUp(follow_up).validate() + }; + let key = pipeline_key(&PipelineDocument::Resolution(resolution_sample())).expect("the sample keys"); + assert_eq!(key, format!("resolution:{CAMPAIGN}:question:Q1")); + assert_eq!(sourced(&key), Ok(()), "a resolution is named by the key it has"); + + for refused in [ + // The shape the reader used to demand. No resolution carries it. + format!("{CAMPAIGN}:resolution:R8"), + format!("{CAMPAIGN}:resolution:{CAMPAIGN}:question:Q1"), + // The prefix and no subject, a subject of no kind, a subject whose + // kind segment is not the one its own id declares, and trailing junk. + "resolution:".into(), + format!("resolution:{CAMPAIGN}:nonsense:Q1"), + format!("resolution:{CAMPAIGN}:question:Q1:junk"), + format!("resolution:{CAMPAIGN}:question:.."), + ] { + assert!( + matches!(sourced(&refused), Err(PipelineRefusal::InvalidFormat { .. })), + "{refused:?} is not an id any resolution has" + ); + } + + // A root subject reads back too: a resolution of a campaign is keyed by + // that campaign's own id, which carries no kind segment. + let mut of_campaign = resolution_sample(); + of_campaign.subject = PipelineRef { kind: PipelineKind::Campaign, id: s(CAMPAIGN) }; + let root_key = pipeline_key(&PipelineDocument::Resolution(of_campaign)).expect("a root subject keys"); + assert_eq!(root_key, format!("resolution:{CAMPAIGN}")); + assert_eq!(sourced(&root_key), Ok(())); + } + /// 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 @@ -1256,26 +1331,18 @@ mod tests { /// the key writer and left out of the id reader, and nothing noticed, /// because the key tests assert strings and never read one back: an /// instance keys to its slug and then fails to parse as an instance id, so - /// no `PipelineRef` and no resolution could ever name one. A resolution is - /// the one kind whose key is not an id of itself: it is its subject's id - /// behind a `resolution:` prefix, so the subject is what reads back. + /// no `PipelineRef` and no resolution could ever name one. Every kind now + /// reads back with no kind excused, a resolution included. #[test] fn keys_read_back_as_ids_of_their_kind() -> Result<()> { for (document, expected) in samples() { let kind = document.kind(); let key = pipeline_key(&document)?; assert_eq!(key, expected, "{kind:?} keys to its sample's key"); - let (id, id_kind) = match &document { - PipelineDocument::Resolution(value) => ( - key.strip_prefix("resolution:").expect("a resolution key names its subject"), - value.subject.kind, - ), - _ => (key.as_str(), kind), - }; assert_eq!( - pipeline_id("read_back", id, id_kind).map(|_| ()), + pipeline_id("read_back", &key, kind).map(|_| ()), Ok(()), - "{id_kind:?} key {id} does not read back as an id of its kind" + "{kind:?} key {key} does not read back as an id of its kind" ); }