1use super::document::*;
11use super::engine::{evaluate, evaluate_as, LoadedPolicy, MemberSignal, MessageSignal, Signals};
12use super::types::*;
13use super::engine::EvalMode;
14use super::normalize::EmojiCodes;
15use nostr_sdk::prelude::{PublicKey, ToBech32};
16use std::collections::BTreeSet;
17
18pub fn hash_policy_bytes(bytes: &[u8]) -> Hash32 {
21 use sha2::{Digest, Sha256};
22 let mut h = Sha256::new();
23 h.update(bytes);
24 Hash32(h.finalize().into())
25}
26
27fn subject_of(npub_or_hex: &str) -> Option<SubjectId> {
28 PublicKey::parse(npub_or_hex).ok().map(|p| SubjectId(p.to_bytes()))
29}
30
31pub const DEFAULTS_POLICY_ID: &str = "vector_defaults";
34
35pub const REPEAT_BURST_SECS: u64 = 30 * 60;
39
40pub fn default_policy() -> Policy {
53 Policy {
54 format: FORMAT,
55 requires: vec!["armed_by_any".into()],
58 name: "raid-detection".into(),
59 emoji_codes: vec![],
60 shields: Shields::default(),
65 window: Window { hours: 168, max_messages: 4000 },
66 exempt: Exempt::default(),
67 rules: vec![
68 Rule {
71 id: "cohort".into(),
72 matcher: Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
73 tiers: None,
74 severity: Some(Severity::Severe),
75 weight: Some(85),
76 pierces_trusted: false,
77 family: None,
78 armed_by: None,
79 exempt: Exempt::default(),
80 enforcement: Enforcement::Advisory,
81 },
82 Rule {
93 id: "burst".into(),
94 matcher: Match::JoinBurst { gap_secs: 300, min: 12 },
95 tiers: None,
96 severity: Some(Severity::Major),
97 weight: Some(40),
98 pierces_trusted: false,
99 family: None,
100 armed_by: None,
101 exempt: Exempt::default(),
102 enforcement: Enforcement::Advisory,
103 },
104 Rule {
111 id: "fresh".into(),
112 matcher: Match::TenureLt { secs: 24 * 3600 },
113 tiers: None,
114 severity: Some(Severity::Notice),
115 weight: Some(12),
122 pierces_trusted: false,
123 family: None,
124 armed_by: Some(ArmedBy {
125 rule: "cohort".into(),
126 scope: ArmScope::Subject,
127 min_subjects: None,
128 also: vec!["burst".into()],
129 }),
130 exempt: Exempt::default(),
131 enforcement: Enforcement::Advisory,
132 },
133 Rule {
134 id: "quiet".into(),
135 matcher: Match::MessagesLte { n: 2 },
136 tiers: None,
137 severity: Some(Severity::Notice),
138 weight: Some(10),
139 pierces_trusted: false,
140 family: None,
141 armed_by: Some(ArmedBy {
142 rule: "cohort".into(),
143 scope: ArmScope::Subject,
144 min_subjects: None,
145 also: vec!["burst".into()],
146 }),
147 exempt: Exempt::default(),
148 enforcement: Enforcement::Advisory,
149 },
150 ],
151 }
152}
153
154pub struct Assembled {
159 pub signals: Signals,
160 pub facts: std::collections::BTreeMap<String, MemberFacts>,
161 pub corpus: usize,
162}
163
164#[allow(clippy::type_complexity)]
165pub fn assemble(
166 community_id_hex: &str,
167 owner: &PublicKey,
168 me: Option<&PublicKey>,
169 members: &[(PublicKey, Option<u64>, bool, Vec<String>, Option<String>)],
170 now_ms: u64,
171) -> Result<Assembled, String> {
172 let rows = crate::db::community::community_policy_messages(community_id_hex, caps::WINDOW_MAX_MESSAGES)?;
173 let corpus = rows.len();
174 let mut messages: Vec<MessageSignal> = Vec::with_capacity(rows.len());
175 for m in rows {
176 let (Some(id), Some(author), Some(channel)) = (
177 crate::simd::hex::hex_to_bytes_32_checked(&m.id),
178 subject_of(&m.npub),
179 crate::simd::hex::hex_to_bytes_32_checked(&m.channel_id),
180 ) else {
181 continue;
182 };
183 messages.push(MessageSignal {
184 id: MessageId(id),
185 author,
186 channel: Hash32(channel),
187 at_ms: m.at_ms,
188 text: m.text,
189 mentions: m.mentions,
190 });
191 }
192
193 let footprints: std::collections::HashMap<String, crate::db::community::AuthorFootprint> =
194 crate::db::community::community_author_footprints(community_id_hex)
195 .unwrap_or_default()
196 .into_iter()
197 .map(|f| (f.npub.clone(), f))
198 .collect();
199 let codes = EmojiCodes::from_policy(default_policy().emoji_codes.iter());
202 let mut distinct: std::collections::HashMap<[u8; 32], BTreeSet<String>> = std::collections::HashMap::new();
203 for m in &messages {
204 let sk = super::normalize::skeleton(&m.text, &codes);
205 if !sk.is_empty() {
206 distinct.entry(m.author.0).or_default().insert(sk);
207 }
208 }
209
210 let channels: BTreeSet<[u8; 32]> = messages.iter().map(|m| m.channel.0).collect();
211 let mut member_signals = Vec::with_capacity(members.len());
212 let mut facts = std::collections::BTreeMap::new();
213 for (pk, joined, is_staff, roles, invite_label) in members {
214 let b32 = pk.to_bech32().unwrap_or_default();
215 let fp = footprints.get(&b32);
216 let first_post_ms = fp.map(|f| f.first_secs).filter(|s| *s > 0).map(|s| s.saturating_mul(1000));
217 let arrived_at_ms = arrival(*joined, first_post_ms);
218 member_signals.push(MemberSignal {
219 subject: SubjectId(pk.to_bytes()),
220 joined_at_ms: arrived_at_ms,
221 roles: roles.iter().filter_map(|r| crate::simd::hex::hex_to_bytes_32_checked(r)).map(Hash32).collect(),
222 is_staff: *is_staff,
223 lifetime_messages: fp.map(|f| f.messages).unwrap_or(0),
224 first_post_ms,
225 });
226 let oldest = arrived_at_ms;
228 facts.insert(
229 b32,
230 MemberFacts {
231 joined_at_ms: joined.unwrap_or(0),
232 invite_label: invite_label.clone(),
233 messages: fp.map(|f| f.messages).unwrap_or(0),
234 distinct: distinct.get(&pk.to_bytes()).map(|d| d.len() as u64).unwrap_or(0),
235 tenure_secs: oldest.map(|o| now_ms.saturating_sub(o) / 1000).unwrap_or(0),
236 last_secs: fp.map(|f| f.last_secs).unwrap_or(0),
237 is_owner: pk == owner,
238 is_admin: *is_staff,
239 is_me: me == Some(pk),
240 },
241 );
242 }
243
244 Ok(Assembled {
245 signals: Signals {
246 owner: SubjectId(owner.to_bytes()),
247 members: member_signals,
248 messages,
249 channels: channels.into_iter().map(Hash32).collect(),
250 relays: vec![],
251 requested_from: 0,
252 requested_to: now_ms,
253 confirmed_from: u64::MAX,
254 confirmed_to: now_ms,
255 roster_version: Hash32([0; 32]),
256 },
257 facts,
258 corpus,
259 })
260}
261
262#[derive(Debug, Clone, serde::Serialize)]
269pub struct PolicyPreview {
270 pub valid: bool,
271 pub error: Option<String>,
272 pub flagged: Vec<PreviewRow>,
274 pub shielded_matches: Vec<PreviewRow>,
279 pub messages_cited: usize,
280 pub corpus: usize,
281 pub unevaluated: Vec<String>,
283}
284
285#[derive(Debug, Clone, serde::Serialize)]
286pub struct PreviewRow {
287 pub npub: String,
288 pub score: u32,
289 pub proven: u32,
290 pub band: String,
291 pub shield: String,
292 pub reasons: Vec<String>,
293 pub tenure_days: u64,
294 pub messages: u64,
295}
296
297pub fn preview_policy(assembled: &Assembled, bytes: &str, now_ms: u64) -> PolicyPreview {
300 let doc: Policy = match serde_json::from_str(bytes) {
301 Ok(d) => d,
302 Err(e) => {
303 return PolicyPreview {
304 valid: false,
305 error: Some(format!("not valid JSON: {e}")),
306 flagged: vec![],
307 shielded_matches: vec![],
308 messages_cited: 0,
309 corpus: assembled.corpus,
310 unevaluated: vec![],
311 }
312 }
313 };
314 if let Err(reason) = doc.validate() {
315 return PolicyPreview {
316 valid: false,
317 error: Some(format!("{reason:?}")),
318 flagged: vec![],
319 shielded_matches: vec![],
320 messages_cited: 0,
321 corpus: assembled.corpus,
322 unevaluated: vec![],
323 };
324 }
325
326 let lp = LoadedPolicy { hash: hash_policy_bytes(bytes.as_bytes()), policy: doc, activated_at: None };
327 let report = evaluate_as(&assembled.signals, std::slice::from_ref(&lp), &[], now_ms, EvalMode::Admin);
328 let Some(pr) = report.policies.first() else {
329 return PolicyPreview {
330 valid: true,
331 error: None,
332 flagged: vec![],
333 shielded_matches: vec![],
334 messages_cited: 0,
335 corpus: assembled.corpus,
336 unevaluated: vec![],
337 };
338 };
339
340 let console = console_report(&report, &assembled.facts, now_ms / 1000);
341 let rows_by_npub: std::collections::BTreeMap<String, serde_json::Value> = console["members"]
342 .as_array()
343 .map(|a| {
344 a.iter()
345 .filter_map(|m| m["npub"].as_str().map(|n| (n.to_string(), m.clone())))
346 .collect()
347 })
348 .unwrap_or_default();
349
350 let mut flagged: Vec<PreviewRow> = Vec::new();
351 for s in &pr.subjects {
352 if s.convictions.iter().all(|c| c.suppressed) {
353 continue;
354 }
355 let Some(npub) = PublicKey::from_slice(&s.subject.0).ok().and_then(|p| p.to_bech32().ok()) else {
356 continue;
357 };
358 let m = rows_by_npub.get(&npub);
359 let facts = assembled.facts.get(&npub);
360 let row = PreviewRow {
361 npub: npub.clone(),
362 score: s.confidence,
363 proven: s.proven,
364 band: format!("{:?}", s.band).to_lowercase(),
365 shield: format!("{:?}", s.shield).to_lowercase(),
366 reasons: m
367 .and_then(|m| m["reasons"].as_array().cloned())
368 .map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect())
369 .unwrap_or_default(),
370 tenure_days: facts.map(|f| f.tenure_secs / 86_400).unwrap_or(0),
371 messages: facts.map(|f| f.messages).unwrap_or(0),
372 };
373 flagged.push(row);
374 }
375 flagged.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.npub.cmp(&b.npub)));
376
377 let mut bare = lp.policy.clone();
382 bare.shields.trusted.tenure_secs = u64::MAX;
383 bare.shields.trusted.veteran_secs = u64::MAX;
384 bare.shields.trusted.roles_trust = false;
385 let bare_lp = LoadedPolicy { hash: lp.hash, policy: bare, activated_at: None };
386 let bare_report = evaluate_as(&assembled.signals, &[bare_lp], &[], now_ms, EvalMode::Admin);
387 let already: std::collections::BTreeSet<[u8; 32]> = pr
388 .subjects
389 .iter()
390 .filter(|s| s.convictions.iter().any(|c| !c.suppressed))
391 .map(|s| s.subject.0)
392 .collect();
393 let mut shielded_matches: Vec<PreviewRow> = Vec::new();
394 if let Some(bpr) = bare_report.policies.first() {
395 for s in &bpr.subjects {
396 if already.contains(&s.subject.0) || s.convictions.iter().all(|c| c.suppressed) {
397 continue;
398 }
399 let Some(npub) = PublicKey::from_slice(&s.subject.0).ok().and_then(|p| p.to_bech32().ok()) else {
400 continue;
401 };
402 let facts = assembled.facts.get(&npub);
403 shielded_matches.push(PreviewRow {
404 npub,
405 score: s.confidence,
406 proven: s.proven,
407 band: format!("{:?}", s.band).to_lowercase(),
408 shield: "trusted".into(),
409 reasons: vec![],
410 tenure_days: facts.map(|f| f.tenure_secs / 86_400).unwrap_or(0),
411 messages: facts.map(|f| f.messages).unwrap_or(0),
412 });
413 }
414 }
415 shielded_matches.sort_by(|a, b| b.messages.cmp(&a.messages).then_with(|| a.npub.cmp(&b.npub)));
416
417 PolicyPreview {
418 valid: true,
419 error: None,
420 messages_cited: pr.citations.len(),
421 corpus: assembled.corpus,
422 unevaluated: pr
423 .rule_status
424 .iter()
425 .filter(|r| r.state != RuleState::Evaluated)
426 .map(|r| format!("{} ({:?})", r.rule_id, r.state))
427 .collect(),
428 flagged,
429 shielded_matches,
430 }
431}
432
433pub fn load_policies(community_id_hex: &str) -> Vec<LoadedPolicy> {
440 select_policies(crate::db::community::get_community_policies(community_id_hex).unwrap_or_default())
441}
442
443pub fn select_policies(stored: Vec<crate::db::community::StoredPolicy>) -> Vec<LoadedPolicy> {
451 let replaced = stored.iter().any(|p| p.policy_id == DEFAULTS_POLICY_ID);
452 let mut loaded: Vec<LoadedPolicy> = stored
453 .into_iter()
454 .filter(|p| p.enabled)
455 .filter_map(|p| {
456 let policy: Policy = serde_json::from_str(&p.bytes).ok()?;
457 Some(LoadedPolicy { hash: hash_policy_bytes(p.bytes.as_bytes()), policy, activated_at: None })
458 })
459 .collect();
460 if !replaced {
461 let policy = default_policy();
462 let bytes = serde_json::to_vec(&policy).unwrap_or_default();
463 loaded.push(LoadedPolicy { hash: hash_policy_bytes(&bytes), policy, activated_at: None });
464 }
465 loaded
466}
467
468pub fn evaluate_for_console(
470 community_id_hex: &str,
471 assembled: &Assembled,
472 now_ms: u64,
473) -> Result<serde_json::Value, String> {
474 let policies = load_policies(community_id_hex);
475 let report = evaluate_as(&assembled.signals, &policies, &[], now_ms, EvalMode::Admin);
476 let mut console = console_report(&report, &assembled.facts, now_ms / 1000);
477 let w = &report.window;
482 console["coverage"] = serde_json::json!({
483 "corpus": assembled.corpus,
484 "corpus_max": caps::WINDOW_MAX_MESSAGES,
485 "channels": w.channels.len(),
486 "observed_from": w.observed_from,
487 "observed_to": w.observed_to,
488 "complete": w.complete,
489 });
490 Ok(console)
491}
492
493pub fn arrival(joined_at_ms: Option<u64>, first_post_ms: Option<u64>) -> Option<u64> {
507 [joined_at_ms, first_post_ms].into_iter().flatten().min()
508}
509
510fn screen_at(message_at_ms: u64) -> u64 {
528 message_at_ms.saturating_add(1)
529}
530
531pub fn screen_message(
532 community_id_hex: &str,
533 owner: &PublicKey,
534 author: &PublicKey,
535 author_roles: &[String],
536 author_is_staff: bool,
537 channel_id_hex: &str,
538 text: &str,
539 now_ms: u64,
540) -> Vec<serde_json::Value> {
541 let policies = load_policies(community_id_hex);
542 if policies.is_empty() {
543 return vec![];
544 }
545 let subject = SubjectId(author.to_bytes());
546 let signals = Signals {
547 owner: SubjectId(owner.to_bytes()),
548 members: vec![MemberSignal {
549 subject,
550 joined_at_ms: None,
553 roles: author_roles.iter().filter_map(|r| crate::simd::hex::hex_to_bytes_32_checked(r)).map(Hash32).collect(),
554 is_staff: author_is_staff,
555 lifetime_messages: 0,
556 first_post_ms: None,
557 }],
558 messages: vec![MessageSignal {
559 id: MessageId([0; 32]),
561 author: subject,
562 channel: Hash32(crate::simd::hex::hex_to_bytes_32(channel_id_hex)),
563 at_ms: now_ms,
564 text: text.to_string(),
565 mentions: super::matchers::count_mentions(text),
566 }],
567 channels: vec![Hash32(crate::simd::hex::hex_to_bytes_32(channel_id_hex))],
568 relays: vec![],
569 requested_from: now_ms,
570 requested_to: now_ms,
571 confirmed_from: now_ms,
572 confirmed_to: now_ms,
573 roster_version: Hash32([0; 32]),
574 };
575 let report = evaluate_as(&signals, &policies, &[], screen_at(now_ms), EvalMode::Member);
576 let mut out = Vec::new();
577 for pr in &report.policies {
578 let detail_of: std::collections::BTreeMap<[u8; 32], String> =
579 pr.citations.iter().filter_map(|c| c.detail.clone().map(|d| (c.id.0, d))).collect();
580 for s in &pr.subjects {
581 for c in s.convictions.iter().filter(|c| !c.suppressed) {
582 let detail: std::collections::BTreeSet<String> =
583 c.citations.iter().filter_map(|id| detail_of.get(&id.0).cloned()).collect();
584 out.push(serde_json::json!({
585 "policy_hash": crate::simd::hex::bytes_to_hex_32(&pr.policy_hash.0),
586 "rule_id": c.rule_id,
587 "scope": wire(c.scope),
588 "basis": wire(c.basis),
589 "severity": wire(c.severity),
590 "stateless": true,
591 "hits": c.hits,
592 "weight": c.tier_weight,
593 "detail": detail.into_iter().collect::<Vec<_>>(),
594 }));
595 }
596 }
597 }
598 out
599}
600
601#[derive(Debug, Clone, Default)]
604pub struct MemberFacts {
605 pub joined_at_ms: u64,
606 pub invite_label: Option<String>,
607 pub messages: u64,
608 pub distinct: u64,
609 pub tenure_secs: u64,
610 pub last_secs: u64,
611 pub is_owner: bool,
612 pub is_admin: bool,
613 pub is_me: bool,
614}
615
616fn wire(v: impl serde::Serialize) -> String {
620 serde_json::to_value(v).ok().and_then(|j| j.as_str().map(String::from)).unwrap_or_default()
621}
622
623pub fn console_report(
628 report: &ModerationReport,
629 facts: &std::collections::BTreeMap<String, MemberFacts>,
630 now_secs: u64,
631) -> serde_json::Value {
632 if report.policies.is_empty() {
633 return serde_json::json!({ "members": [], "cohorts": [], "suspects": 0, "trusted": 0, "protected": 0,
634 "raid_detected": false, "burst_size": 0, "burst_from_ms": 0, "burst_to_ms": 0,
635 "inert_policies": 0 });
636 }
637 let mut by_subject: std::collections::BTreeMap<String, Vec<(&Hash32, &SubjectReport)>> =
642 std::collections::BTreeMap::new();
643 for pr in &report.policies {
644 for s in &pr.subjects {
645 if let Some(b32) = PublicKey::from_slice(&s.subject.0).ok().and_then(|p| p.to_bech32().ok()) {
646 by_subject.entry(b32).or_default().push((&pr.policy_hash, s));
647 }
648 }
649 }
650 let inert = report.policies.iter().filter(|p| p.inert.is_some()).count();
651
652 let detail_of: std::collections::BTreeMap<[u8; 32], String> = report
655 .policies
656 .iter()
657 .flat_map(|p| p.citations.iter())
658 .filter_map(|c| c.detail.clone().map(|d| (c.id.0, d)))
659 .collect();
660
661 let message_of: std::collections::BTreeMap<[u8; 32], String> = report
664 .policies
665 .iter()
666 .flat_map(|p| p.citations.iter())
667 .filter_map(|c| match &c.target {
668 CitationTarget::Message { message_id } => {
669 Some((c.id.0, crate::simd::hex::bytes_to_hex_32(&message_id.0)))
670 }
671 _ => None,
672 })
673 .collect();
674
675 let (mut suspects, mut trusted, mut protected) = (0usize, 0usize, 0usize);
676 let mut members: Vec<serde_json::Value> = Vec::with_capacity(facts.len());
677 for (npub, f) in facts {
678 let reports = by_subject.get(npub).cloned().unwrap_or_default();
679 let judged: Vec<(&Hash32, &Conviction)> = reports
682 .iter()
683 .flat_map(|(h, s)| s.convictions.iter().map(move |c| (*h, c)))
684 .filter(|(_, c)| !c.suppressed)
685 .collect();
686 let convictions: Vec<&Conviction> = judged.iter().map(|(_, c)| *c).collect();
687 let shield = reports
690 .iter()
691 .map(|(_, s)| s.shield)
692 .max_by_key(|s| match s {
693 Shield::Protected => 3,
694 Shield::Trusted => 2,
695 Shield::Indeterminate => 1,
696 Shield::None => 0,
697 })
698 .unwrap_or(Shield::None);
699 let worst = reports.iter().max_by_key(|(_, s)| s.confidence);
702 let score = worst.map(|(_, s)| s.confidence).unwrap_or(0);
703 let band = worst.map(|(_, s)| s.band).unwrap_or(Band::Clear);
704 let proven_score = reports.iter().map(|(_, s)| s.proven).max().unwrap_or(0);
705 let verdict = if shield == Shield::Protected {
707 protected += 1;
708 "protected"
709 } else if !convictions.is_empty() {
710 suspects += 1;
711 "suspect"
712 } else if shield == Shield::Trusted {
713 trusted += 1;
714 "trusted"
715 } else {
716 "neutral"
717 };
718 let mut reasons: Vec<String> = Vec::new();
721 let mut cohort_peers = 0u32;
722 for c in &convictions {
723 for e in &c.evidence {
724 match e {
725 Evidence::Cohort { size, sample, .. } => {
726 cohort_peers = cohort_peers.max(size.saturating_sub(1));
727 let quoted: String = sample.chars().take(48).collect();
728 reasons.push(format!(
729 "Posted the same message as {} other member{} — \"{}\"",
730 size.saturating_sub(1),
731 if *size == 2 { "" } else { "s" },
732 quoted
733 ));
734 }
735 Evidence::Burst { size, .. } => reasons.push(format!("Joined in a burst of {size}")),
736 Evidence::Rate { count, window_secs, .. } => {
737 reasons.push(format!("Sent {count} messages in {window_secs}s"))
738 }
739 _ => {}
740 }
741 }
742 if c.evidence.is_empty() {
743 let quoted: Vec<String> = c
746 .citations
747 .iter()
748 .filter_map(|id| detail_of.get(&id.0).cloned())
749 .collect::<std::collections::BTreeSet<_>>()
750 .into_iter()
751 .take(3)
752 .collect();
753 reasons.push(match c.rule_id.as_str() {
754 "fresh" => "Joined in the last 24h".to_string(),
755 "quiet" => "Has barely posted".to_string(),
756 "repeat" => format!("Repeated one message {} times", c.hits),
757 "shorteners" | "links" => {
758 let which = quoted.join(", ");
759 if which.is_empty() {
760 format!("Posted {} flagged link(s)", c.hits)
761 } else {
762 format!("Posted {} flagged link(s): {which}", c.hits)
763 }
764 }
765 _ if !quoted.is_empty() => {
766 format!("Used {} ({} time{})", quoted.iter().map(|q| format!("\"{q}\"")).collect::<Vec<_>>().join(", "), c.hits, if c.hits == 1 { "" } else { "s" })
767 }
768 other => format!("Matched rule {other}"),
769 });
770 }
771 }
772 if verdict == "protected" {
773 reasons.push(if f.is_owner { "Community owner".into() } else { "Holds a role".into() });
774 } else if verdict == "trusted" && reasons.is_empty() {
775 reasons.push("Long-standing member".into());
776 }
777
778 let findings: Vec<serde_json::Value> = judged
783 .iter()
784 .map(|(policy_hash, c)| {
785 let messages: Vec<String> =
786 c.citations.iter().filter_map(|id| message_of.get(&id.0).cloned()).collect();
787 let detail: std::collections::BTreeSet<String> =
788 c.citations.iter().filter_map(|id| detail_of.get(&id.0).cloned()).collect();
789 serde_json::json!({
790 "conviction_id": crate::simd::hex::bytes_to_hex_32(&c.id.0),
795 "policy_hash": crate::simd::hex::bytes_to_hex_32(&policy_hash.0),
796 "rule_id": c.rule_id,
797 "scope": wire(c.scope),
798 "basis": wire(c.basis),
799 "severity": wire(c.severity),
800 "stateless": c.scope == Scope::PerMessage,
806 "rung": c.rung,
807 "hits": c.hits,
808 "weight": c.tier_weight,
809 "detail": detail.into_iter().collect::<Vec<_>>(),
810 "messages": messages,
811 "citation_count": c.citation_count,
812 })
813 })
814 .collect();
815
816 let mut row = serde_json::json!({
817 "npub": npub,
818 "verdict": verdict,
819 "shield": wire(shield),
823 "score": score,
824 "band": wire(band),
825 "proven": proven_score,
826 "reasons": reasons,
827 "joined_at_ms": f.joined_at_ms,
828 "invite_label": f.invite_label,
829 "messages": f.messages,
830 "distinct": f.distinct,
831 "cohort": cohort_peers,
832 "tenure_secs": f.tenure_secs,
833 "last_secs": f.last_secs,
834 "is_owner": f.is_owner,
835 "is_admin": f.is_admin,
836 "is_me": f.is_me,
837 });
838 if !findings.is_empty() {
841 row["findings"] = serde_json::Value::Array(findings);
842 }
843 members.push(row);
844 }
845 members.sort_by(|a, b| {
850 let rank = |v: &serde_json::Value| match v["verdict"].as_str() {
851 Some("suspect") => 0,
852 Some("protected") => 1,
853 Some("trusted") => 2,
854 _ => 3,
855 };
856 rank(a)
857 .cmp(&rank(b))
858 .then_with(|| b["score"].as_u64().cmp(&a["score"].as_u64()))
859 .then_with(|| b["messages"].as_u64().cmp(&a["messages"].as_u64()))
860 });
861
862 let mut cohorts: std::collections::BTreeMap<[u8; 32], (usize, String)> = std::collections::BTreeMap::new();
864 let mut burst = (0u32, 0u64, 0u64);
865 for s in report.policies.iter().flat_map(|p| p.subjects.iter()) {
866 for c in s.convictions.iter().filter(|c| !c.suppressed) {
867 for e in &c.evidence {
868 match e {
869 Evidence::Cohort { skeleton_hash, size, sample, .. } => {
870 let slot = cohorts.entry(skeleton_hash.0).or_insert((0, sample.clone()));
871 slot.0 = slot.0.max(*size as usize);
872 }
873 Evidence::Burst { size, from, to } => {
874 if *size > burst.0 {
875 burst = (*size, *from, *to);
876 }
877 }
878 _ => {}
879 }
880 }
881 }
882 }
883 let mut cohort_list: Vec<serde_json::Value> = cohorts
884 .into_values()
885 .map(|(size, sample)| serde_json::json!({ "size": size, "sample": sample, "members": [] }))
886 .collect();
887 cohort_list.sort_by(|a, b| b["size"].as_u64().cmp(&a["size"].as_u64()));
888
889 let _ = now_secs;
890 serde_json::json!({
891 "members": members,
892 "cohorts": cohort_list,
893 "suspects": suspects,
894 "trusted": trusted,
895 "protected": protected,
896 "raid_detected": !cohort_list_is_empty(&cohort_list) && suspects > 0,
897 "burst_size": burst.0,
898 "burst_from_ms": burst.1,
899 "burst_to_ms": burst.2,
900 "inert_policies": inert,
901 })
902}
903
904fn cohort_list_is_empty(v: &[serde_json::Value]) -> bool {
905 v.is_empty()
906}
907
908#[derive(Debug, Clone, serde::Serialize)]
910pub struct VerdictRow {
911 pub npub: String,
912 pub assessor: String,
914 pub confidence: u32,
916 pub proven: u32,
917 pub band: String,
918 pub shield: String,
919 pub rules: Vec<String>,
920}
921
922#[derive(Debug, Clone, serde::Serialize)]
924pub struct DiffReport {
925 pub members: usize,
926 pub corpus: usize,
927 pub engine_convicted: usize,
929 pub assessor_suspects: usize,
931 pub engine_only: Vec<VerdictRow>,
933 pub assessor_only: Vec<VerdictRow>,
934 pub agreed: Vec<VerdictRow>,
936 pub protected: usize,
938 pub trusted: usize,
939 pub indeterminate: usize,
940 pub rule_states: Vec<(String, String)>,
942 pub coverage_complete: bool,
943 pub signals_ms: u64,
946 pub evaluate_ms: u64,
947}
948
949pub fn run_side_by_side(
952 community_id_hex: &str,
953 owner: &PublicKey,
954 members: &[(PublicKey, Option<u64>, bool, Vec<String>)],
955 assessments: &[(String, String)],
956 now_ms: u64,
957) -> Result<DiffReport, String> {
958 let policy = default_policy();
959 let bytes = serde_json::to_vec(&policy).map_err(|e| e.to_string())?;
960 let lp = LoadedPolicy { hash: hash_policy_bytes(&bytes), policy, activated_at: None };
961
962 let t0 = std::time::Instant::now();
963 let rows = crate::db::community::community_policy_messages(community_id_hex, caps::WINDOW_MAX_MESSAGES)?;
964 let corpus = rows.len();
965 let messages: Vec<MessageSignal> = rows
966 .into_iter()
967 .filter_map(|m| {
968 Some(MessageSignal {
969 id: MessageId(crate::simd::hex::hex_to_bytes_32_checked(&m.id)?),
970 author: subject_of(&m.npub)?,
971 channel: Hash32(crate::simd::hex::hex_to_bytes_32_checked(&m.channel_id)?),
972 at_ms: m.at_ms,
973 text: m.text,
974 mentions: m.mentions,
975 })
976 })
977 .collect();
978
979 let channels: BTreeSet<[u8; 32]> = messages.iter().map(|m| m.channel.0).collect();
980 let footprints: std::collections::HashMap<String, (u64, u64)> =
983 crate::db::community::community_author_footprints(community_id_hex)
984 .unwrap_or_default()
985 .into_iter()
986 .map(|f| (f.npub, (f.messages, f.first_secs)))
987 .collect();
988 let member_signals: Vec<MemberSignal> = members
989 .iter()
990 .map(|(pk, joined, is_staff, roles)| MemberSignal {
991 subject: SubjectId(pk.to_bytes()),
992 joined_at_ms: *joined,
993 roles: roles.iter().filter_map(|r| crate::simd::hex::hex_to_bytes_32_checked(r)).map(Hash32).collect(),
994 is_staff: *is_staff,
995 lifetime_messages: pk.to_bech32().ok().and_then(|b| footprints.get(&b).map(|f| f.0)).unwrap_or(0),
996 first_post_ms: pk
997 .to_bech32()
998 .ok()
999 .and_then(|b| footprints.get(&b).map(|f| f.1))
1000 .filter(|s| *s > 0)
1001 .map(|s| s.saturating_mul(1000)),
1002 })
1003 .collect();
1004
1005 let signals = Signals {
1006 owner: SubjectId(owner.to_bytes()),
1007 members: member_signals,
1008 messages,
1009 channels: channels.into_iter().map(Hash32).collect(),
1010 relays: vec![],
1013 requested_from: 0,
1014 requested_to: now_ms,
1015 confirmed_from: u64::MAX,
1016 confirmed_to: now_ms,
1017 roster_version: Hash32([0; 32]),
1018 };
1019
1020 let signals_ms = t0.elapsed().as_millis() as u64;
1021 let t1 = std::time::Instant::now();
1022 let report = evaluate(&signals, &[lp], &[], now_ms);
1023 let evaluate_ms = t1.elapsed().as_millis() as u64;
1024 let pr = report.policies.first().ok_or("no policy report")?;
1025
1026 let verdict_of = |npub: &str| -> String {
1027 assessments.iter().find(|(n, _)| n == npub).map(|(_, v)| v.clone()).unwrap_or_else(|| "unknown".into())
1028 };
1029
1030 let mut engine_only = Vec::new();
1031 let mut assessor_only = Vec::new();
1032 let mut agreed = Vec::new();
1033 let (mut protected, mut trusted, mut indeterminate) = (0, 0, 0);
1034 let mut engine_convicted = 0usize;
1035
1036 for s in &pr.subjects {
1037 match s.shield {
1038 Shield::Protected => protected += 1,
1039 Shield::Trusted => trusted += 1,
1040 Shield::Indeterminate => indeterminate += 1,
1041 Shield::None => {}
1042 }
1043 let npub = PublicKey::from_slice(&s.subject.0).ok().and_then(|p| p.to_bech32().ok()).unwrap_or_default();
1044 let assessor = verdict_of(&npub);
1045 let convicted = s.convictions.iter().any(|c| !c.suppressed);
1046 if convicted {
1047 engine_convicted += 1;
1048 }
1049 let row = VerdictRow {
1050 npub: npub.clone(),
1051 assessor: assessor.clone(),
1052 confidence: s.confidence,
1053 proven: s.proven,
1054 band: format!("{:?}", s.band).to_lowercase(),
1055 shield: format!("{:?}", s.shield).to_lowercase(),
1056 rules: s.convictions.iter().map(|c| format!("{}:{:?}", c.rule_id, c.scope)).collect(),
1057 };
1058 match (convicted, assessor == "suspect") {
1059 (true, true) => agreed.push(row),
1060 (true, false) => engine_only.push(row),
1061 (false, true) => assessor_only.push(row),
1062 (false, false) => {}
1063 }
1064 }
1065 for (npub, verdict) in assessments.iter().filter(|(_, v)| v == "suspect") {
1068 if !pr.subjects.iter().any(|s| {
1069 PublicKey::from_slice(&s.subject.0).ok().and_then(|p| p.to_bech32().ok()).as_deref() == Some(npub.as_str())
1070 }) {
1071 assessor_only.push(VerdictRow {
1072 npub: npub.clone(),
1073 assessor: verdict.clone(),
1074 confidence: 0,
1075 proven: 0,
1076 band: "clear".into(),
1077 shield: "none".into(),
1078 rules: vec![],
1079 });
1080 }
1081 }
1082
1083 Ok(DiffReport {
1084 members: members.len(),
1085 corpus,
1086 engine_convicted,
1087 assessor_suspects: assessments.iter().filter(|(_, v)| v == "suspect").count(),
1088 engine_only,
1089 assessor_only,
1090 agreed,
1091 protected,
1092 trusted,
1093 indeterminate,
1094 rule_states: pr
1095 .rule_status
1096 .iter()
1097 .map(|r| (r.rule_id.clone(), format!("{:?}", r.state).to_lowercase()))
1098 .collect(),
1099 coverage_complete: pr.coverage_complete,
1100 signals_ms,
1101 evaluate_ms,
1102 })
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108
1109 #[test]
1113 fn the_message_being_screened_is_inside_its_own_corpus() {
1114 use nostr_sdk::prelude::Keys;
1115
1116 let owner = Keys::generate();
1117 let author = Keys::generate();
1118 let now = 1_700_000_000_000u64;
1119
1120 let policy = Policy {
1121 format: FORMAT,
1122 requires: vec![],
1123 name: "screen-test".into(),
1124 emoji_codes: vec![],
1125 shields: Shields::default(),
1126 window: Window { hours: 168, max_messages: 4000 },
1127 exempt: Exempt::default(),
1128 rules: vec![Rule {
1129 id: "words".into(),
1130 matcher: Match::Keyword { patterns: vec!["badword".into()], normalize: Normalize::Fold },
1131 tiers: Some(Tiers {
1132 per_message: vec![Rung {
1133 hits: 1,
1134 severity: Severity::Severe,
1135 weight: 70,
1136 pierces_trusted: false,
1137 }],
1138 per_window: vec![],
1139 }),
1140 severity: None,
1141 weight: None,
1142 pierces_trusted: false,
1143 family: None,
1144 armed_by: None,
1145 exempt: Exempt::default(),
1146 enforcement: Enforcement::Advisory,
1147 }],
1148 };
1149 let loaded = vec![LoadedPolicy { hash: Hash32([9u8; 32]), policy, activated_at: None }];
1150
1151 let subject = SubjectId(author.public_key().to_bytes());
1153 let signals = Signals {
1154 owner: SubjectId(owner.public_key().to_bytes()),
1155 members: vec![MemberSignal {
1156 subject,
1157 joined_at_ms: None,
1158 roles: vec![],
1159 is_staff: false,
1160 lifetime_messages: 0,
1161 first_post_ms: None,
1162 }],
1163 messages: vec![MessageSignal {
1164 id: MessageId([0; 32]),
1165 author: subject,
1166 channel: Hash32([1u8; 32]),
1167 at_ms: now,
1168 text: "this has badword in it".into(),
1169 mentions: 0,
1170 }],
1171 channels: vec![Hash32([1u8; 32])],
1172 relays: vec![],
1173 requested_from: now,
1174 requested_to: now,
1175 confirmed_from: now,
1176 confirmed_to: now,
1177 roster_version: Hash32([0; 32]),
1178 };
1179
1180 let report = evaluate_as(&signals, &loaded, &[], screen_at(now), EvalMode::Member);
1181 let convictions: usize = report.policies.iter().flat_map(|p| &p.subjects).map(|s| s.convictions.len()).sum();
1182 assert!(convictions > 0, "the screened message must be judged, not clamped out of its own corpus");
1183 }
1184
1185 #[test]
1190 fn every_member_carries_the_band_its_confidence_earned() {
1191 use nostr_sdk::prelude::Keys;
1192
1193 fn one(subjects: Vec<(u32, Band)>) -> serde_json::Value {
1194 let keys = Keys::generate();
1195 let npub = keys.public_key().to_bech32().expect("npub");
1196 let policies = subjects
1197 .into_iter()
1198 .map(|(confidence, band)| PolicyReport {
1199 policy_hash: Hash32([1; 32]),
1200 inert: None,
1201 activated_at: None,
1202 coverage_complete: true,
1203 rule_status: vec![],
1204 subjects: vec![SubjectReport {
1205 subject: SubjectId(keys.public_key().to_bytes()),
1206 shield: Shield::None,
1207 confidence,
1208 proven: confidence,
1209 band,
1210 convictions: vec![],
1211 }],
1212 subjects_truncated: 0,
1213 citations: vec![],
1214 })
1215 .collect();
1216 let report = ModerationReport {
1217 engine_version: caps::ENGINE_VERSION,
1218 bundle_version: caps::BUNDLE_VERSION,
1219 roster_version: Hash32([0; 32]),
1220 override_hash: Hash32([0; 32]),
1221 evaluated_at: 0,
1222 window: WindowCoverage::default(),
1223 policies,
1224 };
1225 let facts = std::collections::BTreeMap::from([(npub, MemberFacts::default())]);
1226 console_report(&report, &facts, 0)["members"][0].clone()
1227 }
1228
1229 for (confidence, band) in [(90u32, Band::Alert), (60, Band::Flagged), (30, Band::Watch), (0, Band::Clear)] {
1230 let row = one(vec![(confidence, band)]);
1231 let expected = format!("{band:?}").to_lowercase();
1232 assert_eq!(row["score"].as_u64(), Some(confidence as u64));
1233 assert_eq!(
1234 row["band"].as_str(),
1235 Some(expected.as_str()),
1236 "a member at confidence {confidence} must carry the band it earned, not a default"
1237 );
1238 }
1239
1240 let row = one(vec![(30, Band::Watch), (90, Band::Alert)]);
1243 assert_eq!(row["score"].as_u64(), Some(90), "the worst law decides the score");
1244 assert_eq!(row["band"].as_str(), Some("alert"), "and the same law decides the band");
1245 }
1246
1247 #[test]
1253 fn a_pierced_trusted_member_keeps_their_standing_in_the_report() {
1254 use nostr_sdk::prelude::Keys;
1255
1256 let keys = Keys::generate();
1257 let npub = keys.public_key().to_bech32().expect("npub");
1258 let subject = SubjectId(keys.public_key().to_bytes());
1259 let conviction = Conviction {
1260 id: conviction_id(&Hash32([1; 32]), "slurs", Scope::PerMessage, 0, &subject),
1261 subject,
1262 rule_id: "slurs".into(),
1263 scope: Scope::PerMessage,
1264 rung: 0,
1265 hits: 1,
1266 severity: Severity::Severe,
1267 basis: Basis::Deterministic,
1268 tier_weight: 85,
1269 retroactive: Retroactive::Unknown,
1270 suppressed: false,
1271 folded: false,
1272 folded_into: None,
1273 combined: true,
1274 proven_combined: true,
1275 citations: vec![],
1276 citation_count: 0,
1277 earliest_citation_at: 0,
1278 latest_citation_at: 0,
1279 family: None,
1280 evidence: vec![],
1281 };
1282 let report = ModerationReport {
1283 engine_version: caps::ENGINE_VERSION,
1284 bundle_version: caps::BUNDLE_VERSION,
1285 roster_version: Hash32([0; 32]),
1286 override_hash: Hash32([0; 32]),
1287 evaluated_at: 0,
1288 window: WindowCoverage::default(),
1289 policies: vec![PolicyReport {
1290 policy_hash: Hash32([1; 32]),
1291 inert: None,
1292 activated_at: None,
1293 coverage_complete: true,
1294 rule_status: vec![],
1295 subjects: vec![SubjectReport {
1296 subject,
1297 shield: Shield::Trusted,
1298 confidence: 85,
1299 proven: 85,
1300 band: Band::Alert,
1301 convictions: vec![conviction],
1302 }],
1303 subjects_truncated: 0,
1304 citations: vec![],
1305 }],
1306 };
1307 let facts = std::collections::BTreeMap::from([(npub, MemberFacts::default())]);
1308 let row = &console_report(&report, &facts, 0)["members"][0];
1309
1310 assert_eq!(row["verdict"].as_str(), Some("suspect"), "the panel opens on what needs deciding");
1311 assert_eq!(row["shield"].as_str(), Some("trusted"), "and their standing survives the projection");
1312 }
1313
1314 #[test]
1318 fn a_conviction_reaches_a_bot_as_rule_identity_and_the_messages_it_cited() {
1319 use nostr_sdk::prelude::Keys;
1320
1321 let keys = Keys::generate();
1322 let npub = keys.public_key().to_bech32().expect("npub");
1323 let subject = SubjectId(keys.public_key().to_bytes());
1324 let policy_hash = Hash32([7; 32]);
1325 let message = MessageId([0xb1; 32]);
1326 let target = CitationTarget::Message { message_id: message };
1327 let cid = citation_id(&policy_hash, "links", Scope::PerMessage, &subject, &target, None);
1328
1329 let conviction = Conviction {
1330 id: conviction_id(&policy_hash, "links", Scope::PerMessage, 0, &subject),
1331 subject,
1332 rule_id: "links".into(),
1333 scope: Scope::PerMessage,
1334 rung: 0,
1335 hits: 2,
1336 severity: Severity::Severe,
1337 basis: Basis::Deterministic,
1338 tier_weight: 85,
1339 retroactive: Retroactive::Unknown,
1340 suppressed: false,
1341 folded: false,
1342 folded_into: None,
1343 combined: true,
1344 proven_combined: true,
1345 citations: vec![cid],
1346 citation_count: 2,
1347 earliest_citation_at: 10,
1348 latest_citation_at: 20,
1349 family: None,
1350 evidence: vec![],
1351 };
1352 let clean = Keys::generate();
1353 let clean_npub = clean.public_key().to_bech32().expect("npub");
1354 let report = ModerationReport {
1355 engine_version: caps::ENGINE_VERSION,
1356 bundle_version: caps::BUNDLE_VERSION,
1357 roster_version: Hash32([0; 32]),
1358 override_hash: Hash32([0; 32]),
1359 evaluated_at: 0,
1360 window: WindowCoverage::default(),
1361 policies: vec![PolicyReport {
1362 policy_hash,
1363 inert: None,
1364 activated_at: None,
1365 coverage_complete: true,
1366 rule_status: vec![],
1367 subjects: vec![
1368 SubjectReport {
1369 subject,
1370 shield: Shield::None,
1371 confidence: 85,
1372 proven: 85,
1373 band: Band::Alert,
1374 convictions: vec![conviction],
1375 },
1376 SubjectReport {
1377 subject: SubjectId(clean.public_key().to_bytes()),
1378 shield: Shield::None,
1379 confidence: 0,
1380 proven: 0,
1381 band: Band::Clear,
1382 convictions: vec![],
1383 },
1384 ],
1385 subjects_truncated: 0,
1386 citations: vec![Citation {
1387 id: cid,
1388 target,
1389 at: 20,
1390 span: None,
1391 detail: Some("bit.ly".into()),
1392 suppressed: false,
1393 }],
1394 }],
1395 };
1396 let facts = std::collections::BTreeMap::from([
1397 (npub.clone(), MemberFacts::default()),
1398 (clean_npub.clone(), MemberFacts::default()),
1399 ]);
1400 let console = console_report(&report, &facts, 0);
1401 let rows = console["members"].as_array().expect("members");
1402 let row = rows.iter().find(|m| m["npub"].as_str() == Some(npub.as_str())).expect("the convicted member");
1403
1404 let f = &row["findings"][0];
1405 assert_eq!(f["rule_id"].as_str(), Some("links"), "the rule, so a bot can ramp on it");
1406 assert_eq!(f["severity"].as_str(), Some("severe"), "the author's gravity, snake_case like the wire");
1407 assert_eq!(f["basis"].as_str(), Some("deterministic"), "provable, so it may be acted on unattended");
1408 assert_eq!(f["scope"].as_str(), Some("per_message"));
1409 assert_eq!(f["hits"].as_u64(), Some(2));
1410 assert_eq!(f["weight"].as_u64(), Some(85));
1411 assert_eq!(f["policy_hash"].as_str(), Some(crate::simd::hex::bytes_to_hex_32(&policy_hash.0).as_str()));
1412 assert_eq!(f["detail"][0].as_str(), Some("bit.ly"), "what actually matched");
1413 assert_eq!(
1414 f["messages"][0].as_str(),
1415 Some(crate::simd::hex::bytes_to_hex_32(&message.0).as_str()),
1416 "the message to hide, resolved from the citation"
1417 );
1418 assert_eq!(
1419 f["conviction_id"].as_str(),
1420 Some(crate::simd::hex::bytes_to_hex_32(&conviction_id(&policy_hash, "links", Scope::PerMessage, 0, &subject).0).as_str()),
1421 "the dedup key, stable across polls"
1422 );
1423 assert!(!row["reasons"].as_array().unwrap().is_empty(), "and the prose the panel reads is untouched");
1424
1425 let clean_row = rows.iter().find(|m| m["npub"].as_str() == Some(clean_npub.as_str())).unwrap();
1426 assert!(clean_row["findings"].is_null(), "a member nothing convicted carries no findings key at all");
1427 }
1428
1429 #[test]
1433 fn screening_answers_the_stateless_rules_and_stays_quiet_about_the_rest() {
1434 use nostr_sdk::prelude::Keys;
1435 let owner = Keys::generate();
1436 let member = Keys::generate();
1437 let cid = "aa".repeat(32);
1438 let chan = "bb".repeat(32);
1439
1440 let none = screen_message(&cid, &owner.public_key(), &member.public_key(), &[], false, &chan, "hello", 0);
1442 assert!(none.is_empty(), "a community with no policies screens to nothing");
1443
1444 for m in [
1446 Match::Keyword { patterns: vec!["x".into()], normalize: Normalize::Fold },
1447 Match::Link { patterns: vec!["bit.ly".into()] },
1448 Match::Mentions {},
1449 ] {
1450 assert!(m.is_stateless(), "{m:?} must answer per message");
1451 }
1452 for m in [
1453 Match::Rate { per_secs: 60 },
1454 Match::Repeat { normalize: Normalize::Skeleton, within_secs: None },
1455 Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
1456 Match::JoinBurst { gap_secs: 300, min: 5 },
1457 ] {
1458 assert!(!m.is_stateless(), "{m:?} describes a window and must not answer from one message");
1459 }
1460 }
1461
1462 #[test]
1466 fn arrival_counts_every_way_in_not_just_the_paperwork() {
1467 assert_eq!(arrival(Some(1_000), Some(5_000)), Some(1_000));
1469 assert_eq!(arrival(None, Some(5_000)), Some(5_000), "posting IS arriving");
1472 assert_eq!(arrival(Some(9_000), Some(2_000)), Some(2_000));
1475 assert_eq!(arrival(None, None), None);
1477 }
1478
1479 #[test]
1480 fn the_builtin_policy_validates() {
1481 assert!(default_policy().validate().is_ok(), "a shipped policy that cannot validate is a shipped outage");
1482 }
1483
1484 fn stored(policy_id: &str, name: &str, enabled: bool) -> crate::db::community::StoredPolicy {
1485 let mut p = crate::community::policy::presets::all()
1486 .into_iter()
1487 .find(|x| x.id == "word_filter")
1488 .expect("word_filter preset")
1489 .policy;
1490 p.name = name.into();
1491 let bytes = serde_json::to_string(&p).unwrap();
1492 crate::db::community::StoredPolicy {
1493 policy_id: policy_id.into(),
1494 hash: crate::simd::hex::bytes_to_hex_32(&hash_policy_bytes(bytes.as_bytes()).0),
1495 bytes,
1496 enabled,
1497 updated_at: 0,
1498 }
1499 }
1500
1501 fn names(loaded: &[LoadedPolicy]) -> Vec<String> {
1502 loaded.iter().map(|l| l.policy.name.to_string()).collect()
1503 }
1504
1505 #[test]
1509 fn a_custom_policy_runs_beside_the_defaults_not_instead_of_them() {
1510 let loaded = select_policies(vec![stored("word_filter", "Spoilers", true)]);
1511 let got = names(&loaded);
1512 assert!(got.contains(&"Spoilers".to_string()), "the community's own policy must run: {got:?}");
1513 assert!(
1514 got.contains(&default_policy().name.to_string()),
1515 "the shipped defaults must still run alongside it: {got:?}"
1516 );
1517 }
1518
1519 #[test]
1520 fn nothing_stored_still_runs_the_defaults() {
1521 assert_eq!(names(&select_policies(vec![])), vec![default_policy().name.to_string()]);
1522 }
1523
1524 #[test]
1527 fn a_fork_replaces_the_defaults_rather_than_stacking_on_them() {
1528 let loaded = select_policies(vec![stored(DEFAULTS_POLICY_ID, "My defaults", true)]);
1529 assert_eq!(names(&loaded), vec!["My defaults".to_string()]);
1530 }
1531
1532 #[test]
1535 fn a_disabled_fork_leaves_the_community_with_no_defaults() {
1536 let loaded = select_policies(vec![
1537 stored(DEFAULTS_POLICY_ID, "My defaults", false),
1538 stored("word_filter", "Spoilers", true),
1539 ]);
1540 assert_eq!(names(&loaded), vec!["Spoilers".to_string()]);
1541 }
1542
1543 #[test]
1549 fn the_defaults_block_no_links_and_no_words() {
1550 for r in &default_policy().rules {
1551 match &r.matcher {
1552 Match::Link { .. } => panic!("the defaults ship a link blocker: rule {}", r.id),
1553 Match::Keyword { .. } | Match::Regex { .. } => {
1554 panic!("the defaults ship a word filter: rule {}", r.id)
1555 }
1556 _ => {}
1557 }
1558 }
1559 }
1560
1561 #[test]
1565 fn the_defaults_judge_nothing_but_the_swarm() {
1566 for r in &default_policy().rules {
1567 let allowed = matches!(
1568 r.matcher,
1569 Match::Cohort { .. } | Match::JoinBurst { .. } | Match::TenureLt { .. } | Match::MessagesLte { .. }
1570 );
1571 assert!(allowed, "the defaults ship a non-raid rule: {} ({})", r.id, r.matcher.type_name());
1572 }
1573 }
1574
1575 #[test]
1578 fn the_defaults_still_detect_a_raid() {
1579 let p = default_policy();
1580 assert!(
1581 p.rules.iter().any(|r| matches!(r.matcher, Match::Cohort { .. }) && r.armed_by.is_none()),
1582 "raid detection has to be able to convict on its own"
1583 );
1584 assert!(
1585 p.rules.iter().any(|r| matches!(r.matcher, Match::JoinBurst { .. })),
1586 "the join-flood signal went missing"
1587 );
1588 }
1589
1590 #[test]
1594 fn the_builtin_badge_agrees_with_what_the_engine_loads() {
1595 let cases = vec![
1596 vec![],
1597 vec![stored("word_filter", "Spoilers", true)],
1598 vec![stored(DEFAULTS_POLICY_ID, "Mine", true)],
1599 vec![stored(DEFAULTS_POLICY_ID, "Mine", false)],
1600 ];
1601 for stored_rows in cases {
1602 let badge = !stored_rows.iter().any(|p| p.policy_id == DEFAULTS_POLICY_ID);
1603 let engine_runs_defaults = select_policies(stored_rows.clone())
1604 .iter()
1605 .any(|l| l.policy.name == default_policy().name);
1606 assert_eq!(badge, engine_runs_defaults, "badge and loader disagree for {stored_rows:?}");
1607 }
1608 }
1609
1610 #[test]
1613 fn every_buildable_rule_kind_stands_alone() {
1614 for k in crate::community::policy::presets::rule_kinds() {
1615 assert!(k.rule.armed_by.is_none(), "{} is an aggravator, not a rule you can build with", k.id);
1616 let mut p = base_for_test();
1617 p.rules = vec![k.rule.clone()];
1618 assert!(p.validate().is_ok(), "rule kind {} does not validate alone", k.id);
1619 assert!(!k.description.is_empty(), "rule kind {} needs a plain-language description", k.id);
1620 }
1621 }
1622
1623 fn base_for_test() -> Policy {
1624 let mut p = default_policy();
1625 p.rules = vec![];
1626 p
1627 }
1628
1629 #[test]
1634 fn every_weak_signal_is_armed_by_a_real_conviction() {
1635 let p = default_policy();
1636 for rule in &p.rules {
1637 let describes_the_innocent =
1642 matches!(rule.matcher, Match::TenureLt { .. } | Match::MessagesLte { .. });
1643 if describes_the_innocent {
1644 assert!(rule.armed_by.is_some(), "rule {} must never speak alone", rule.id);
1645 }
1646 }
1647 }
1648
1649 #[test]
1653 fn the_aggravators_answer_to_either_detector() {
1654 let p = default_policy();
1655 for rule in &p.rules {
1656 if !matches!(rule.matcher, Match::TenureLt { .. } | Match::MessagesLte { .. }) {
1657 continue;
1658 }
1659 let arm = rule.armed_by.as_ref().expect("aggravators are armed");
1660 let names: Vec<&str> =
1661 std::iter::once(arm.rule.as_str()).chain(arm.also.iter().map(|s| s.as_str())).collect();
1662 assert!(
1663 names.contains(&"cohort") && names.contains(&"burst"),
1664 "aggravator {} hangs off {:?} — one detector is one point of failure",
1665 rule.id,
1666 names
1667 );
1668 }
1669 }
1670
1671 #[test]
1677 fn a_join_flood_alone_asks_for_a_look_never_for_an_action() {
1678 let burst = default_policy()
1679 .rules
1680 .iter()
1681 .find(|r| matches!(r.matcher, Match::JoinBurst { .. }))
1682 .cloned()
1683 .expect("the defaults carry a join-flood rule");
1684 let weight = burst.weight.expect("an unarmed join flood needs a weight");
1685 let band = super::super::combine::band(super::super::combine::conf_pm(&[weight]));
1686 assert!(
1687 matches!(band, Band::Clear | Band::Noted | Band::Watch),
1688 "a join flood alone reached {band:?} — an invite link would be actioned as a raid"
1689 );
1690 }
1691
1692 #[test]
1696 fn stored_bytes_are_what_the_engine_hashes() {
1697 let p = default_policy();
1698 let bytes = serde_json::to_string(&p).unwrap();
1699 let hash = hash_policy_bytes(bytes.as_bytes());
1700 let parsed: Policy = serde_json::from_str(&bytes).unwrap();
1703 assert_eq!(parsed, p, "a policy survives its own round trip");
1704 assert_eq!(hash_policy_bytes(bytes.as_bytes()), hash);
1705 let pretty = serde_json::to_string_pretty(&p).unwrap();
1707 assert_ne!(hash_policy_bytes(pretty.as_bytes()), hash, "identity is the bytes, not the meaning");
1708 }
1709
1710 #[test]
1711 fn the_policy_hash_is_over_the_bytes_it_arrived_as() {
1712 let p = default_policy();
1713 let a = serde_json::to_vec(&p).unwrap();
1714 assert_eq!(hash_policy_bytes(&a), hash_policy_bytes(&a));
1718 let mut spaced = String::from_utf8(a.clone()).unwrap();
1719 spaced.push(' ');
1720 assert_ne!(hash_policy_bytes(&a), hash_policy_bytes(spaced.as_bytes()));
1721 }
1722}