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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 04:55:04 +02:00
commit 3d9965983f

View file

@ -172,31 +172,40 @@ impl<S: MindStore> Mind<S> {
}
}
/// 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<Staged, MindRefusal> {
validate_pipeline_write_envelope(&envelope)?;
let document = PipelineDocument::decode(&envelope)?;
@ -720,8 +729,12 @@ mod tests {
}
fn admit_attributed(agent: &str, session: &str) -> (Mind<MemoryStore>, PipelineAdmissionOutcome) {
admit_signed(agent, session, "admit")
}
fn admit_signed(agent: &str, session: &str, tool: &str) -> (Mind<MemoryStore>, 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]