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/// `(member, joined_at_ms, is_staff, role names, invite label)`.
165pub(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    // Distinct shapes per author, over the window — how someone speaks now is
202    // what tells a person from a script.
203    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        // Tenure for DISPLAY uses the same oldest-trace rule the shield does.
229        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/// What a policy WOULD do, without storing it or touching anyone.
265///
266/// This is the safety rail the designer is built around: a policy is never
267/// enabled from a form alone, it is enabled from a preview that named the
268/// members it would catch. An over-broad rule announces itself by catching
269/// regulars, and the fix is a button rather than a number.
270#[derive(Debug, Clone, serde::Serialize)]
271pub struct PolicyPreview {
272    pub valid: bool,
273    pub error: Option<String>,
274    /// Members this policy would flag, worst first.
275    pub flagged: Vec<PreviewRow>,
276    /// Members whose messages ALSO matched, and who were spared only because
277    /// they have standing. This is the number that tells an admin a rule
278    /// catches ordinary conversation: the flagged list can look small and
279    /// harmless while every regular in the room tripped the same wire.
280    pub shielded_matches: Vec<PreviewRow>,
281    pub messages_cited: usize,
282    pub corpus: usize,
283    /// Rules that could not run here, so a silent zero never reads as "clean".
284    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
299/// Evaluate a candidate policy against local history. Stores nothing, publishes
300/// nothing, removes nobody.
301pub 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    // Now the question the flagged list cannot answer: who ELSE matched, and
380    // was spared only by their standing? Re-evaluate with the trust bar raised
381    // out of reach — staff stay Protected, since a rule that needs to reach
382    // them is a different conversation — and take the difference.
383    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
435/// The policies a community actually runs: everything it has stored and
436/// enabled, or the built-in default when it has declared none.
437///
438/// A stored policy that no longer validates is loaded anyway and reported
439/// INERT by the engine — silently dropping it would tell a moderator their
440/// rules are running when they are not.
441pub 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
445/// Which policies actually run, given everything the community has stored.
446///
447/// A row under [`DEFAULTS_POLICY_ID`] is the community's own copy of the
448/// shipped defaults, and its PRESENCE is what stands them down — enabled or
449/// not, because a disabled fork means the admin turned raid and scam cover off
450/// on purpose. Everything else runs ALONGSIDE the defaults rather than instead
451/// of them: a word filter for spoilers must not silently drop scam-link cover.
452pub 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
470/// Evaluate a community's policies and render the console's report.
471pub 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    // How much history this verdict was reached over. Without it an empty
480    // suspect list reads as "this community is clean" when the truth may be
481    // "four days of it were looked at" — a distinction that decides whether a
482    // verdict means anything at all.
483    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
495/// When a member turned up, by any route.
496///
497/// Arrival, not paperwork. The Guestbook is ONE witness that somebody appeared;
498/// a member folded in from their first post (CORD-02 §5, "observed authors")
499/// arrived just as much as one who filed a Join.
500///
501/// Those two definitions disagreeing is a hole, not a nuance. Anyone holding a
502/// community's keys can post straight into it without ever publishing a Join,
503/// and `stored_memberlist` counts them as a member — so a join-flood rule
504/// reading only Guestbook entries sees a thousand such accounts as zero
505/// arrivals. Taking the earliest evidence closes that, and restores tenure for
506/// members whose Join was genuinely lost (a v1 migration, a pruned Guestbook)
507/// who would otherwise read as having never arrived at all.
508pub 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
512/// Screen ONE message, right now, against the community's stored policies.
513///
514/// Only stateless rules run — words, links, regex, mentions — because those are
515/// the only ones a single message can answer. Rate, repetition, cohorts and
516/// join bursts are statements about a window, and there is nothing for them to
517/// measure here; they belong to the periodic sweep and are silently absent
518/// rather than wrongly clean.
519///
520/// This is `EvalMode::Member`, the same path a sending client uses, so a
521/// verdict reached here is the verdict the console reaches later over the same
522/// text. Shields still gate: the owner and staff are not screened.
523/// The instant a screen evaluates at: a moment AFTER the message it is about.
524///
525/// `clamp_corpus` keeps `at_ms < now_ms` strictly — a message stamped at the
526/// evaluation instant is not yet history and no rule can see it. Screening a
527/// message at its own timestamp therefore judged an empty corpus and convicted
528/// nobody, ever.
529fn 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            // Tenure and volume are unknowable from one message, and no
554            // stateless rule reads them. Left unknown rather than invented.
555            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            // A screen happens before the message has an id of its own.
563            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/// What the moderation console needs about one member beyond the verdict:
605/// the display facts a moderator reads before acting.
606#[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
619/// A wire-frozen enum as the console's string. Serde, never `Debug`: the wire
620/// vocabulary is snake_case, and `format!("{:?}")` renders `PerMessage` as
621/// "permessage" — a consumer matching on it would silently never match.
622fn 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
626/// Turn an engine report into the shape the moderation console already speaks,
627/// so the panel changes its SOURCE without changing its contract. `raid.rs`
628/// keeps running beside it (see [`run_side_by_side`]) until the two have agreed
629/// on live data for long enough to retire one.
630pub 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    // Every law is scored independently, so the console folds ACROSS policies:
641    // a member's worst standing and every conviction any policy reached.
642    // Paired with its law: a conviction has to name the policy it came from, and
643    // the hash lives on the PolicyReport rather than the subject.
644    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    // What each citation actually matched, so a conviction can say the word
656    // rather than the rule id.
657    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    // Which message each citation points at. Message targets only: a profile
665    // field or an attachment is cited too, and neither is something to hide.
666    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        // (law, conviction) pairs: the prose below needs the conviction, the
683        // machine-readable findings need the law it was reached under.
684        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        // Shields are a property of the member, not of one law; every policy
691        // computes the same one, so the strongest answer is the answer.
692        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        // Band travels with the confidence that earned it: taking each
703        // separately across laws can report a band the score never reached.
704        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        // The console's four verdicts, from the engine's own vocabulary.
709        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        // Reasons in evidence, never in score — the panel shows WHY, and a
722        // moderator who disagrees can see exactly what they are disagreeing with.
723        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                // Quote what matched when we can: "used 'darn' 3 times" tells a
747                // moderator in one glance what "matched rule words" never could.
748                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        // The machine-readable spine beside the prose. `reasons` says what a
782        // person reads; this says what a program acts on — which rule, how
783        // grave its author called it, and which messages it cited. A consumer
784        // that has to parse English cannot tell a swear from a scam link.
785        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                    // Stable per (policy, rule, scope, rung, subject) and
794                    // deliberately NOT per hit, so a consumer can dedup across
795                    // polls; escalating a rung mints a new one, which is the
796                    // right moment to act again.
797                    "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                    // Could one message have settled this? Only content rules
804                    // admit PerMessage, so the scope already answers it — and a
805                    // consumer that screens live AND sweeps needs to know which
806                    // findings the other clock has already answered, or one
807                    // offense is counted twice.
808                    "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            // The standing itself, beside the console's word for it: a Trusted
823            // member pierced by a grave rule reads "suspect", which answers what
824            // to show and loses what may be done to them.
825            "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        // Absent rather than empty for the clean majority: 512 members may each
842        // hold 16 convictions, and this crosses an IPC boundary Android feels.
843        if !findings.is_empty() {
844            row["findings"] = serde_json::Value::Array(findings);
845        }
846        members.push(row);
847    }
848    // Suspects first — the panel opens on what needs deciding — then the people
849    // who hold this place together, and the unremarkable majority last. Sorting
850    // neutral above trusted buried every regular below a hundred rows of nobody
851    // in particular.
852    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    // Cohort exhibits, largest first.
866    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/// One member's verdict from each side, for eyeballing where they differ.
910#[derive(Debug, Clone, serde::Serialize)]
911pub struct VerdictRow {
912    pub npub: String,
913    /// What `raid.rs` said.
914    pub assessor: String,
915    /// What the engine scored.
916    pub confidence: u32,
917    pub proven: u32,
918    pub band: String,
919    pub shield: String,
920    pub rules: Vec<String>,
921}
922
923/// What the side-by-side run found.
924#[derive(Debug, Clone, serde::Serialize)]
925pub struct DiffReport {
926    pub members: usize,
927    pub corpus: usize,
928    /// Members the engine convicted (any conviction).
929    pub engine_convicted: usize,
930    /// Members `raid.rs` calls Suspect.
931    pub assessor_suspects: usize,
932    /// Convicted by the engine, cleared by the assessor — and the reverse.
933    pub engine_only: Vec<VerdictRow>,
934    pub assessor_only: Vec<VerdictRow>,
935    /// Both agree something is wrong.
936    pub agreed: Vec<VerdictRow>,
937    /// Shield distribution, so a mis-shielded roster is visible immediately.
938    pub protected: usize,
939    pub trusted: usize,
940    pub indeterminate: usize,
941    /// Rule-level states — a rule that could not evaluate says so.
942    pub rule_states: Vec<(String, String)>,
943    pub coverage_complete: bool,
944    /// Wall-clock cost, split so a slow run says WHICH half is slow: reading
945    /// and decrypting local state, versus the pure evaluation.
946    pub signals_ms: u64,
947    pub evaluate_ms: u64,
948}
949
950/// Assemble signals from local state and evaluate. Reads only; the engine is
951/// pure, so this is safe to run against a live community at any time.
952pub 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    // Lifetime footprints: standing is historical, so a quiet week must not
982    // cost a regular the standing they built over months.
983    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        // The harness reads local state, so it makes no coverage claim: the
1012        // caller has not proven an EOSE-confirmed range here.
1013        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    // A member the assessor suspects but the engine never scored at all is
1067    // still a disagreement worth seeing.
1068    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    /// A screened message sits at `now_ms`, and `clamp_corpus` keeps
1111    /// `at_ms < now_ms` STRICTLY — so the one message being screened fell
1112    /// outside its own corpus and the live text lane convicted nobody, ever.
1113    #[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        // Exactly the shape `screen_message` builds.
1153        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    /// The console builds each member by hand, and it left `band` out. Every
1187    /// consumer that gates on it — the SDK's `actionable()`, `needs_human()`
1188    /// and the whole autopilot — read the default "clear" and acted on nobody,
1189    /// which looks exactly like a community where nothing happened.
1190    #[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        // Laws are scored independently and the console folds across them, so
1242        // the band has to travel with the confidence it belongs to.
1243        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    /// A Trusted member CAN carry a conviction: `pierces_trusted` exists exactly
1249    /// so a grave rule reaches them. The console then calls them "suspect",
1250    /// which answers what to SHOW and says nothing about what may be done —
1251    /// so their standing has to travel as its own field. A consumer inferring
1252    /// the shield from the word strips it at the one moment it matters.
1253    #[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    /// `reasons` is prose for a person. A bot ramping its response — a warning
1316    /// for a swear, a ban for a scam link — has to branch on the RULE, and it
1317    /// must never do that by parsing English.
1318    #[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    /// A word filter answering on the next 90-second tick is not a word
1431    /// filter. The stateless rules settle per message; the windowed ones are
1432    /// ABSENT here rather than wrongly clean.
1433    #[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        // No stored policy: nothing to screen against, and no panic either.
1442        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        // The stateless matchers are exactly the ones a single message can settle.
1446        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    /// A wave that posts straight into a community without ever filing a Join
1464    /// is a member by `stored_memberlist` and used to be zero arrivals to a
1465    /// join-flood rule. Both routes count now.
1466    #[test]
1467    fn arrival_counts_every_way_in_not_just_the_paperwork() {
1468        // The ordinary member: a Join, and posts after it.
1469        assert_eq!(arrival(Some(1_000), Some(5_000)), Some(1_000));
1470        // The cheat: no Join at all, straight to posting. Used to be None,
1471        // which the burst rule reads as "not evidence of a join".
1472        assert_eq!(arrival(None, Some(5_000)), Some(5_000), "posting IS arriving");
1473        // A member whose Join was lost but who was already talking: the earlier
1474        // trace wins, so they keep the tenure they earned.
1475        assert_eq!(arrival(Some(9_000), Some(2_000)), Some(2_000));
1476        // Nothing at all is still unknowable, and unknown is never a conviction.
1477        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    /// The bug this replaced: any stored policy returned early, so enabling a
1507    /// word filter for spoilers silently switched scam-link and raid cover off
1508    /// while the console still said "always on".
1509    #[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    /// Forking is the ONLY way to change the defaults, so the fork has to be
1526    /// what runs — two copies of the same rules would double every weight.
1527    #[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    /// Turning the fork off is a deliberate act, and it has to stick: silently
1534    /// restoring the shipped defaults would make the switch a lie.
1535    #[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    /// The shipped defaults must never decide what a community may say or link
1545    /// to. A denylist baked into the client makes that call for every community
1546    /// at once while looking like a rule of the protocol; the bundled shortener
1547    /// list belongs to the Scam Links template, where switching it on is
1548    /// somebody's decision.
1549    #[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    /// Spam, mass-tagging and word lists are a community's taste, switched on
1563    /// from a template when it wants them. Shipping any of them on by default
1564    /// decides that taste for every community at once.
1565    #[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    /// Whatever else changes, the swarm shape is the thing a community cannot
1577    /// hand-configure before it is attacked, so it stays in the defaults.
1578    #[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    /// The console's "always on" badge and the engine's loader must never
1592    /// disagree: one flag says cover is running, the other decides whether it
1593    /// actually does.
1594    #[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    /// Every kind the from-scratch builder offers has to survive validation on
1612    /// its own, and none may be a weak signal that needs arming.
1613    #[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    /// The first live run convicted 147 of 155 members on "has posted at most
1631    /// twice" alone. A weak signal must never speak by itself — that includes
1632    /// a join burst, which is exactly what a freshly-posted invite link looks
1633    /// like.
1634    #[test]
1635    fn every_weak_signal_is_armed_by_a_real_conviction() {
1636        let p = default_policy();
1637        for rule in &p.rules {
1638            // "Joined recently" and "has barely posted" describe most of a
1639            // healthy community — the first live run convicted 147 of 155 on the
1640            // second one alone. These are only ever true ABOUT someone a real
1641            // detector already caught.
1642            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    /// The reason the second path exists: a raid whose accounts each say
1651    /// something DIFFERENT never forms a cohort cluster, and before this every
1652    /// downstream signal was armed by that one rule and stayed silent with it.
1653    #[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    /// A join flood is now a conviction path of its own, so that it can catch a
1673    /// raid whose accounts vary their text and `cohort` never clusters. The price
1674    /// is that a freshly posted invite link looks the same, so the rule must stay
1675    /// too weak to act on by itself: it takes an aggravator to lift it, and a
1676    /// wave of real people who then TALK never supplies one.
1677    #[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    /// Round-tripping the bytes must not change them: the hash is over exactly
1694    /// what was stored, and one day exactly what a control-plane edition
1695    /// carries to every device.
1696    #[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        // Parse and re-hash the ORIGINAL bytes, not the re-serialization —
1702        // reserialising is exactly the mistake the wire cannot tolerate.
1703        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        // Pretty-printing is semantically identical and hashes DIFFERENTLY.
1707        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        // Same bytes, same hash; different bytes (even semantically equal
1716        // JSON), different hash — which is exactly why the wire hashes bytes
1717        // rather than a re-serialization.
1718        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}