Skip to main content

bones_core/verify/
redact.rs

1//! Redaction completeness verification.
2//!
3//! Verifies that all `item.redact` events have been fully applied:
4//! redacted content must be absent from projection rows, FTS5 index,
5//! and comment bodies.
6//!
7//! # Approach
8//!
9//! 1. Replay the event log to find all `item.redact` events and their targets.
10//! 2. For each redaction, look up the original event content.
11//! 3. Check every query surface (projection, FTS5, comments) for residual content.
12//! 4. Report any failures with precise location information.
13
14use std::collections::HashMap;
15use std::path::Path;
16
17use anyhow::{Context, Result};
18use rusqlite::{Connection, params};
19use serde::Serialize;
20
21use crate::event::Event;
22use crate::event::data::EventData;
23use crate::event::parser::parse_lines;
24use crate::event::types::EventType;
25use crate::shard::ShardManager;
26
27// ---------------------------------------------------------------------------
28// Report types
29// ---------------------------------------------------------------------------
30
31/// Report from verifying redaction completeness.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct RedactionReport {
34    /// Number of redaction events checked.
35    pub redactions_checked: usize,
36    /// Number that passed (content confirmed absent from all surfaces).
37    pub passed: usize,
38    /// Number that failed (residual content found somewhere).
39    pub failed: usize,
40    /// Details of each failure.
41    pub failures: Vec<RedactionFailure>,
42}
43
44impl RedactionReport {
45    /// Returns `true` if all redactions passed verification.
46    #[must_use]
47    pub const fn is_ok(&self) -> bool {
48        self.failed == 0
49    }
50}
51
52/// Details of one failed redaction check.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct RedactionFailure {
55    /// The item ID whose redaction is incomplete.
56    pub item_id: String,
57    /// The original event hash that was supposed to be redacted.
58    pub event_hash: String,
59    /// Where residual content was found.
60    pub residual_locations: Vec<ResidualLocation>,
61}
62
63/// A location where residual (un-redacted) content was found.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub enum ResidualLocation {
66    /// Redaction record missing from `event_redactions` table.
67    MissingRedactionRecord,
68    /// Comment body not replaced with `[redacted]`.
69    CommentNotRedacted {
70        /// The comment ID in the projection.
71        comment_id: i64,
72    },
73    /// Content found in FTS5 index (the redacted text is still searchable).
74    Fts5Index {
75        /// The search term that matched.
76        matched_term: String,
77    },
78}
79
80// ---------------------------------------------------------------------------
81// Parsed redaction context
82// ---------------------------------------------------------------------------
83
84/// A redact event paired with the content it targets.
85#[derive(Debug)]
86struct RedactionTarget {
87    /// The item ID being redacted.
88    item_id: String,
89    /// The target event hash being redacted.
90    target_hash: String,
91    /// The redaction reason.
92    _reason: String,
93    /// The original event's type (if found in the log).
94    original_event_type: Option<EventType>,
95    /// Searchable text from the original event (for FTS residual checks).
96    original_text: Option<String>,
97}
98
99// ---------------------------------------------------------------------------
100// Public API
101// ---------------------------------------------------------------------------
102
103/// Verify that all `item.redact` events have been fully applied.
104///
105/// Replays the event log to find all `item.redact` events, then checks every
106/// projection surface for residual content.
107///
108/// # Arguments
109///
110/// * `events_dir` — Path to `.bones/events/` directory
111/// * `db` — Open `SQLite` connection to the projection database
112///
113/// # Errors
114///
115/// Returns an error if the event log cannot be read or parsed.
116pub fn verify_redactions(events_dir: &Path, db: &Connection) -> Result<RedactionReport> {
117    let targets = collect_redaction_targets(events_dir)?;
118    let total = targets.len();
119
120    let mut failures = Vec::new();
121    for target in &targets {
122        let locs = check_residuals(db, target)?;
123        if !locs.is_empty() {
124            failures.push(RedactionFailure {
125                item_id: target.item_id.clone(),
126                event_hash: target.target_hash.clone(),
127                residual_locations: locs,
128            });
129        }
130    }
131
132    let failed = failures.len();
133    Ok(RedactionReport {
134        redactions_checked: total,
135        passed: total - failed,
136        failed,
137        failures,
138    })
139}
140
141/// Verify redaction for a single item.
142///
143/// Finds all `item.redact` events targeting the given `item_id` and checks
144/// for residual content.
145///
146/// # Arguments
147///
148/// * `item_id` — The item ID to verify
149/// * `events_dir` — Path to `.bones/events/` directory
150/// * `db` — Open `SQLite` connection to the projection database
151///
152/// # Errors
153///
154/// Returns an error if the event log cannot be read or parsed.
155pub fn verify_item_redaction(
156    item_id: &str,
157    events_dir: &Path,
158    db: &Connection,
159) -> Result<Vec<RedactionFailure>> {
160    let all_targets = collect_redaction_targets(events_dir)?;
161    let item_targets: Vec<_> = all_targets
162        .into_iter()
163        .filter(|t| t.item_id == item_id)
164        .collect();
165
166    let mut failures = Vec::new();
167    for target in &item_targets {
168        let locs = check_residuals(db, target)?;
169        if !locs.is_empty() {
170            failures.push(RedactionFailure {
171                item_id: target.item_id.clone(),
172                event_hash: target.target_hash.clone(),
173                residual_locations: locs,
174            });
175        }
176    }
177    Ok(failures)
178}
179
180// ---------------------------------------------------------------------------
181// Internal: collect redaction targets from the event log
182// ---------------------------------------------------------------------------
183
184/// Parse all events and build a map of redaction targets with their
185/// original content.
186fn collect_redaction_targets(events_dir: &Path) -> Result<Vec<RedactionTarget>> {
187    let dot = Path::new(".");
188    let bones_dir = events_dir.parent().unwrap_or(dot);
189    let shard_mgr = ShardManager::new(bones_dir);
190
191    let content = shard_mgr
192        .replay()
193        .map_err(|e| anyhow::anyhow!("replay shards: {e}"))?;
194
195    if content.trim().is_empty() {
196        return Ok(Vec::new());
197    }
198
199    let events = parse_lines(&content)
200        .map_err(|(line_num, e)| anyhow::anyhow!("parse error at line {line_num}: {e}"))?;
201
202    // Build a hash → event index for looking up original events.
203    let events_by_hash: HashMap<&str, &Event> =
204        events.iter().map(|e| (e.event_hash.as_str(), e)).collect();
205
206    // Find all redact events and pair them with their targets.
207    let mut targets = Vec::new();
208    for event in &events {
209        if event.event_type != EventType::Redact {
210            continue;
211        }
212        let EventData::Redact(ref redact_data) = event.data else {
213            continue;
214        };
215
216        let original = events_by_hash.get(redact_data.target_hash.as_str());
217        let (original_event_type, original_text) = original.map_or((None, None), |orig| {
218            (Some(orig.event_type), extract_searchable_text(orig))
219        });
220
221        targets.push(RedactionTarget {
222            item_id: event.item_id.as_str().to_string(),
223            target_hash: redact_data.target_hash.clone(),
224            _reason: redact_data.reason.clone(),
225            original_event_type,
226            original_text,
227        });
228    }
229
230    Ok(targets)
231}
232
233/// Extract searchable text from an event for FTS residual checking.
234///
235/// Returns the concatenation of meaningful text fields from the event
236/// payload, which should NOT appear in any search index after redaction.
237fn extract_searchable_text(event: &Event) -> Option<String> {
238    match &event.data {
239        EventData::Comment(d) => Some(d.body.clone()),
240        EventData::Create(d) => {
241            let mut parts = vec![d.title.clone()];
242            if let Some(ref desc) = d.description {
243                parts.push(desc.clone());
244            }
245            Some(parts.join(" "))
246        }
247        EventData::Update(d) => {
248            // The value field may contain text content.
249            Some(
250                d.value
251                    .as_str()
252                    .map_or_else(|| d.value.to_string(), str::to_string),
253            )
254        }
255        EventData::Compact(d) => Some(d.summary.clone()),
256        _ => None,
257    }
258}
259
260// ---------------------------------------------------------------------------
261// Internal: check residuals in each query surface
262// ---------------------------------------------------------------------------
263
264/// Check all projection surfaces for residual un-redacted content.
265fn check_residuals(db: &Connection, target: &RedactionTarget) -> Result<Vec<ResidualLocation>> {
266    let mut locations = Vec::new();
267
268    // 1. Check that the redaction record exists in event_redactions table.
269    check_redaction_record(db, target, &mut locations)?;
270
271    // 2. If the target was a comment event, check that the comment body
272    //    has been replaced with '[redacted]'.
273    check_comment_redacted(db, target, &mut locations)?;
274
275    // 3. If we have the original text, check FTS5 for residual content.
276    check_fts5_residual(db, target, &mut locations)?;
277
278    Ok(locations)
279}
280
281/// Verify that an `event_redactions` record exists for this target.
282fn check_redaction_record(
283    db: &Connection,
284    target: &RedactionTarget,
285    locations: &mut Vec<ResidualLocation>,
286) -> Result<()> {
287    let exists: bool = db
288        .query_row(
289            "SELECT EXISTS(SELECT 1 FROM event_redactions WHERE target_event_hash = ?1)",
290            params![target.target_hash],
291            |row| row.get(0),
292        )
293        .context("check event_redactions for target hash")?;
294
295    if !exists {
296        locations.push(ResidualLocation::MissingRedactionRecord);
297    }
298    Ok(())
299}
300
301/// If the targeted event was a comment, verify its body is `[redacted]`.
302fn check_comment_redacted(
303    db: &Connection,
304    target: &RedactionTarget,
305    locations: &mut Vec<ResidualLocation>,
306) -> Result<()> {
307    // Check if a comment with this event_hash exists and has un-redacted body.
308    let mut stmt = db
309        .prepare("SELECT comment_id, body FROM item_comments WHERE event_hash = ?1")
310        .context("prepare comment redaction check")?;
311
312    let rows: Vec<(i64, String)> = stmt
313        .query_map(params![target.target_hash], |row| {
314            Ok((row.get(0)?, row.get(1)?))
315        })
316        .context("query comment by event_hash")?
317        .filter_map(Result::ok)
318        .collect();
319
320    for (comment_id, body) in rows {
321        if body != "[redacted]" {
322            locations.push(ResidualLocation::CommentNotRedacted { comment_id });
323        }
324    }
325    Ok(())
326}
327
328/// If original text is available, check that it doesn't appear in FTS5 results.
329///
330/// Strategy: extract significant words from the original text (skip common
331/// stop-words, require length ≥ 4), then search the FTS5 index. If the
332/// redacted item's ID appears in results, there may be residual content.
333///
334/// Note: This is a heuristic check. Redaction of create events doesn't
335/// necessarily mean the title/description should be removed from the
336/// projection (only the targeted event's payload is redacted). We only
337/// flag FTS hits when the original event was a comment (which the projection
338/// stores directly).
339fn check_fts5_residual(
340    db: &Connection,
341    target: &RedactionTarget,
342    locations: &mut Vec<ResidualLocation>,
343) -> Result<()> {
344    // Only check FTS residual for comment events — other event types'
345    // content may legitimately exist in the projection from non-redacted events.
346    let is_comment = matches!(target.original_event_type, Some(EventType::Comment));
347    if !is_comment {
348        return Ok(());
349    }
350
351    let text = match &target.original_text {
352        Some(t) if !t.is_empty() => t,
353        _ => return Ok(()),
354    };
355
356    // Extract significant words for FTS probing.
357    let words = extract_probe_words(text);
358    if words.is_empty() {
359        return Ok(());
360    }
361
362    // Check if any of the probe words match in the FTS index for this item.
363    // We use quoted phrases to avoid false positives from stemming.
364    for word in &words {
365        // FTS5 query: search for the exact word
366        let fts_query = format!("\"{}\"", word.replace('"', ""));
367        let hit_count: i64 = db
368            .query_row(
369                "SELECT COUNT(*) FROM items_fts \
370                 WHERE items_fts MATCH ?1 AND item_id = ?2",
371                params![fts_query, target.item_id],
372                |row| row.get(0),
373            )
374            .unwrap_or(0);
375
376        if hit_count > 0 {
377            locations.push(ResidualLocation::Fts5Index {
378                matched_term: word.clone(),
379            });
380            // One FTS hit is enough to flag the issue.
381            break;
382        }
383    }
384
385    Ok(())
386}
387
388/// Extract significant words from text for FTS probing.
389///
390/// Filters out short words (< 4 chars) and common English stop-words
391/// to reduce false positives.
392fn extract_probe_words(text: &str) -> Vec<String> {
393    const STOP_WORDS: &[&str] = &[
394        "the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "her", "was", "one",
395        "our", "out", "has", "have", "been", "from", "this", "that", "they", "with", "which",
396        "their", "would", "there", "what", "about", "will", "make", "like", "just", "than", "them",
397        "very", "when", "some", "could", "more", "also", "into", "other", "then", "these", "only",
398        "after", "most",
399    ];
400
401    text.split_whitespace()
402        .map(|w| {
403            w.trim_matches(|c: char| !c.is_alphanumeric())
404                .to_lowercase()
405        })
406        .filter(|w| w.len() >= 4 && !STOP_WORDS.contains(&w.as_str()))
407        .take(5) // Limit probe words to avoid excessive queries
408        .collect()
409}
410
411// ---------------------------------------------------------------------------
412// Tests
413// ---------------------------------------------------------------------------
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::db::project;
419    use crate::event::data::*;
420    use crate::event::writer::write_event;
421    use crate::model::item::Kind;
422    use crate::model::item_id::ItemId;
423    use rusqlite::Connection;
424    use std::collections::BTreeMap;
425    use std::time::Duration;
426    use tempfile::TempDir;
427
428    // -----------------------------------------------------------------------
429    // Test helpers
430    // -----------------------------------------------------------------------
431
432    /// Set up a bones project with shard infrastructure and projection DB.
433    fn setup_test_project() -> (TempDir, Connection) {
434        let dir = TempDir::new().expect("create tempdir");
435        let bones_dir = dir.path().join(".bones");
436        let shard_mgr = ShardManager::new(&bones_dir);
437        shard_mgr.ensure_dirs().expect("ensure dirs");
438        shard_mgr.init().expect("init shard");
439
440        let db_path = bones_dir.join("bones.db");
441        let conn = crate::db::open_projection(&db_path).expect("open projection");
442        project::ensure_tracking_table(&conn).expect("tracking table");
443
444        (dir, conn)
445    }
446
447    fn make_create_event(item_id: &str, title: &str, ts: i64) -> Event {
448        let mut event = Event {
449            wall_ts_us: ts,
450            agent: "test-agent".into(),
451            itc: "itc:AQ".into(),
452            parents: vec![],
453            event_type: EventType::Create,
454            item_id: ItemId::new_unchecked(item_id),
455            data: EventData::Create(CreateData {
456                title: title.into(),
457                kind: Kind::Task,
458                size: None,
459                urgency: crate::model::item::Urgency::Default,
460                labels: vec![],
461                parent: None,
462                causation: None,
463                description: Some(format!("Description for {title}")),
464                extra: BTreeMap::new(),
465            }),
466            event_hash: String::new(),
467        };
468        write_event(&mut event).expect("compute hash");
469        event
470    }
471
472    fn make_comment_event(item_id: &str, body: &str, ts: i64) -> Event {
473        let mut event = Event {
474            wall_ts_us: ts,
475            agent: "test-agent".into(),
476            itc: "itc:AQ".into(),
477            parents: vec![],
478            event_type: EventType::Comment,
479            item_id: ItemId::new_unchecked(item_id),
480            data: EventData::Comment(CommentData {
481                body: body.into(),
482                extra: BTreeMap::new(),
483            }),
484            event_hash: String::new(),
485        };
486        write_event(&mut event).expect("compute hash");
487        event
488    }
489
490    fn make_redact_event(item_id: &str, target_hash: &str, reason: &str, ts: i64) -> Event {
491        let mut event = Event {
492            wall_ts_us: ts,
493            agent: "test-agent".into(),
494            itc: "itc:AQ".into(),
495            parents: vec![],
496            event_type: EventType::Redact,
497            item_id: ItemId::new_unchecked(item_id),
498            data: EventData::Redact(RedactData {
499                target_hash: target_hash.into(),
500                reason: reason.into(),
501                extra: BTreeMap::new(),
502            }),
503            event_hash: String::new(),
504        };
505        write_event(&mut event).expect("compute hash");
506        event
507    }
508
509    /// Write events to the shard and project them into the DB.
510    fn write_and_project(dir: &TempDir, conn: &Connection, events: &[Event]) {
511        let bones_dir = dir.path().join(".bones");
512        let shard_mgr = ShardManager::new(&bones_dir);
513        let projector = project::Projector::new(conn);
514
515        for event in events {
516            let mut event_clone = event.clone();
517            let line = write_event(&mut event_clone).expect("serialize event");
518            shard_mgr
519                .append(&line, false, Duration::from_secs(5))
520                .expect("append event");
521            projector.project_event(event).expect("project event");
522        }
523    }
524
525    // -----------------------------------------------------------------------
526    // Unit tests: extract_probe_words
527    // -----------------------------------------------------------------------
528
529    #[test]
530    fn probe_words_filters_short_and_stop_words() {
531        let words = extract_probe_words("the quick brown fox jumps over the lazy dog");
532        assert!(words.contains(&"quick".to_string()));
533        assert!(words.contains(&"brown".to_string()));
534        assert!(words.contains(&"jumps".to_string()));
535        assert!(words.contains(&"lazy".to_string()));
536        assert!(!words.contains(&"the".to_string()));
537        assert!(!words.contains(&"fox".to_string())); // 3 chars
538        assert!(!words.contains(&"dog".to_string())); // 3 chars
539    }
540
541    #[test]
542    fn probe_words_limits_to_five() {
543        let text = "alpha bravo charlie delta echo foxtrot golf hotel india juliet";
544        let words = extract_probe_words(text);
545        assert!(words.len() <= 5);
546    }
547
548    #[test]
549    fn probe_words_handles_empty_text() {
550        assert!(extract_probe_words("").is_empty());
551        assert!(extract_probe_words("   ").is_empty());
552    }
553
554    #[test]
555    fn probe_words_strips_punctuation() {
556        let words = extract_probe_words("hello! world? testing... (works)");
557        assert!(words.contains(&"hello".to_string()));
558        assert!(words.contains(&"world".to_string()));
559        assert!(words.contains(&"testing".to_string()));
560        assert!(words.contains(&"works".to_string()));
561    }
562
563    // -----------------------------------------------------------------------
564    // Unit tests: extract_searchable_text
565    // -----------------------------------------------------------------------
566
567    #[test]
568    fn searchable_text_from_comment() {
569        let event = make_comment_event("bn-test", "Secret API key: abc123", 1000);
570        let text = extract_searchable_text(&event);
571        assert_eq!(text, Some("Secret API key: abc123".into()));
572    }
573
574    #[test]
575    fn searchable_text_from_create() {
576        let event = make_create_event("bn-test", "Fix auth timeout", 1000);
577        let text = extract_searchable_text(&event);
578        let t = text.unwrap();
579        assert!(t.contains("Fix auth timeout"));
580        assert!(t.contains("Description for Fix auth timeout"));
581    }
582
583    // -----------------------------------------------------------------------
584    // Integration tests: verify_redactions
585    // -----------------------------------------------------------------------
586
587    #[test]
588    fn verify_redactions_empty_log() {
589        let (dir, conn) = setup_test_project();
590        let events_dir = dir.path().join(".bones").join("events");
591
592        let report = verify_redactions(&events_dir, &conn).expect("verify");
593        assert_eq!(report.redactions_checked, 0);
594        assert_eq!(report.passed, 0);
595        assert_eq!(report.failed, 0);
596        assert!(report.is_ok());
597    }
598
599    #[test]
600    fn verify_redactions_no_redacts() {
601        let (dir, conn) = setup_test_project();
602
603        let create = make_create_event("bn-tst1", "Normal item", 1000);
604        write_and_project(&dir, &conn, &[create]);
605
606        let events_dir = dir.path().join(".bones").join("events");
607        let report = verify_redactions(&events_dir, &conn).expect("verify");
608        assert_eq!(report.redactions_checked, 0);
609        assert!(report.is_ok());
610    }
611
612    #[test]
613    fn verify_redactions_comment_properly_redacted() {
614        let (dir, conn) = setup_test_project();
615
616        let create = make_create_event("bn-tst1", "Test item", 1000);
617        let comment = make_comment_event("bn-tst1", "Contains secret info", 2000);
618        let redact = make_redact_event("bn-tst1", &comment.event_hash, "accidental secret", 3000);
619
620        write_and_project(&dir, &conn, &[create, comment, redact]);
621
622        let events_dir = dir.path().join(".bones").join("events");
623        let report = verify_redactions(&events_dir, &conn).expect("verify");
624        assert_eq!(report.redactions_checked, 1);
625        assert_eq!(report.passed, 1);
626        assert_eq!(report.failed, 0);
627        assert!(report.is_ok());
628    }
629
630    #[test]
631    fn verify_detects_missing_redaction_record() {
632        let (dir, conn) = setup_test_project();
633
634        let create = make_create_event("bn-tst1", "Test item", 1000);
635        let comment = make_comment_event("bn-tst1", "Secret info", 2000);
636
637        // Write create + comment to shard and project
638        write_and_project(&dir, &conn, &[create, comment.clone()]);
639
640        // Write redact event to shard ONLY (don't project it)
641        let redact = make_redact_event("bn-tst1", &comment.event_hash, "accidental secret", 3000);
642        let bones_dir = dir.path().join(".bones");
643        let shard_mgr = ShardManager::new(&bones_dir);
644        let mut redact_clone = redact.clone();
645        let line = write_event(&mut redact_clone).expect("serialize");
646        shard_mgr
647            .append(&line, false, Duration::from_secs(5))
648            .expect("append");
649
650        let events_dir = bones_dir.join("events");
651        let report = verify_redactions(&events_dir, &conn).expect("verify");
652        assert_eq!(report.redactions_checked, 1);
653        assert_eq!(report.failed, 1);
654
655        let failure = &report.failures[0];
656        assert_eq!(failure.item_id, "bn-tst1");
657        assert!(
658            failure
659                .residual_locations
660                .iter()
661                .any(|l| matches!(l, ResidualLocation::MissingRedactionRecord))
662        );
663        assert!(
664            failure
665                .residual_locations
666                .iter()
667                .any(|l| matches!(l, ResidualLocation::CommentNotRedacted { .. }))
668        );
669    }
670
671    #[test]
672    fn verify_item_redaction_filters_by_item() {
673        let (dir, conn) = setup_test_project();
674
675        let create1 = make_create_event("bn-aaa", "Item A", 1000);
676        let create2 = make_create_event("bn-bbb", "Item B", 1001);
677        let comment1 = make_comment_event("bn-aaa", "Secret A", 2000);
678        let comment2 = make_comment_event("bn-bbb", "Secret B", 2001);
679        let redact1 = make_redact_event("bn-aaa", &comment1.event_hash, "reason A", 3000);
680        let redact2 = make_redact_event("bn-bbb", &comment2.event_hash, "reason B", 3001);
681
682        write_and_project(
683            &dir,
684            &conn,
685            &[create1, create2, comment1, comment2, redact1, redact2],
686        );
687
688        let events_dir = dir.path().join(".bones").join("events");
689
690        // Verify only item A
691        let failures_a = verify_item_redaction("bn-aaa", &events_dir, &conn).expect("verify A");
692        assert!(failures_a.is_empty(), "item A should pass");
693
694        // Verify only item B
695        let failures_b = verify_item_redaction("bn-bbb", &events_dir, &conn).expect("verify B");
696        assert!(failures_b.is_empty(), "item B should pass");
697
698        // Verify non-existent item
699        let failures_none =
700            verify_item_redaction("bn-zzz", &events_dir, &conn).expect("verify nonexistent");
701        assert!(failures_none.is_empty());
702    }
703
704    #[test]
705    fn verify_multiple_redactions_mixed_results() {
706        let (dir, conn) = setup_test_project();
707
708        let create = make_create_event("bn-mix1", "Mixed item", 1000);
709        let comment_ok = make_comment_event("bn-mix1", "Safe comment", 2000);
710        let comment_fail = make_comment_event("bn-mix1", "Dangerous secret", 2001);
711
712        // Redact both comments
713        let redact_ok = make_redact_event("bn-mix1", &comment_ok.event_hash, "reason 1", 3000);
714        let redact_fail = make_redact_event("bn-mix1", &comment_fail.event_hash, "reason 2", 3001);
715
716        // Write and project all events normally (both redactions applied)
717        write_and_project(
718            &dir,
719            &conn,
720            &[
721                create,
722                comment_ok,
723                comment_fail.clone(),
724                redact_ok,
725                redact_fail,
726            ],
727        );
728
729        let events_dir = dir.path().join(".bones").join("events");
730        let report = verify_redactions(&events_dir, &conn).expect("verify");
731
732        // Both should pass since both redactions were projected
733        assert_eq!(report.redactions_checked, 2);
734        assert_eq!(report.passed, 2);
735        assert_eq!(report.failed, 0);
736        assert!(report.is_ok());
737    }
738
739    #[test]
740    fn report_serializes_to_json() {
741        let report = RedactionReport {
742            redactions_checked: 3,
743            passed: 2,
744            failed: 1,
745            failures: vec![RedactionFailure {
746                item_id: "bn-abc".into(),
747                event_hash: "blake3:deadbeef".into(),
748                residual_locations: vec![
749                    ResidualLocation::MissingRedactionRecord,
750                    ResidualLocation::CommentNotRedacted { comment_id: 42 },
751                ],
752            }],
753        };
754
755        let json = serde_json::to_string_pretty(&report).expect("serialize");
756        assert!(json.contains("redactions_checked"));
757        assert!(json.contains("bn-abc"));
758        assert!(json.contains("MissingRedactionRecord"));
759        assert!(json.contains("CommentNotRedacted"));
760    }
761
762    #[test]
763    fn report_is_ok_when_no_failures() {
764        let report = RedactionReport {
765            redactions_checked: 5,
766            passed: 5,
767            failed: 0,
768            failures: vec![],
769        };
770        assert!(report.is_ok());
771    }
772
773    #[test]
774    fn report_not_ok_when_failures_exist() {
775        let report = RedactionReport {
776            redactions_checked: 5,
777            passed: 4,
778            failed: 1,
779            failures: vec![RedactionFailure {
780                item_id: "bn-x".into(),
781                event_hash: "blake3:abc".into(),
782                residual_locations: vec![ResidualLocation::MissingRedactionRecord],
783            }],
784        };
785        assert!(!report.is_ok());
786    }
787}