From 3d9965983f4dc1695eebd2f8e23b725a3f767bf0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 04:55:04 +0200 Subject: [PATCH] huginn-mind: provenance follows the leaf's Title rule and covers the tool refuse_provenance validated only agent and session, and only for blank and a handful of line breaks. It now covers agent, session and tool with the leaf's Title character rule (an alphanumeric character, no control or line-break character, no bidi control) inside Short's byte bound. The leaf keeps that rule private, so a differential test holds the copy to a campaign Title. Restores the stage doc comment that drifted above SHORT_MAX. Multibyte bound test kills a chars-count mutant. Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/admission.rs | 136 ++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 38 deletions(-) diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 0288ad0..6443e94 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -172,31 +172,40 @@ impl Mind { } } -/// A3 through the leaf, then A5. /// The bound `epiphany_pipeline::Short` declares, in UTF-8 bytes. The leaf /// exposes no validator for it, so `short_bound_is_the_leafs_own` pins this to /// the `maxLength` its schema publishes. const SHORT_MAX: usize = 200; /// Who admitted a batch is the receipt's only record of it, so the mind checks -/// it here and every writer, whatever its transport, gets the check: the agent -/// and the session are within `Short`'s bound, hold something besides -/// whitespace, and sit on one line. Refused as the leaf's own refusals, since -/// the fields are the leaf's own type. +/// it here and every writer, whatever its transport, gets the check: the agent, +/// the session and the tool follow the leaf's `Title` rule (one alphanumeric +/// character, no control or line-break character, no bidi control) within +/// `Short`'s bound. The leaf keeps that rule private, so +/// `provenance_text_is_the_leafs_title_rule` pins this to it. Refused as the +/// leaf's own refusals, since the fields are the leaf's own type. fn refuse_provenance(provenance: &PipelineProvenance) -> Result<(), MindRefusal> { - for (field, value) in [("provenance.agent", &provenance.agent), ("provenance.session", &provenance.session)] { + let bidi = |c: char| { + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') + }; + let control = |c: char| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'); + for (field, value) in [ + ("provenance.agent", &provenance.agent), + ("provenance.session", &provenance.session), + ("provenance.tool", &provenance.tool), + ] { let text = value.0.as_str(); + if !text.chars().any(char::is_alphanumeric) || text.contains(control) || text.contains(bidi) { + return Err(PipelineRefusal::InvalidFormat { field: field.into(), value: text.into() }.into()); + } if text.len() > SHORT_MAX { return Err(PipelineRefusal::FieldBound { field: field.into(), limit: SHORT_MAX as u32, actual: text.len() as u32 }.into()); } - let line_break = |c: char| matches!(c, '\n' | '\r' | '\u{0B}' | '\u{0C}' | '\u{85}' | '\u{2028}' | '\u{2029}'); - if text.trim().is_empty() || text.contains(line_break) { - return Err(PipelineRefusal::InvalidFormat { field: field.into(), value: text.into() }.into()); - } } Ok(()) } +/// A3 through the leaf, then A5. fn stage(envelope: CultCacheEnvelope, mind: &Slug) -> Result { validate_pipeline_write_envelope(&envelope)?; let document = PipelineDocument::decode(&envelope)?; @@ -720,8 +729,12 @@ mod tests { } fn admit_attributed(agent: &str, session: &str) -> (Mind, PipelineAdmissionOutcome) { + admit_signed(agent, session, "admit") + } + + fn admit_signed(agent: &str, session: &str, tool: &str) -> (Mind, PipelineAdmissionOutcome) { let mut mind = opened(MemoryStore::new(), INSTANCE); - let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: s(agent), session: s(session), tool: s("admit") }; + let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: s(agent), session: s(session), tool: s(tool) }; let batch = PipelineAdmissionBatch { instance: slug(INSTANCE), provenance, documents: vec![instance(INSTANCE), stewardship(INSTANCE, REPO)] }; let outcome = mind.admit(batch, now()); (mind, outcome) @@ -731,45 +744,92 @@ mod tests { /// a blank, multi-line or oversized attribution, and a refused batch leaves /// nothing behind. #[test] - fn provenance_names_an_agent_and_a_session_on_one_line_within_short() { + fn provenance_names_an_agent_a_session_and_a_tool_on_one_clean_line_within_short() { let over = "x".repeat(SHORT_MAX + 1); let exact = "y".repeat(SHORT_MAX); - for (field, agent, session) in [ - ("provenance.agent", "", "session-1"), - ("provenance.agent", " \t ", "session-1"), - ("provenance.session", "claude", ""), - ("provenance.session", "claude", " "), - ("provenance.session", "claude", "\u{2003}"), - ("provenance.session", "claude", "line\nbreak"), - ("provenance.session", "claude", "line\rbreak"), - ("provenance.session", "claude", "a\u{2028}b"), - ("provenance.agent", "two\nlines", "session-1"), + for bad in [ + "", + " \t ", + "\u{2003}", + "\u{200B}", + "line\nbreak", + "line\rbreak", + "a\u{0B}b", + "a\u{0C}b", + "a\u{85}b", + "a\u{2028}b", + "a\u{2029}b", + "a\0b", + "a\u{1B}[31mb", + "a\u{202E}b", + "a\u{2066}b", + "a\u{200F}b", ] { - let (mind, outcome) = admit_attributed(agent, session); - let refused = refusal(outcome); - assert!( - matches!(&refused, MindRefusal::Document(PipelineRefusal::InvalidFormat { field: named, .. }) if named == field), - "{agent:?}/{session:?}: {refused:?}" - ); - assert!(mind.is_empty(), "{agent:?}/{session:?}: a refused batch wrote"); + for field in ["provenance.agent", "provenance.session", "provenance.tool"] { + let (mind, outcome) = match field { + "provenance.agent" => admit_signed(bad, "session-1", "admit"), + "provenance.session" => admit_signed("claude", bad, "admit"), + _ => admit_signed("claude", "session-1", bad), + }; + let refused = refusal(outcome); + assert!( + matches!(&refused, MindRefusal::Document(PipelineRefusal::InvalidFormat { field: named, .. }) if named == field), + "{field}={bad:?}: {refused:?}" + ); + assert!(mind.is_empty(), "{field}={bad:?}: a refused batch wrote"); + } } - let (mind, outcome) = admit_attributed(&over, "session-1"); + for (field, outcome) in [ + ("provenance.agent", admit_signed(&over, "session-1", "admit")), + ("provenance.session", admit_signed("claude", &over, "admit")), + ("provenance.tool", admit_signed("claude", "session-1", &over)), + ] { + let (mind, outcome) = outcome; + assert_eq!( + refusal(outcome), + MindRefusal::Document(PipelineRefusal::FieldBound { field: field.into(), limit: 200, actual: 201 }) + ); + assert!(mind.is_empty()); + } + // The bound is in bytes, not characters: 101 two-byte characters are 202 bytes. + let (_, outcome) = admit_attributed(&"é".repeat(101), "session-1"); assert_eq!( refusal(outcome), - MindRefusal::Document(PipelineRefusal::FieldBound { field: "provenance.agent".into(), limit: 200, actual: 201 }) - ); - assert!(mind.is_empty()); - let (_, outcome) = admit_attributed("claude", &over); - assert_eq!( - refusal(outcome), - MindRefusal::Document(PipelineRefusal::FieldBound { field: "provenance.session".into(), limit: 200, actual: 201 }) + MindRefusal::Document(PipelineRefusal::FieldBound { field: "provenance.agent".into(), limit: 200, actual: 202 }) ); + let (_, outcome) = admit_signed("claude", "session-1", &"é".repeat(101)); + assert!(matches!(refusal(outcome), MindRefusal::Document(PipelineRefusal::FieldBound { actual: 202, .. }))); + let (_, outcome) = admit_attributed(&"é".repeat(100), "session-1"); + committed(outcome); // The bound is inclusive and a name with inner spaces is a name. - let (mind, outcome) = admit_attributed(&exact, "session 7"); + let (mind, outcome) = admit_signed(&exact, "session 7", "admit tool"); committed(outcome); let receipts = mind.receipts().unwrap(); assert_eq!(receipts[0].provenance.agent, s(&exact)); assert_eq!(receipts[0].provenance.session, s("session 7")); + assert_eq!(receipts[0].provenance.tool, s("admit tool")); + } + + /// The leaf's `Title` rule is private, so this holds the provenance rule to + /// it: the two accept and refuse the same text. + #[test] + fn provenance_text_is_the_leafs_title_rule() { + let leaf_accepts = |text: &str| { + let D::Campaign(mut campaign) = campaign(&[REPO]) else { unreachable!() }; + campaign.title = epiphany_pipeline::Title(text.into()); + D::Campaign(campaign).validate().is_ok() + }; + let mine_accepts = |text: &str| { + let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: s(text), session: s("s"), tool: s("t") }; + refuse_provenance(&provenance).is_ok() + }; + for text in [ + "claude", "session 7", "", " ", "\t", "\u{200B}", "\u{2003}", "!!!", "a\nb", "a\u{0B}b", "a\u{85}b", "a\u{2028}b", + "a\0b", "a\u{7F}b", "a\u{202E}b", "a\u{061C}b", "a\u{2069}b", "a\u{200D}b", "é", "日本", "a\u{FEFF}b", + &"x".repeat(200), &"x".repeat(201), &"é".repeat(100), &"é".repeat(101), + ] { + assert_eq!(mine_accepts(text), leaf_accepts(text), "{text:?}"); + } } #[test]