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