From a26bce249dead9394db97b98f486b301257f9df8 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 12:26:47 +0000 Subject: [PATCH] persona: a rule's words are a clause of her own line outside quotation, its campaign is checked before any effect, a co-stewarded repo is not a rule, and the repo fault names her repos; tests pin the survivors Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-pipeline/src/lib.rs | 3 +- crates/huginn-mind/src/index.rs | 9 +++ crates/mind-body/src/persona.rs | 91 ++++++++++++++++------- crates/mind-body/src/persona/tests.rs | 82 ++++++++++++++++++-- crates/mind-body/tests/public_surface.txt | 2 +- 5 files changed, 152 insertions(+), 35 deletions(-) diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index 4541ae7..08b0bf2 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -2008,8 +2008,9 @@ mod tests { refused(|a| if let RulingAuthority::Steward { steward, .. } = a { steward.0 = String::new() }), refused(|a| if let RulingAuthority::Steward { repo, .. } = a { repo.0 = "nope".into() }), refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = "w".repeat(4001) }), + refused(|a| if let RulingAuthority::Steward { event, .. } = a { *event = Some("e".repeat(201).as_str().into()) }), ]; - for (fault, field) in fields.iter().zip(["authority.words", "authority.steward", "authority.repo", "authority.words"]) { + for (fault, field) in fields.iter().zip(["authority.words", "authority.steward", "authority.repo", "authority.words", "authority.event"]) { assert!(fault.contains(field), "{fault}"); } } diff --git a/crates/huginn-mind/src/index.rs b/crates/huginn-mind/src/index.rs index 2d83c5a..13ccff1 100644 --- a/crates/huginn-mind/src/index.rs +++ b/crates/huginn-mind/src/index.rs @@ -250,6 +250,15 @@ More documents."); assert_eq!(INDEX_TEXT_VERSION, 1); } + /// A Steward ruling is found by the words the peer said as well as by its title and ruling. + #[test] + fn a_steward_rulings_words_are_indexed() { + let mut peer = ruling("R1"); + peer.operator_quote = None; + peer.authority = RulingAuthority::Steward { steward: s("@heimdall:chat"), repo: eureka_pipeline::OrgRepo(REPO.into()), words: "refuse leading zeros".into(), event: None }; + assert!(index_text(&D::Ruling(peer)).unwrap().lines().any(|line| line == "refuse leading zeros")); + } + fn texts(mind: &Mind) -> BTreeMap { mind.index_entries(None).unwrap().into_iter().map(|entry| (entry.id.id.0, entry.text)).collect() } diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index a835fce..f597215 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -165,8 +165,8 @@ const OPEN_DIRECT_TEXT_MAX_BYTES: usize = 4000; const RULE_BY_OWN_FAULT: &str = "rule.by: your own decision is not a rule"; const RULE_BY_OPERATOR_FAULT: &str = "rule.by: rule records a Mind's decision in her stewardship"; const RULE_BY_UNKNOWN_FAULT: &str = "rule.by: not a Mind of the swarm"; -const RULE_WORDS_FAULT: &str = "rule.words: copy them exactly from one of her lines in this session"; -const RULE_REPO_FAULT: &str = "rule.repo: not a repo the Mind named stewards"; +const RULE_WORDS_FAULT: &str = "rule.words: copy them exactly, at least 12 bytes, from one of her lines in this session, outside any quotation"; +const RULE_REPO_OWN_FAULT: &str = "rule.repo: inside your own stewardship, where a Mind's word is a respected contributor's: weigh it and decide, it is not a rule"; const RULE_REPO_UNREAD_FAULT: &str = "rule.repo: no stewardship of the Mind named has been read yet"; const RULE_BOUNDS_FAULT: &str = "rule: more entries than one reply may carry, or a field blank or over its length bound"; const RULE_CAMPAIGN_FAULT: &str = "rule.campaign: not a campaign in force"; @@ -176,6 +176,8 @@ const REMEMBER_MAX: usize = 8; const SETTLE_MAX: usize = 8; /// The most rulings of other Minds one reply records. const RULE_MAX: usize = 4; +/// The fewest bytes of her line `words` must copy: a clause, not a letter. +const RULE_WORDS_MIN_BYTES: usize = 12; const RULE_TITLE_MAX_BYTES: usize = 200; const RULE_TEXT_MAX_BYTES: usize = 4000; /// A memory's text in bytes, the people it is about, and a settle's reason in characters. @@ -750,7 +752,7 @@ pub struct Organs<'a> { } /// Why a start did not complete. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Failure { Held(HeldReason), Chat, @@ -766,6 +768,8 @@ pub enum Failure { Undecodable, /// A reply that parsed and broke the bound of the field this names: the organ's words, never the reply's. OutOfBounds(&'static str), + /// A rule named a repo the decider does not steward: the organ's own words, naming what the heartbeat's table says she stewards. + RuleRepo(String), RoomNotAllowed, Send, SelfRun, @@ -786,6 +790,7 @@ impl fmt::Display for Failure { Self::Session => f.write_str("your session could not be started or ended without answering"), Self::Undecodable => f.write_str("the reply is not a persona reply"), Self::OutOfBounds(field) => f.write_str(field), + Self::RuleRepo(told) => f.write_str(told), Self::RoomNotAllowed => f.write_str("the reply speaks outside your rooms"), Self::Send => f.write_str("a send was refused or failed"), Self::SelfRun => f.write_str("the Self run could not be started"), @@ -856,7 +861,7 @@ struct Progress { } /// A refused batch as the start remembers it. The mouth still holds its deliveries, so this needs only their lowest seq. -#[derive(Clone, Copy)] +#[derive(Clone)] struct Refused { lowest: u64, why: Failure, @@ -966,7 +971,7 @@ pub fn carry(organs: &Organs, control: &impl ControlSource, swarm: &impl Control break; } } - close_run(organs, &run, progress.answered, progress.last_intent.as_deref(), progress.failure, noted.failures.get())?; + close_run(organs, &run, progress.answered, progress.last_intent.as_deref(), progress.failure.clone(), noted.failures.get())?; Ok(Outcome::Served { run, answered: progress.answered, failure: progress.failure, self_run: progress.self_run }) } @@ -1663,7 +1668,7 @@ impl SessionInbox for PersonaInbox<'_, C, S> fn start_line(&mut self) -> Result { self.read_replies(); let batch = std::mem::take(&mut self.start); - let (note, refusals) = self.progress.refused.map_or((None, 0), |Refused { why, refusals, .. }| (Some(refused_note(why)), refusals)); + let (note, refusals) = self.progress.refused.clone().map_or((None, 0), |Refused { why, refusals, .. }| (Some(refused_note(why)), refusals)); let line = self.whole_line(&batch, note.as_deref())?; Ok(self.write(batch, line, refusals)) } @@ -1828,7 +1833,7 @@ impl SessionInbox for PersonaInbox<'_, C, S> // Her final answer lands after she took in every line, so a revision still waiting is answered by it and its note, that a send was not sent, would be false. let spent = if final_reply { self.revision.take().map(|revision| revision.batch) } else { None }; self.answered_through = self.answered_through.max(batch.iter().chain(spent.iter().flatten()).map(|delivery| delivery.seq).max().unwrap_or(0)); - if self.progress.refused.is_some_and(|refused| batch.iter().any(|delivery| delivery.seq == refused.lowest)) { + if self.progress.refused.as_ref().is_some_and(|refused| batch.iter().any(|delivery| delivery.seq == refused.lowest)) { self.progress.refused = None; } let unanswered = self @@ -1898,9 +1903,10 @@ impl SessionInbox for PersonaInbox<'_, C, S> merged.dedup_by_key(|delivery| delivery.seq); // A line in flight may hold a batch at a higher count than this result's, so the start's record only rises. A refusal always has a batch: the result that carries none is a flush alone, and a flush is written alone only when no refused batch waits, so one in flight has its own result first, whose refusal puts it back in the retell this one merges. let held = self.progress.refused.take(); + let held = held.as_ref(); self.progress.refused = merged .first() - .map(|first| Refused { lowest: held.map_or(first.seq, |held| held.lowest.min(first.seq)), why, refusals: held.map_or(most, |held| held.refusals.max(most)) }); + .map(|first| Refused { lowest: held.map_or(first.seq, |held| held.lowest.min(first.seq)), why: why.clone(), refusals: held.map_or(most, |held| held.refusals.max(most)) }); self.retell = Some(Retell { batch: merged, why, refusals: most, revision }); Ok((true, false)) } @@ -1974,6 +1980,13 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch Err(why) => return refuse(why), }; + // A campaign closed since the schema was fixed refuses the reply before any say or open has gone out, so the retell repeats no effect. + if !reply.rule.is_empty() { + let in_force = campaigns_in_force(organs.mind)?; + if reply.rule.iter().any(|rule| !in_force.iter().any(|(slug, _)| *slug == rule.campaign)) { + return refuse(Failure::OutOfBounds(RULE_CAMPAIGN_FAULT)); + } + } // Request ids name the batch of deliveries and the say, so a repeat is the same send. let batch_id = match (batch.iter().map(|delivery| delivery.seq).min(), batch.iter().map(|delivery| delivery.seq).max()) { (Some(first), Some(last)) => format!("s{first}-{last}"), @@ -2047,12 +2060,6 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch // The one origin of this turn: its memories and its brief record the same conversation. let origin = origin_of(config, batch, &inbox.origin); let memories = build_memories(&instance, run, turn, &reply, &origin); - if !reply.rule.is_empty() { - let in_force = campaigns_in_force(organs.mind)?; - if reply.rule.iter().any(|rule| !in_force.iter().any(|(slug, _)| *slug == rule.campaign)) { - return refuse(Failure::OutOfBounds(RULE_CAMPAIGN_FAULT)); - } - } let rulings = build_rulings(run, turn, &reply, &inbox.seen, date(now)); for rule in &reply.rule { outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("rule {} {}: {}", rule.by, rule.repo, rule.title) }); @@ -2220,36 +2227,66 @@ fn memory_fault(reply: &PersonaReply, people: &[String], open: &[String]) -> Opt /// The field a rule breaks, if any: it records a peer Mind's decision, so `by` is a Mind of the table (neither herself nor the /// operator), `words` are the Mind's own, copied from one of her lines this session showed, and `repo` is one the table says she stewards. /// What the words mean, and whether they fall inside that repo, is her reading; the organ checks the line and the stewardship. -fn rule_fault(reply: &PersonaReply, shown: &Shown, addressing: &[Addressee], config: &Config) -> Option<&'static str> { +fn rule_fault(reply: &PersonaReply, shown: &Shown, addressing: &[Addressee], config: &Config) -> Option { + use Failure::OutOfBounds as Out; if reply.rule.len() > RULE_MAX { - return Some(RULE_BOUNDS_FAULT); + return Some(Out(RULE_BOUNDS_FAULT)); } for rule in &reply.rule { let text_ok = |text: &str, max: usize| (1..=max).contains(&text.len()) && !text.trim().is_empty(); let title_ok = text_ok(&rule.title, RULE_TITLE_MAX_BYTES) && rule.title.chars().any(char::is_alphanumeric) && !rule.title.chars().any(char::is_control); if rule.by.len() > SHORT_MAX_BYTES || !title_ok || !text_ok(&rule.ruling, RULE_TEXT_MAX_BYTES) || !text_ok(&rule.words, RULE_TEXT_MAX_BYTES) || !repo_in_bounds(&rule.repo) || !campaign_in_bounds(&rule.campaign) { - return Some(RULE_BOUNDS_FAULT); + return Some(Out(RULE_BOUNDS_FAULT)); } match sender_role(config, addressing, &rule.by) { - Sender::Persona => return Some(RULE_BY_OWN_FAULT), - Sender::Operator => return Some(RULE_BY_OPERATOR_FAULT), - Sender::Member => return Some(RULE_BY_UNKNOWN_FAULT), + Sender::Persona => return Some(Out(RULE_BY_OWN_FAULT)), + Sender::Operator => return Some(Out(RULE_BY_OPERATOR_FAULT)), + Sender::Member => return Some(Out(RULE_BY_UNKNOWN_FAULT)), Sender::Mind => {} } - if !shown.said.iter().any(|said| said.sender == rule.by && said.text.contains(&rule.words)) { - return Some(RULE_WORDS_FAULT); + if !shown.said.iter().any(|said| said.sender == rule.by && in_her_own_voice(&said.text, &rule.words)) { + return Some(Out(RULE_WORDS_FAULT)); } let stewarded = addressing.iter().find(|row| row.matrix_id() == rule.by).map(Addressee::repos).unwrap_or_default(); if stewarded.is_empty() { - return Some(RULE_REPO_UNREAD_FAULT); + return Some(Out(RULE_REPO_UNREAD_FAULT)); } - if !stewarded.contains(&OrgRepo(rule.repo.clone())) { - return Some(RULE_REPO_FAULT); + let repo = OrgRepo(rule.repo.clone()); + if !stewarded.contains(&repo) { + let list = stewarded.iter().map(|repo| repo.0.as_str()).collect::>().join(", "); + return Some(Failure::RuleRepo(format!("rule.repo: {} stewards {list}", rule.by))); + } + if addressing.iter().find(|row| row.matrix_id() == config.own_sender).is_some_and(|own| own.repos().contains(&repo)) { + return Some(Out(RULE_REPO_OWN_FAULT)); } } None } +/// Whether `words` are her own: at least `RULE_WORDS_MIN_BYTES`, copied from `line` on whole-word boundaries and outside any quotation +/// (straight or curly), so a letter of a greeting, or what she was quoting (the operator's words included), is not hers. +fn in_her_own_voice(line: &str, words: &str) -> bool { + if words.trim().len() < RULE_WORDS_MIN_BYTES { + return false; + } + line.match_indices(words).any(|(at, found)| { + let before = line[..at].chars().next_back(); + let after = line[at + found.len()..].chars().next(); + let edge = |c: Option| c.is_none_or(|c| !c.is_alphanumeric()); + let (mut straight, mut curly) = (false, 0i32); + for c in line[..at].chars() { + match c { + '"' => straight = !straight, + '\u{201C}' => curly += 1, + '\u{201D}' => curly = (curly - 1).max(0), + _ => {} + } + } + let quoted = straight || curly > 0 || found.contains(['"', '\u{201C}', '\u{201D}']); + edge(before) && edge(after) && !quoted + }) +} + /// Whether an open is hers to ask: from a public conversation, of a person this session showed who is neither a Mind nor herself, with /// an invitation the mouth takes (not blank, within its byte bound). fn open_in_bounds(ask: &OpenDirect, conversation: &Conversation, config: &Config, addressing: &[Addressee], people: &[String]) -> bool { @@ -2277,8 +2314,8 @@ fn decode_reply(text: &str, rooms: &[String], seen: &Shown, conversation: &Conve if let Some(field) = memory_fault(&reply, &seen.people, &seen.open) { return Err(Failure::OutOfBounds(field)); } - if let Some(field) = rule_fault(&reply, seen, addressing, config) { - return Err(Failure::OutOfBounds(field)); + if let Some(failure) = rule_fault(&reply, seen, addressing, config) { + return Err(failure); } if reply.open_direct.as_ref().is_some_and(|ask| !open_in_bounds(ask, conversation, config, addressing, &seen.people)) { return Err(Failure::OutOfBounds(OPEN_DIRECT_FAULT)); diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index a924758..4f64af4 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -1783,10 +1783,10 @@ fn outcomes_and_failures_read_as_one_line_each_without_values() { Failure::Memory, Failure::Ack, ]; - for failure in all { + for failure in all.clone() { assert!(!failure.to_string().is_empty()); } - let texts: std::collections::BTreeSet<_> = all.map(|failure| failure.to_string()).into(); + let texts: std::collections::BTreeSet<_> = all.iter().map(|failure| failure.to_string()).collect(); assert_eq!(texts.len(), all.len(), "each failure reads differently"); } @@ -6387,7 +6387,7 @@ fn a_stale_refusal_is_her_next_input_and_costs_no_refusal() { assert_eq!(sent[1].basis, None, "one revision, then her choice stands, though the room moved again"); assert!(sent[1].request_id.contains("-r1-"), "{}", sent[1].request_id); assert!(rig.chat.landed.borrow().values().any(|(_, text)| text == "then the cellar is mine")); - assert_eq!((inbox.progress.answered, inbox.progress.failure), (1, None)); + assert_eq!((inbox.progress.answered, inbox.progress.failure.clone()), (1, None)); assert_eq!(rig.chat.acked.borrow().as_slice(), &[2], "the batch is acked once the revision is answered"); }); } @@ -6444,7 +6444,7 @@ fn eight_stale_refusals_in_a_row_in_different_batches_never_end_the_session() { inbox.tell(false).unwrap(); inbox.echoed(); inbox.result(&reply_result(&says(&[("swarm", "revised")])), true, false).unwrap(); - assert_eq!((inbox.progress.failure, inbox.progress.refused.is_none()), (None, true), "round {round}"); + assert_eq!((inbox.progress.failure.clone(), inbox.progress.refused.is_none()), (None, true), "round {round}"); } assert_eq!((inbox.progress.answered, inbox.progress.turn), (8, 16)); }); @@ -6579,7 +6579,7 @@ fn an_undecodable_revision_stays_final() { inbox.tell(false).unwrap(); inbox.echoed(); inbox.result(&json!({"type": "result", "is_error": false, "structured_output": {"nonsense": 1}}).to_string(), true, false).unwrap(); - assert_eq!(inbox.progress.refused.map(|refused| refused.refusals), Some(1), "an undecodable reply is a refusal, as ever"); + assert_eq!(inbox.progress.refused.as_ref().map(|refused| refused.refusals), Some(1), "an undecodable reply is a refusal, as ever"); assert!(inbox.poll().unwrap().pending); inbox.tell(false).unwrap(); inbox.echoed(); @@ -8507,7 +8507,7 @@ fn a_rule_from_a_minds_own_line_inside_her_stewardship_is_admitted_as_steward_wi fn a_rule_naming_a_repo_the_decider_does_not_steward_is_told_back_and_admits_nothing() { let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "refuse leading zeros")], &thrice(reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/CultLib")))); *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); - assert_eq!(failure_of(rig.go()), Some(Failure::OutOfBounds(RULE_REPO_FAULT))); + assert_eq!(failure_of(rig.go()), Some(Failure::RuleRepo("rule.repo: @libby:chat stewards GameCult/Bifrost".into()))); assert!(rulings_in(&rig.mind).is_empty()); } @@ -8544,3 +8544,73 @@ fn words_found_only_in_an_earlier_line_carry_no_event() { let both = Shown { said: [libby_said("refuse leading zeros", Some("$e9")).said, libby_said("refuse leading zeros", None).said].concat(), ..Shown::default() }; assert_eq!(event_of(&both), Some(Short("$e9".into())), "a delivery that held the words names its event even when an earlier line repeats them"); } + + +#[test] +fn rule_words_must_be_a_clause_of_her_own_line_outside_any_quotation() { + let c = config(); + let table = table_with_libby("GameCult/Bifrost"); + let decode = |line: &str, words: &str| decode_reply(&reply_with_rule(rule_json("@libby:chat", words, "GameCult/Bifrost")), &c.rooms, &libby_said(line, Some("$e1")), &Conversation::Public, &c, &table).err(); + let fault = Some(Failure::OutOfBounds(RULE_WORDS_FAULT)); + assert_eq!(decode("refuse leading zeros", "refuse leading zeros"), None, "the whole line"); + assert_eq!(decode("he said \"no\" and we refuse leading zeros", "refuse leading zeros"), None, "after a closed quotation"); + assert_eq!(decode("hello everyone, nice weather today", "e"), fault, "a letter of a greeting"); + assert_eq!(decode("refuse leading zeros", "refuse"), fault, "under the floor"); + assert_eq!(decode("xrefuse leading zerosy", "refuse leading zeros"), fault, "inside words"); + assert_eq!(decode("The operator told me: \"refuse leading zeros\"", "refuse leading zeros"), fault, "the operator's words in straight quotes"); + assert_eq!(decode("The operator told me: \u{201C}refuse leading zeros\u{201D}", "refuse leading zeros"), fault, "in curly quotes"); + assert_eq!(decode("she said \"x\"; refuse leading zeros", "x\"; refuse leading"), fault, "words that carry a quotation mark"); +} + +#[test] +fn a_rule_inside_the_followers_own_stewardship_is_not_a_rule_even_when_the_decider_co_stewards_it() { + let c = config(); + let mut table = table_with_libby("GameCult/Thing"); + table[0] = own_addressee(&c).with_stewardships([], [OrgRepo("GameCult/Thing".into())]); + let said = libby_said("we refuse leading zeros", Some("$e1")); + let decode = |repo: &str, table: &[Addressee]| decode_reply(&reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", repo)), &c.rooms, &said, &Conversation::Public, &c, table).err(); + assert_eq!(decode("GameCult/Thing", &table), Some(Failure::OutOfBounds(RULE_REPO_OWN_FAULT))); + assert_eq!(decode("GameCult/Thing", &table_with_libby("GameCult/Thing")), None, "a repo she does not steward herself stands"); +} + +#[test] +fn a_rule_whose_campaign_is_not_in_force_is_refused_before_any_say_goes_out() { + let mut rule = rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"); + rule["campaign"] = json!("closed-since"); + let reply = json!({ "intent": "Record it", "say": [{ "room": "swarm", "text": "noted" }], "open_direct": null, "start_self_run": null, "remember": [], "rule": [rule], "settle": [], "urgency_permille": null }).to_string(); + let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "we refuse leading zeros")], &thrice(reply)); + *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); + assert_eq!(failure_of(rig.go()), Some(Failure::OutOfBounds(RULE_CAMPAIGN_FAULT))); + assert!(rig.sent_texts().is_empty(), "no say goes out under a refused reply: {:?}", rig.sent_texts()); + assert!(rulings_in(&rig.mind).is_empty()); +} + +#[test] +fn one_reply_records_at_most_four_rules() { + let c = config(); + let table = table_with_libby("GameCult/Bifrost"); + let said = libby_said("we refuse leading zeros", Some("$e1")); + let many = |n: usize| { + let rules: Vec<_> = (0..n).map(|_| rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost")).collect(); + json!({ "intent": "x", "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": rules, "settle": [], "urgency_permille": null }).to_string() + }; + assert!(decode_reply(&many(4), &c.rooms, &said, &Conversation::Public, &c, &table).is_ok()); + assert_eq!(decode_reply(&many(5), &c.rooms, &said, &Conversation::Public, &c, &table), Err(Failure::OutOfBounds(RULE_BOUNDS_FAULT))); +} + +#[test] +fn the_event_is_the_newest_delivery_and_said_holds_every_line_a_session_showed_in_print_order() { + let run = PipelineRef { kind: PipelineKind::Run, id: format!("{INSTANCE}:run:mind-1").as_str().into() }; + let reply: PersonaReply = serde_json::from_str(&reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"))).unwrap(); + let said = |event: &str| Said { sender: "@libby:chat".into(), text: "refuse leading zeros".into(), event: Some(event.into()) }; + let shown = Shown { said: vec![said("$old"), said("$new")], ..Shown::default() }; + let PipelineDocument::Ruling(ruling) = build_rulings(&run, 1, &reply, &shown, Date("2026-11-11".into())).remove(0) else { panic!() }; + assert!(matches!(ruling.authority, RulingAuthority::Steward { event: Some(Short(ref e)), .. } if e == "$new")); + + let first = delivery_of(1, "swarm", "@libby:chat", "first", "$a"); + let second = delivery_of(2, "swarm", "@guest:chat", "second", "$b"); + let context = ContextLine { sender: "@libby:chat".into(), text: "before".into(), at_unix_ms: 1 }; + let lines = said_of(&[first, second], &[vec![&context], vec![]]); + let seen: Vec<(&str, &str, Option<&str>)> = lines.iter().map(|l| (l.sender.as_str(), l.text.as_str(), l.event.as_deref())).collect(); + assert_eq!(seen, [("@libby:chat", "before", None), ("@libby:chat", "first", Some("$a")), ("@guest:chat", "second", Some("$b"))]); +} diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index b923c13..a6bc864 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -189,7 +189,7 @@ mind_body::lease_client: struct SocketLender pub-fields[] mind_body::lease_client: struct TokenFd pub-fields[] mind_body::lease_client::socket: impl Lease for Granted mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq , Serialize , Deserialize)] # [serde (rename_all = "lowercase")] pub enum SettleOutcome { Done , Declined , Wrong , } -mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Lease , Session , Undecodable , OutOfBounds (& 'static str) , RoomNotAllowed , Send , SelfRun , Memory , Ack , } +mind_body::persona: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Lease , Session , Undecodable , OutOfBounds (& 'static str) , RuleRepo (String) , RoomNotAllowed , Send , SelfRun , Memory , Ack , } mind_body::persona: # [derive (Debug , PartialEq , Eq)] pub enum Outcome { Dead (HeldReason) , NoRun , Served { run : PipelineRef , answered : u32 , failure : Option < Failure > , self_run : Option < PipelineRef > } , } mind_body::persona: const AGENT : & str mind_body::persona: const DOCTRINE_MAX_BYTES : usize