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:
parent
c2b2952749
commit
3d9965983f
1 changed files with 97 additions and 37 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue