Skip to main content

vector_core/community/policy/
harness.rs

1//! Side-by-side harness: run the engine against a live community and diff its
2//! verdicts against the shipped assessor.
3//!
4//! The engine convicts nothing in production yet. `raid.rs` keeps driving the
5//! moderation console while this runs alongside, so a disagreement is a finding
6//! to read rather than a member wrongly removed. Everything here is diagnostic:
7//! it reads local state, evaluates, and reports — it publishes nothing and
8//! changes no membership.
9
10use 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
18/// Hash a policy the way the wire will: over the exact bytes it arrived as,
19/// never a re-serialization.
20pub 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
31/// The reserved id a community's fork of the shipped defaults is stored under.
32/// Writing this id is the only way to stand the defaults down.
33pub const DEFAULTS_POLICY_ID: &str = "vector_defaults";
34
35/// The span a repeat rule counts inside. Malicious repetition is a burst;
36/// saying "gm" every morning is not, and a seven-day count cannot tell them
37/// apart.
38pub const REPEAT_BURST_SECS: u64 = 30 * 60;
39
40/// What every community gets without asking: raid detection, and nothing else.
41///
42/// Not spam, not mass-tagging, not words. Those are a community's taste and it
43/// can switch them on from a template; a swarm is the one thing nobody can be
44/// expected to hand-configure before it arrives.
45///
46/// Deliberately NOT a link blocker. Which domains a community will not host is
47/// its own call, and a list baked into the client makes that call for everyone
48/// while looking like a law of the protocol. The bundled shortener list lives
49/// in the Scam Links template instead, where turning it on is a choice someone
50/// made. What is left here is the one thing no community can be expected to
51/// hand-configure before it is attacked: the shape of a swarm.
52pub fn default_policy() -> Policy {
53    Policy {
54        format: FORMAT,
55        // `repeat` bounds its count to a burst; an engine that does not know
56        // the field must go inert rather than count across the whole week.
57        requires: vec!["armed_by_any".into()],
58        name: "raid-detection".into(),
59        emoji_codes: vec![],
60        // A WEEK, not a day. Shield inputs are measured over the declared
61        // window (that is what keeps them identical across clients), so a
62        // 24-hour window asks members to earn trust daily and almost nobody
63        // does: the first live run trusted 1 of 155.
64        shields: Shields::default(),
65        window: Window { hours: 168, max_messages: 4000 },
66        exempt: Exempt::default(),
67        rules: vec![
68            // The raid shape: many identities, one line each. Heuristic, so it
69            // flags for a human and never feeds `proven`.
70            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            // The SECOND conviction path, for a raid whose accounts each say
83            // something different and never form a cohort cluster.
84            //
85            // A join flood is also what a popular invite link looks like, and the
86            // fresh-account aggravator does NOT tell them apart — it fires on
87            // anyone under a day old, talking or not (`quiet` folds into it at
88            // max, so posting changes nothing). The only thing separating the two
89            // is RATE, so the threshold carries that weight alone: twelve joins
90            // inside five minutes is a join every twenty-five seconds sustained,
91            // which is a script rather than a good afternoon.
92            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            // Aggravators, and ONLY aggravators: each fires only for a subject
105            // the link rule already convicted. Unarmed, "has posted at most
106            // twice" describes most of a healthy community — the first live run
107            // convicted 147 of 155 on it alone, which is exactly the
108            // weak-signal-convicts-nobody rule being violated by a policy the
109            // engine faithfully executed.
110            Rule {
111                id: "fresh".into(),
112                matcher: Match::TenureLt { secs: 24 * 3600 },
113                tiers: None,
114                severity: Some(Severity::Notice),
115                // Twelve, not twenty: an aggravator must AGGRAVATE, never carry a
116                // lone detector across a band on its own. At 20 a bare join flood
117                // reached 52 and the console staged a whole invite wave for
118                // removal; at 12 the same pair lands at 47 — a look, not a verdict
119                // — while a real raid, which trips a second INDEPENDENT detector,
120                // still stacks to the same 92 it always did.
121                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
154/// Everything an evaluation needs, assembled from local state once: the engine
155/// inputs and the display facts the console shows beside each verdict. Both the
156/// console and the side-by-side diff read this, so they can never disagree
157/// about what they were looking at.
158pub 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    // Distinct shapes per author, over the window — how someone speaks now is
200    // what tells a person from a script.
201    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        // Tenure for DISPLAY uses the same oldest-trace rule the shield does.
227        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/// What a policy WOULD do, without storing it or touching anyone.
263///
264/// This is the safety rail the designer is built around: a policy is never
265/// enabled from a form alone, it is enabled from a preview that named the
266/// members it would catch. An over-broad rule announces itself by catching
267/// regulars, and the fix is a button rather than a number.
268#[derive(Debug, Clone, serde::Serialize)]
269pub struct PolicyPreview {
270    pub valid: bool,
271    pub error: Option<String>,
272    /// Members this policy would flag, worst first.
273    pub flagged: Vec<PreviewRow>,
274    /// Members whose messages ALSO matched, and who were spared only because
275    /// they have standing. This is the number that tells an admin a rule
276    /// catches ordinary conversation: the flagged list can look small and
277    /// harmless while every regular in the room tripped the same wire.
278    pub shielded_matches: Vec<PreviewRow>,
279    pub messages_cited: usize,
280    pub corpus: usize,
281    /// Rules that could not run here, so a silent zero never reads as "clean".
282    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
297/// Evaluate a candidate policy against local history. Stores nothing, publishes
298/// nothing, removes nobody.
299pub 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    // Now the question the flagged list cannot answer: who ELSE matched, and
378    // was spared only by their standing? Re-evaluate with the trust bar raised
379    // out of reach — staff stay Protected, since a rule that needs to reach
380    // them is a different conversation — and take the difference.
381    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
433/// The policies a community actually runs: everything it has stored and
434/// enabled, or the built-in default when it has declared none.
435///
436/// A stored policy that no longer validates is loaded anyway and reported
437/// INERT by the engine — silently dropping it would tell a moderator their
438/// rules are running when they are not.
439pub 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
443/// Which policies actually run, given everything the community has stored.
444///
445/// A row under [`DEFAULTS_POLICY_ID`] is the community's own copy of the
446/// shipped defaults, and its PRESENCE is what stands them down — enabled or
447/// not, because a disabled fork means the admin turned raid and scam cover off
448/// on purpose. Everything else runs ALONGSIDE the defaults rather than instead
449/// of them: a word filter for spoilers must not silently drop scam-link cover.
450pub 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
468/// Evaluate a community's policies and render the console's report.
469pub 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    // How much history this verdict was reached over. Without it an empty
478    // suspect list reads as "this community is clean" when the truth may be
479    // "four days of it were looked at" — a distinction that decides whether a
480    // verdict means anything at all.
481    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
493/// When a member turned up, by any route.
494///
495/// Arrival, not paperwork. The Guestbook is ONE witness that somebody appeared;
496/// a member folded in from their first post (CORD-02 §5, "observed authors")
497/// arrived just as much as one who filed a Join.
498///
499/// Those two definitions disagreeing is a hole, not a nuance. Anyone holding a
500/// community's keys can post straight into it without ever publishing a Join,
501/// and `stored_memberlist` counts them as a member — so a join-flood rule
502/// reading only Guestbook entries sees a thousand such accounts as zero
503/// arrivals. Taking the earliest evidence closes that, and restores tenure for
504/// members whose Join was genuinely lost (a v1 migration, a pruned Guestbook)
505/// who would otherwise read as having never arrived at all.
506pub 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
510/// Screen ONE message, right now, against the community's stored policies.
511///
512/// Only stateless rules run — words, links, regex, mentions — because those are
513/// the only ones a single message can answer. Rate, repetition, cohorts and
514/// join bursts are statements about a window, and there is nothing for them to
515/// measure here; they belong to the periodic sweep and are silently absent
516/// rather than wrongly clean.
517///
518/// This is `EvalMode::Member`, the same path a sending client uses, so a
519/// verdict reached here is the verdict the console reaches later over the same
520/// text. Shields still gate: the owner and staff are not screened.
521/// The instant a screen evaluates at: a moment AFTER the message it is about.
522///
523/// `clamp_corpus` keeps `at_ms < now_ms` strictly — a message stamped at the
524/// evaluation instant is not yet history and no rule can see it. Screening a
525/// message at its own timestamp therefore judged an empty corpus and convicted
526/// nobody, ever.
527fn 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            // Tenure and volume are unknowable from one message, and no
551            // stateless rule reads them. Left unknown rather than invented.
552            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            // A screen happens before the message has an id of its own.
560            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/// What the moderation console needs about one member beyond the verdict:
602/// the display facts a moderator reads before acting.
603#[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
616/// A wire-frozen enum as the console's string. Serde, never `Debug`: the wire
617/// vocabulary is snake_case, and `format!("{:?}")` renders `PerMessage` as
618/// "permessage" — a consumer matching on it would silently never match.
619fn 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
623/// Turn an engine report into the shape the moderation console already speaks,
624/// so the panel changes its SOURCE without changing its contract. `raid.rs`
625/// keeps running beside it (see [`run_side_by_side`]) until the two have agreed
626/// on live data for long enough to retire one.
627pub 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    // Every law is scored independently, so the console folds ACROSS policies:
638    // a member's worst standing and every conviction any policy reached.
639    // Paired with its law: a conviction has to name the policy it came from, and
640    // the hash lives on the PolicyReport rather than the subject.
641    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    // What each citation actually matched, so a conviction can say the word
653    // rather than the rule id.
654    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    // Which message each citation points at. Message targets only: a profile
662    // field or an attachment is cited too, and neither is something to hide.
663    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        // (law, conviction) pairs: the prose below needs the conviction, the
680        // machine-readable findings need the law it was reached under.
681        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        // Shields are a property of the member, not of one law; every policy
688        // computes the same one, so the strongest answer is the answer.
689        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        // Band travels with the confidence that earned it: taking each
700        // separately across laws can report a band the score never reached.
701        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        // The console's four verdicts, from the engine's own vocabulary.
706        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        // Reasons in evidence, never in score — the panel shows WHY, and a
719        // moderator who disagrees can see exactly what they are disagreeing with.
720        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                // Quote what matched when we can: "used 'darn' 3 times" tells a
744                // moderator in one glance what "matched rule words" never could.
745                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        // The machine-readable spine beside the prose. `reasons` says what a
779        // person reads; this says what a program acts on — which rule, how
780        // grave its author called it, and which messages it cited. A consumer
781        // that has to parse English cannot tell a swear from a scam link.
782        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                    // Stable per (policy, rule, scope, rung, subject) and
791                    // deliberately NOT per hit, so a consumer can dedup across
792                    // polls; escalating a rung mints a new one, which is the
793                    // right moment to act again.
794                    "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                    // Could one message have settled this? Only content rules
801                    // admit PerMessage, so the scope already answers it — and a
802                    // consumer that screens live AND sweeps needs to know which
803                    // findings the other clock has already answered, or one
804                    // offense is counted twice.
805                    "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            // The standing itself, beside the console's word for it: a Trusted
820            // member pierced by a grave rule reads "suspect", which answers what
821            // to show and loses what may be done to them.
822            "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        // Absent rather than empty for the clean majority: 512 members may each
839        // hold 16 convictions, and this crosses an IPC boundary Android feels.
840        if !findings.is_empty() {
841            row["findings"] = serde_json::Value::Array(findings);
842        }
843        members.push(row);
844    }
845    // Suspects first — the panel opens on what needs deciding — then the people
846    // who hold this place together, and the unremarkable majority last. Sorting
847    // neutral above trusted buried every regular below a hundred rows of nobody
848    // in particular.
849    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    // Cohort exhibits, largest first.
863    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/// One member's verdict from each side, for eyeballing where they differ.
909#[derive(Debug, Clone, serde::Serialize)]
910pub struct VerdictRow {
911    pub npub: String,
912    /// What `raid.rs` said.
913    pub assessor: String,
914    /// What the engine scored.
915    pub confidence: u32,
916    pub proven: u32,
917    pub band: String,
918    pub shield: String,
919    pub rules: Vec<String>,
920}
921
922/// What the side-by-side run found.
923#[derive(Debug, Clone, serde::Serialize)]
924pub struct DiffReport {
925    pub members: usize,
926    pub corpus: usize,
927    /// Members the engine convicted (any conviction).
928    pub engine_convicted: usize,
929    /// Members `raid.rs` calls Suspect.
930    pub assessor_suspects: usize,
931    /// Convicted by the engine, cleared by the assessor — and the reverse.
932    pub engine_only: Vec<VerdictRow>,
933    pub assessor_only: Vec<VerdictRow>,
934    /// Both agree something is wrong.
935    pub agreed: Vec<VerdictRow>,
936    /// Shield distribution, so a mis-shielded roster is visible immediately.
937    pub protected: usize,
938    pub trusted: usize,
939    pub indeterminate: usize,
940    /// Rule-level states — a rule that could not evaluate says so.
941    pub rule_states: Vec<(String, String)>,
942    pub coverage_complete: bool,
943    /// Wall-clock cost, split so a slow run says WHICH half is slow: reading
944    /// and decrypting local state, versus the pure evaluation.
945    pub signals_ms: u64,
946    pub evaluate_ms: u64,
947}
948
949/// Assemble signals from local state and evaluate. Reads only; the engine is
950/// pure, so this is safe to run against a live community at any time.
951pub 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    // Lifetime footprints: standing is historical, so a quiet week must not
981    // cost a regular the standing they built over months.
982    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        // The harness reads local state, so it makes no coverage claim: the
1011        // caller has not proven an EOSE-confirmed range here.
1012        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    // A member the assessor suspects but the engine never scored at all is
1066    // still a disagreement worth seeing.
1067    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    /// A screened message sits at `now_ms`, and `clamp_corpus` keeps
1110    /// `at_ms < now_ms` STRICTLY — so the one message being screened fell
1111    /// outside its own corpus and the live text lane convicted nobody, ever.
1112    #[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        // Exactly the shape `screen_message` builds.
1152        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    /// The console builds each member by hand, and it left `band` out. Every
1186    /// consumer that gates on it — the SDK's `actionable()`, `needs_human()`
1187    /// and the whole autopilot — read the default "clear" and acted on nobody,
1188    /// which looks exactly like a community where nothing happened.
1189    #[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        // Laws are scored independently and the console folds across them, so
1241        // the band has to travel with the confidence it belongs to.
1242        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    /// A Trusted member CAN carry a conviction: `pierces_trusted` exists exactly
1248    /// so a grave rule reaches them. The console then calls them "suspect",
1249    /// which answers what to SHOW and says nothing about what may be done —
1250    /// so their standing has to travel as its own field. A consumer inferring
1251    /// the shield from the word strips it at the one moment it matters.
1252    #[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    /// `reasons` is prose for a person. A bot ramping its response — a warning
1315    /// for a swear, a ban for a scam link — has to branch on the RULE, and it
1316    /// must never do that by parsing English.
1317    #[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    /// A word filter answering on the next 90-second tick is not a word
1430    /// filter. The stateless rules settle per message; the windowed ones are
1431    /// ABSENT here rather than wrongly clean.
1432    #[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        // No stored policy: nothing to screen against, and no panic either.
1441        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        // The stateless matchers are exactly the ones a single message can settle.
1445        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    /// A wave that posts straight into a community without ever filing a Join
1463    /// is a member by `stored_memberlist` and used to be zero arrivals to a
1464    /// join-flood rule. Both routes count now.
1465    #[test]
1466    fn arrival_counts_every_way_in_not_just_the_paperwork() {
1467        // The ordinary member: a Join, and posts after it.
1468        assert_eq!(arrival(Some(1_000), Some(5_000)), Some(1_000));
1469        // The cheat: no Join at all, straight to posting. Used to be None,
1470        // which the burst rule reads as "not evidence of a join".
1471        assert_eq!(arrival(None, Some(5_000)), Some(5_000), "posting IS arriving");
1472        // A member whose Join was lost but who was already talking: the earlier
1473        // trace wins, so they keep the tenure they earned.
1474        assert_eq!(arrival(Some(9_000), Some(2_000)), Some(2_000));
1475        // Nothing at all is still unknowable, and unknown is never a conviction.
1476        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    /// The bug this replaced: any stored policy returned early, so enabling a
1506    /// word filter for spoilers silently switched scam-link and raid cover off
1507    /// while the console still said "always on".
1508    #[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    /// Forking is the ONLY way to change the defaults, so the fork has to be
1525    /// what runs — two copies of the same rules would double every weight.
1526    #[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    /// Turning the fork off is a deliberate act, and it has to stick: silently
1533    /// restoring the shipped defaults would make the switch a lie.
1534    #[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    /// The shipped defaults must never decide what a community may say or link
1544    /// to. A denylist baked into the client makes that call for every community
1545    /// at once while looking like a rule of the protocol; the bundled shortener
1546    /// list belongs to the Scam Links template, where switching it on is
1547    /// somebody's decision.
1548    #[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    /// Spam, mass-tagging and word lists are a community's taste, switched on
1562    /// from a template when it wants them. Shipping any of them on by default
1563    /// decides that taste for every community at once.
1564    #[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    /// Whatever else changes, the swarm shape is the thing a community cannot
1576    /// hand-configure before it is attacked, so it stays in the defaults.
1577    #[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    /// The console's "always on" badge and the engine's loader must never
1591    /// disagree: one flag says cover is running, the other decides whether it
1592    /// actually does.
1593    #[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    /// Every kind the from-scratch builder offers has to survive validation on
1611    /// its own, and none may be a weak signal that needs arming.
1612    #[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    /// The first live run convicted 147 of 155 members on "has posted at most
1630    /// twice" alone. A weak signal must never speak by itself — that includes
1631    /// a join burst, which is exactly what a freshly-posted invite link looks
1632    /// like.
1633    #[test]
1634    fn every_weak_signal_is_armed_by_a_real_conviction() {
1635        let p = default_policy();
1636        for rule in &p.rules {
1637            // "Joined recently" and "has barely posted" describe most of a
1638            // healthy community — the first live run convicted 147 of 155 on the
1639            // second one alone. These are only ever true ABOUT someone a real
1640            // detector already caught.
1641            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    /// The reason the second path exists: a raid whose accounts each say
1650    /// something DIFFERENT never forms a cohort cluster, and before this every
1651    /// downstream signal was armed by that one rule and stayed silent with it.
1652    #[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    /// A join flood is now a conviction path of its own, so that it can catch a
1672    /// raid whose accounts vary their text and `cohort` never clusters. The price
1673    /// is that a freshly posted invite link looks the same, so the rule must stay
1674    /// too weak to act on by itself: it takes an aggravator to lift it, and a
1675    /// wave of real people who then TALK never supplies one.
1676    #[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    /// Round-tripping the bytes must not change them: the hash is over exactly
1693    /// what was stored, and one day exactly what a control-plane edition
1694    /// carries to every device.
1695    #[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        // Parse and re-hash the ORIGINAL bytes, not the re-serialization —
1701        // reserialising is exactly the mistake the wire cannot tolerate.
1702        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        // Pretty-printing is semantically identical and hashes DIFFERENTLY.
1706        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        // Same bytes, same hash; different bytes (even semantically equal
1715        // JSON), different hash — which is exactly why the wire hashes bytes
1716        // rather than a re-serialization.
1717        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}