Skip to main content

zenith_session/
scratch.rs

1//! Scratch/candidate store: content-addressed `.zen` snapshots indexed in
2//! `scratch/index.jsonl`.
3//!
4//! Each [`CandidateEntry`] records a `page_id` (the page or source being
5//! snapshotted), a `snapshot_hash` that addresses the raw `.zen` bytes in the
6//! shared object store, a [`CandidateStatus`], and optional workflow metadata.
7//!
8//! # Append-only contract
9//!
10//! This module is **append-only**: all writes are appends. [`put_scratch`] adds
11//! new candidates; [`set_candidate_status`] transitions an existing candidate's
12//! status by appending a superseding entry (same `id`/`page_id`/`snapshot_hash`,
13//! new status and timestamp). [`list_scratch`] resolves the latest status per
14//! `id` via **last-write-wins** deduplication, returning one entry per distinct
15//! candidate in first-appearance order. The raw file is fully auditable.
16
17use std::time::UNIX_EPOCH;
18
19use serde::{Deserialize, Serialize};
20
21use crate::adapter::{Clock, Fs};
22use crate::error::SessionError;
23use crate::layout::StorePaths;
24use crate::manifest::{append_jsonl_record, read_jsonl_records};
25use crate::store::{get_object, put_object};
26
27// ── CandidateStatus ───────────────────────────────────────────────────────────
28
29/// Lifecycle state of a scratch candidate.
30#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum CandidateStatus {
33    /// The candidate is still being evaluated.
34    Draft,
35    /// The candidate has been chosen for promotion.
36    Selected,
37    /// The candidate has been discarded.
38    Rejected,
39}
40
41// ── CandidateEntry ────────────────────────────────────────────────────────────
42
43/// A single scratch candidate record appended to `scratch/index.jsonl`.
44///
45/// `id` and `seq` are derived by [`put_scratch`] from the current index
46/// length; callers do not supply them.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct CandidateEntry {
49    /// Stable candidate id within this document's scratch index (e.g. `cand0`).
50    pub id: String,
51    /// Monotonic sequence number (0-based, append order).
52    pub seq: u64,
53    /// The page or source document this candidate snapshots.
54    pub page_id: String,
55    /// SHA-256 content hash of the stored `.zen` snapshot bytes (in `objects/`).
56    pub snapshot_hash: String,
57    /// Lifecycle status at the time this entry was appended.
58    pub status: CandidateStatus,
59    /// Optional workflow role label (e.g. `"hero"`, `"fallback"`).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub workspace_role: Option<String>,
62    /// Optional target to promote this candidate to (e.g. a branch or slot id).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub promotion_target: Option<String>,
65    /// Optional policy controlling when this candidate may be cleaned up.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub cleanup_policy: Option<String>,
68    /// Optional free-text notes about this candidate.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub notes: Option<String>,
71    /// Unix timestamp in milliseconds when this entry was created.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub timestamp_ms: Option<u128>,
74}
75
76// ── CandidateMeta ─────────────────────────────────────────────────────────────
77
78/// Borrowed optional metadata for a new candidate (mirrors `VersionMeta`).
79///
80/// All fields default to `None`; supply only what is available at call time.
81#[derive(Debug, Clone, Copy, Default)]
82pub struct CandidateMeta<'a> {
83    /// Optional workflow role label for this candidate.
84    pub workspace_role: Option<&'a str>,
85    /// Optional promotion target for this candidate.
86    pub promotion_target: Option<&'a str>,
87    /// Optional cleanup policy tag.
88    pub cleanup_policy: Option<&'a str>,
89    /// Optional free-text notes.
90    pub notes: Option<&'a str>,
91}
92
93// ── NewCandidate ──────────────────────────────────────────────────────────────
94
95/// The describing inputs for a new candidate snapshot: which page it captures,
96/// the `.zen` snapshot bytes, its lifecycle status, and optional metadata.
97#[derive(Debug, Clone, Copy)]
98pub struct NewCandidate<'a> {
99    /// The page or source document this candidate snapshots.
100    pub page_id: &'a str,
101    /// Raw `.zen` snapshot bytes to store in the object store.
102    pub snapshot: &'a [u8],
103    /// Lifecycle status for this candidate at creation time.
104    pub status: CandidateStatus,
105    /// Optional workflow metadata (role, target, policy, notes).
106    pub meta: CandidateMeta<'a>,
107}
108
109// ── Public API ────────────────────────────────────────────────────────────────
110
111/// Store a candidate snapshot and append a [`CandidateEntry`] to the scratch
112/// index.
113///
114/// The `.zen` bytes in `candidate.snapshot` are written to the shared object
115/// store (content-addressed, idempotent). `seq` and `id` are derived from the
116/// current index length so callers do not need to track them. Returns the
117/// created entry.
118pub fn put_scratch(
119    fs: &impl Fs,
120    paths: &StorePaths,
121    clock: &impl Clock,
122    doc_id: &str,
123    candidate: NewCandidate<'_>,
124) -> Result<CandidateEntry, SessionError> {
125    let snapshot_hash = put_object(fs, paths, doc_id, candidate.snapshot)?;
126    let seq = u64::try_from(list_scratch(fs, paths, doc_id)?.len())
127        .map_err(|_| SessionError::new("candidate count exceeds u64"))?;
128    let id = format!("cand{seq}");
129    let timestamp_ms = clock
130        .now()
131        .duration_since(UNIX_EPOCH)
132        .ok()
133        .map(|d| d.as_millis());
134    let entry = CandidateEntry {
135        id,
136        seq,
137        page_id: candidate.page_id.to_owned(),
138        snapshot_hash,
139        status: candidate.status,
140        workspace_role: candidate.meta.workspace_role.map(str::to_owned),
141        promotion_target: candidate.meta.promotion_target.map(str::to_owned),
142        cleanup_policy: candidate.meta.cleanup_policy.map(str::to_owned),
143        notes: candidate.meta.notes.map(str::to_owned),
144        timestamp_ms,
145    };
146    append_jsonl_record(fs, &paths.scratch_index(doc_id), &entry)?;
147    Ok(entry)
148}
149
150/// List candidate entries for `doc_id`, one per distinct candidate id.
151///
152/// Reads all appended records and deduplicates by `id`: for each distinct `id`,
153/// the **last** record in file order is kept (last-write-wins). Entries are
154/// returned in **first-appearance order** (the order each `id` was first seen).
155///
156/// Returns an empty vec when no scratch index exists for the document.
157pub fn list_scratch(
158    fs: &impl Fs,
159    paths: &StorePaths,
160    doc_id: &str,
161) -> Result<Vec<CandidateEntry>, SessionError> {
162    let raw = read_jsonl_records(fs, &paths.scratch_index(doc_id))?;
163    Ok(dedup_last_write_wins(raw))
164}
165
166/// Deduplicate `records` by `id`, keeping the last occurrence of each `id`
167/// and emitting entries in first-appearance order. Deterministic; uses only
168/// `Vec` and `BTreeMap`.
169fn dedup_last_write_wins(records: Vec<CandidateEntry>) -> Vec<CandidateEntry> {
170    let mut order: Vec<String> = Vec::new();
171    let mut map: std::collections::BTreeMap<String, CandidateEntry> =
172        std::collections::BTreeMap::new();
173    for entry in records {
174        if !map.contains_key(&entry.id) {
175            order.push(entry.id.clone());
176        }
177        map.insert(entry.id.clone(), entry);
178    }
179    order.iter().filter_map(|id| map.get(id).cloned()).collect()
180}
181
182/// Transition a candidate's lifecycle status by appending a superseding entry
183/// (same `id`/`page_id`/`snapshot_hash`, new `status` + fresh timestamp). The
184/// scratch index stays append-only and auditable; [`list_scratch`] resolves the
185/// latest status per `id` via last-write-wins.
186///
187/// Returns `SessionError` if `cand_id` does not match any known candidate.
188pub fn set_candidate_status(
189    fs: &impl Fs,
190    paths: &StorePaths,
191    clock: &impl Clock,
192    doc_id: &str,
193    cand_id: &str,
194    new_status: CandidateStatus,
195) -> Result<CandidateEntry, SessionError> {
196    let entries = list_scratch(fs, paths, doc_id)?;
197    let existing = entries
198        .iter()
199        .find(|e| e.id == cand_id)
200        .ok_or_else(|| SessionError::new(format!("candidate not found: {cand_id}")))?;
201    let timestamp_ms = clock
202        .now()
203        .duration_since(UNIX_EPOCH)
204        .ok()
205        .map(|d| d.as_millis());
206    let updated = CandidateEntry {
207        status: new_status,
208        timestamp_ms,
209        ..existing.clone()
210    };
211    append_jsonl_record(fs, &paths.scratch_index(doc_id), &updated)?;
212    Ok(updated)
213}
214
215// ── FinalizeReport ────────────────────────────────────────────────────────────
216
217/// Report of a finalize pass.
218#[derive(Debug, Clone, PartialEq, Serialize)]
219pub struct FinalizeReport {
220    /// Candidate ids removed from the index (had `status == Rejected` and
221    /// `cleanup_policy == Some("delete")`).
222    pub deleted: Vec<String>,
223    /// Number of distinct candidates remaining in the index after the pass.
224    pub kept: usize,
225}
226
227/// Apply each rejected candidate's cleanup-policy to the scratch store.
228///
229/// Candidates that are `Rejected` with `cleanup_policy == Some("delete")` are
230/// removed from the index entirely. Their snapshot objects are left in
231/// `objects/` for a future GC pass. All other candidates (non-rejected, or
232/// rejected with a different/absent cleanup policy) are preserved.
233///
234/// The scratch index is normally append-only; this is an explicit compaction
235/// that rewrites it to exclude deleted candidates' lines while preserving
236/// every other candidate's full append history (all raw lines for kept ids are
237/// retained in their original order).
238///
239/// Returns `Ok(FinalizeReport { deleted: [], kept: N })` without touching the
240/// file when there is nothing to delete.
241pub fn finalize_candidates(
242    fs: &impl Fs,
243    paths: &StorePaths,
244    doc_id: &str,
245) -> Result<FinalizeReport, SessionError> {
246    let resolved = list_scratch(fs, paths, doc_id)?;
247
248    let mut to_delete: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
249    for entry in &resolved {
250        match entry.status {
251            CandidateStatus::Rejected => {
252                if entry.cleanup_policy.as_deref() == Some("delete") {
253                    to_delete.insert(entry.id.clone());
254                }
255            }
256            CandidateStatus::Draft | CandidateStatus::Selected => {}
257        }
258    }
259
260    if to_delete.is_empty() {
261        return Ok(FinalizeReport {
262            deleted: vec![],
263            kept: resolved.len(),
264        });
265    }
266
267    let raw = read_jsonl_records::<CandidateEntry>(fs, &paths.scratch_index(doc_id))?;
268    let kept_raw: Vec<&CandidateEntry> =
269        raw.iter().filter(|r| !to_delete.contains(&r.id)).collect();
270
271    let mut bytes: Vec<u8> = Vec::new();
272    for entry in &kept_raw {
273        let mut line = serde_json::to_vec(entry)
274            .map_err(|e| SessionError::new(format!("serialize candidate: {e}")))?;
275        line.push(b'\n');
276        bytes.extend_from_slice(&line);
277    }
278
279    let index_path = paths.scratch_index(doc_id);
280    if let Some(parent) = index_path.parent() {
281        fs.create_dir_all(parent)?;
282    }
283    fs.write(&index_path, &bytes)?;
284
285    let kept = resolved.len() - to_delete.len();
286    Ok(FinalizeReport {
287        deleted: to_delete.into_iter().collect(),
288        kept,
289    })
290}
291
292/// Recover the stored `.zen` snapshot bytes for a candidate entry.
293///
294/// Decompresses and verifies the object addressed by `entry.snapshot_hash`.
295pub fn get_scratch_snapshot(
296    fs: &impl Fs,
297    paths: &StorePaths,
298    doc_id: &str,
299    entry: &CandidateEntry,
300) -> Result<Vec<u8>, SessionError> {
301    get_object(fs, paths, doc_id, &entry.snapshot_hash)
302}
303
304// ── Tests ─────────────────────────────────────────────────────────────────────
305
306#[cfg(test)]
307mod tests {
308    use std::time::Duration;
309
310    use super::*;
311    use crate::adapter::{FakeClock, MemFs};
312    use crate::layout::StorePaths;
313
314    fn setup() -> (MemFs, StorePaths) {
315        (MemFs::new(), StorePaths::new("/data"))
316    }
317
318    fn clock() -> FakeClock {
319        FakeClock(UNIX_EPOCH + Duration::from_millis(100))
320    }
321
322    #[test]
323    fn put_then_list_scratch_roundtrip() {
324        let (fs, paths) = setup();
325        let clk = clock();
326
327        let meta_full = CandidateMeta {
328            workspace_role: Some("hero"),
329            promotion_target: Some("slot-a"),
330            cleanup_policy: Some("on_select"),
331            notes: Some("first pass"),
332        };
333        let e0 = put_scratch(
334            &fs,
335            &paths,
336            &clk,
337            "doc1",
338            NewCandidate {
339                page_id: "page-a",
340                snapshot: b"zen content A",
341                status: CandidateStatus::Draft,
342                meta: meta_full,
343            },
344        )
345        .unwrap();
346
347        let e1 = put_scratch(
348            &fs,
349            &paths,
350            &clk,
351            "doc1",
352            NewCandidate {
353                page_id: "page-b",
354                snapshot: b"zen content B",
355                status: CandidateStatus::Selected,
356                meta: CandidateMeta::default(),
357            },
358        )
359        .unwrap();
360
361        assert_eq!(e0.seq, 0);
362        assert_eq!(e0.id, "cand0");
363        assert_eq!(e0.page_id, "page-a");
364        assert_eq!(e0.status, CandidateStatus::Draft);
365        assert_eq!(e0.workspace_role, Some("hero".to_owned()));
366        assert_eq!(e0.promotion_target, Some("slot-a".to_owned()));
367        assert_eq!(e0.cleanup_policy, Some("on_select".to_owned()));
368        assert_eq!(e0.notes, Some("first pass".to_owned()));
369
370        assert_eq!(e1.seq, 1);
371        assert_eq!(e1.id, "cand1");
372        assert_eq!(e1.page_id, "page-b");
373        assert_eq!(e1.status, CandidateStatus::Selected);
374        assert_eq!(e1.workspace_role, None);
375
376        let entries = list_scratch(&fs, &paths, "doc1").unwrap();
377        assert_eq!(entries.len(), 2);
378        assert_eq!(entries[0], e0);
379        assert_eq!(entries[1], e1);
380    }
381
382    #[test]
383    fn snapshot_bytes_recovered_intact() {
384        let (fs, paths) = setup();
385        let clk = clock();
386        let zen_bytes = b"node layout { width 100 }";
387
388        let entry = put_scratch(
389            &fs,
390            &paths,
391            &clk,
392            "doc1",
393            NewCandidate {
394                page_id: "page-x",
395                snapshot: zen_bytes,
396                status: CandidateStatus::Draft,
397                meta: CandidateMeta::default(),
398            },
399        )
400        .unwrap();
401
402        let recovered = get_scratch_snapshot(&fs, &paths, "doc1", &entry).unwrap();
403        assert_eq!(recovered.as_slice(), zen_bytes.as_slice());
404    }
405
406    #[test]
407    fn lean_candidate_omits_optionals() {
408        let (fs, paths) = setup();
409        let clk = clock();
410
411        put_scratch(
412            &fs,
413            &paths,
414            &clk,
415            "doc1",
416            NewCandidate {
417                page_id: "page-lean",
418                snapshot: b"lean",
419                status: CandidateStatus::Draft,
420                meta: CandidateMeta::default(),
421            },
422        )
423        .unwrap();
424
425        let raw = fs.read(&paths.scratch_index("doc1")).unwrap();
426        let line = std::str::from_utf8(&raw).unwrap();
427
428        assert!(
429            !line.contains("workspace_role"),
430            "workspace_role must be absent in lean form"
431        );
432        assert!(
433            !line.contains("promotion_target"),
434            "promotion_target must be absent in lean form"
435        );
436        assert!(
437            !line.contains("cleanup_policy"),
438            "cleanup_policy must be absent in lean form"
439        );
440        assert!(
441            !line.contains("\"notes\""),
442            "notes must be absent in lean form"
443        );
444    }
445
446    #[test]
447    fn status_serializes_snake_case() {
448        let (fs, paths) = setup();
449        let clk = clock();
450
451        put_scratch(
452            &fs,
453            &paths,
454            &clk,
455            "doc1",
456            NewCandidate {
457                page_id: "page-sel",
458                snapshot: b"sel",
459                status: CandidateStatus::Selected,
460                meta: CandidateMeta::default(),
461            },
462        )
463        .unwrap();
464
465        let raw = fs.read(&paths.scratch_index("doc1")).unwrap();
466        let line = std::str::from_utf8(&raw).unwrap();
467        assert!(
468            line.contains("\"selected\""),
469            "Selected status must serialize as \"selected\""
470        );
471    }
472
473    #[test]
474    fn list_scratch_absent_is_empty() {
475        let (fs, paths) = setup();
476        let entries = list_scratch(&fs, &paths, "no-such-doc").unwrap();
477        assert!(entries.is_empty());
478    }
479
480    #[test]
481    fn set_status_draft_to_selected() {
482        let (fs, paths) = setup();
483        let clk = clock();
484
485        put_scratch(
486            &fs,
487            &paths,
488            &clk,
489            "doc1",
490            NewCandidate {
491                page_id: "page-a",
492                snapshot: b"zen A",
493                status: CandidateStatus::Draft,
494                meta: CandidateMeta::default(),
495            },
496        )
497        .unwrap();
498
499        let updated = set_candidate_status(
500            &fs,
501            &paths,
502            &clk,
503            "doc1",
504            "cand0",
505            CandidateStatus::Selected,
506        )
507        .unwrap();
508        assert_eq!(updated.status, CandidateStatus::Selected);
509        assert_eq!(updated.id, "cand0");
510
511        let entries = list_scratch(&fs, &paths, "doc1").unwrap();
512        assert_eq!(entries.len(), 1, "dedup must yield exactly one entry");
513        assert_eq!(entries[0].status, CandidateStatus::Selected);
514    }
515
516    #[test]
517    fn set_status_draft_to_rejected() {
518        let (fs, paths) = setup();
519        let clk = clock();
520
521        put_scratch(
522            &fs,
523            &paths,
524            &clk,
525            "doc1",
526            NewCandidate {
527                page_id: "page-b",
528                snapshot: b"zen B",
529                status: CandidateStatus::Draft,
530                meta: CandidateMeta::default(),
531            },
532        )
533        .unwrap();
534
535        set_candidate_status(
536            &fs,
537            &paths,
538            &clk,
539            "doc1",
540            "cand0",
541            CandidateStatus::Rejected,
542        )
543        .unwrap();
544
545        let entries = list_scratch(&fs, &paths, "doc1").unwrap();
546        assert_eq!(entries.len(), 1);
547        assert_eq!(entries[0].status, CandidateStatus::Rejected);
548    }
549
550    #[test]
551    fn list_scratch_dedup_keeps_latest_and_order() {
552        let (fs, paths) = setup();
553        let clk = clock();
554
555        put_scratch(
556            &fs,
557            &paths,
558            &clk,
559            "doc1",
560            NewCandidate {
561                page_id: "page-a",
562                snapshot: b"zen A",
563                status: CandidateStatus::Draft,
564                meta: CandidateMeta::default(),
565            },
566        )
567        .unwrap();
568        put_scratch(
569            &fs,
570            &paths,
571            &clk,
572            "doc1",
573            NewCandidate {
574                page_id: "page-b",
575                snapshot: b"zen B",
576                status: CandidateStatus::Draft,
577                meta: CandidateMeta::default(),
578            },
579        )
580        .unwrap();
581        set_candidate_status(
582            &fs,
583            &paths,
584            &clk,
585            "doc1",
586            "cand0",
587            CandidateStatus::Selected,
588        )
589        .unwrap();
590
591        let entries = list_scratch(&fs, &paths, "doc1").unwrap();
592        assert_eq!(entries.len(), 2, "must have exactly 2 distinct candidates");
593        assert_eq!(entries[0].id, "cand0");
594        assert_eq!(entries[0].status, CandidateStatus::Selected);
595        assert_eq!(entries[1].id, "cand1");
596        assert_eq!(entries[1].status, CandidateStatus::Draft);
597    }
598
599    #[test]
600    fn set_status_unknown_candidate_errors() {
601        let (fs, paths) = setup();
602        let clk = clock();
603
604        let result = set_candidate_status(
605            &fs,
606            &paths,
607            &clk,
608            "doc1",
609            "cand99",
610            CandidateStatus::Selected,
611        );
612        assert!(result.is_err());
613        let err = result.unwrap_err();
614        assert!(
615            err.message.contains("cand99"),
616            "error message should include the missing id"
617        );
618    }
619
620    #[test]
621    fn finalize_deletes_rejected_delete_policy() {
622        let (fs, paths) = setup();
623        let clk = clock();
624
625        // cand0: rejected + delete  → must be removed
626        let e0 = put_scratch(
627            &fs,
628            &paths,
629            &clk,
630            "doc1",
631            NewCandidate {
632                page_id: "page-a",
633                snapshot: b"zen A",
634                status: CandidateStatus::Rejected,
635                meta: CandidateMeta {
636                    cleanup_policy: Some("delete"),
637                    ..CandidateMeta::default()
638                },
639            },
640        )
641        .unwrap();
642
643        // cand1: draft → kept
644        put_scratch(
645            &fs,
646            &paths,
647            &clk,
648            "doc1",
649            NewCandidate {
650                page_id: "page-b",
651                snapshot: b"zen B",
652                status: CandidateStatus::Draft,
653                meta: CandidateMeta::default(),
654            },
655        )
656        .unwrap();
657
658        // cand2: selected → kept
659        put_scratch(
660            &fs,
661            &paths,
662            &clk,
663            "doc1",
664            NewCandidate {
665                page_id: "page-c",
666                snapshot: b"zen C",
667                status: CandidateStatus::Selected,
668                meta: CandidateMeta::default(),
669            },
670        )
671        .unwrap();
672
673        let report = finalize_candidates(&fs, &paths, "doc1").unwrap();
674        assert_eq!(report.deleted, vec![e0.id.clone()]);
675        assert_eq!(report.kept, 2);
676
677        let remaining = list_scratch(&fs, &paths, "doc1").unwrap();
678        assert_eq!(remaining.len(), 2);
679        assert!(
680            remaining.iter().all(|e| e.id != e0.id),
681            "deleted candidate must not appear in list"
682        );
683    }
684
685    #[test]
686    fn finalize_keeps_archived_and_selected() {
687        let (fs, paths) = setup();
688        let clk = clock();
689
690        // cand0: rejected + archive → kept
691        put_scratch(
692            &fs,
693            &paths,
694            &clk,
695            "doc1",
696            NewCandidate {
697                page_id: "page-a",
698                snapshot: b"zen A",
699                status: CandidateStatus::Rejected,
700                meta: CandidateMeta {
701                    cleanup_policy: Some("archive"),
702                    ..CandidateMeta::default()
703                },
704            },
705        )
706        .unwrap();
707
708        // cand1: selected → kept
709        put_scratch(
710            &fs,
711            &paths,
712            &clk,
713            "doc1",
714            NewCandidate {
715                page_id: "page-b",
716                snapshot: b"zen B",
717                status: CandidateStatus::Selected,
718                meta: CandidateMeta::default(),
719            },
720        )
721        .unwrap();
722
723        let report = finalize_candidates(&fs, &paths, "doc1").unwrap();
724        assert!(report.deleted.is_empty(), "nothing should be deleted");
725        assert_eq!(report.kept, 2);
726
727        let remaining = list_scratch(&fs, &paths, "doc1").unwrap();
728        assert_eq!(remaining.len(), 2);
729    }
730
731    #[test]
732    fn finalize_noop_when_nothing_to_delete() {
733        let (fs, paths) = setup();
734        let clk = clock();
735
736        put_scratch(
737            &fs,
738            &paths,
739            &clk,
740            "doc1",
741            NewCandidate {
742                page_id: "page-a",
743                snapshot: b"zen A",
744                status: CandidateStatus::Draft,
745                meta: CandidateMeta::default(),
746            },
747        )
748        .unwrap();
749        put_scratch(
750            &fs,
751            &paths,
752            &clk,
753            "doc1",
754            NewCandidate {
755                page_id: "page-b",
756                snapshot: b"zen B",
757                status: CandidateStatus::Draft,
758                meta: CandidateMeta::default(),
759            },
760        )
761        .unwrap();
762
763        let bytes_before = fs.read(&paths.scratch_index("doc1")).unwrap();
764        let report = finalize_candidates(&fs, &paths, "doc1").unwrap();
765        assert!(report.deleted.is_empty());
766        assert_eq!(report.kept, 2);
767        // No-op path: file must be unchanged.
768        let bytes_after = fs.read(&paths.scratch_index("doc1")).unwrap();
769        assert_eq!(bytes_before, bytes_after, "file must be unchanged on noop");
770    }
771
772    #[test]
773    fn finalize_preserves_other_candidates_history() {
774        let (fs, paths) = setup();
775        let clk = clock();
776
777        // cand0: draft → selected (two raw lines in the index)
778        put_scratch(
779            &fs,
780            &paths,
781            &clk,
782            "doc1",
783            NewCandidate {
784                page_id: "page-a",
785                snapshot: b"zen A",
786                status: CandidateStatus::Draft,
787                meta: CandidateMeta::default(),
788            },
789        )
790        .unwrap();
791        set_candidate_status(
792            &fs,
793            &paths,
794            &clk,
795            "doc1",
796            "cand0",
797            CandidateStatus::Selected,
798        )
799        .unwrap();
800
801        // cand1: rejected + delete → removed
802        put_scratch(
803            &fs,
804            &paths,
805            &clk,
806            "doc1",
807            NewCandidate {
808                page_id: "page-b",
809                snapshot: b"zen B",
810                status: CandidateStatus::Rejected,
811                meta: CandidateMeta {
812                    cleanup_policy: Some("delete"),
813                    ..CandidateMeta::default()
814                },
815            },
816        )
817        .unwrap();
818
819        let report = finalize_candidates(&fs, &paths, "doc1").unwrap();
820        assert_eq!(report.deleted, vec!["cand1".to_owned()]);
821        assert_eq!(report.kept, 1);
822
823        let remaining = list_scratch(&fs, &paths, "doc1").unwrap();
824        assert_eq!(remaining.len(), 1);
825        assert_eq!(remaining[0].id, "cand0");
826        assert_eq!(
827            remaining[0].status,
828            CandidateStatus::Selected,
829            "cand0 history lines survived; resolved status must be Selected"
830        );
831    }
832
833    #[test]
834    fn put_after_status_change_gets_correct_next_seq() {
835        let (fs, paths) = setup();
836        let clk = clock();
837
838        put_scratch(
839            &fs,
840            &paths,
841            &clk,
842            "doc1",
843            NewCandidate {
844                page_id: "page-a",
845                snapshot: b"zen A",
846                status: CandidateStatus::Draft,
847                meta: CandidateMeta::default(),
848            },
849        )
850        .unwrap();
851        set_candidate_status(
852            &fs,
853            &paths,
854            &clk,
855            "doc1",
856            "cand0",
857            CandidateStatus::Selected,
858        )
859        .unwrap();
860
861        let e1 = put_scratch(
862            &fs,
863            &paths,
864            &clk,
865            "doc1",
866            NewCandidate {
867                page_id: "page-b",
868                snapshot: b"zen B",
869                status: CandidateStatus::Draft,
870                meta: CandidateMeta::default(),
871            },
872        )
873        .unwrap();
874
875        assert_eq!(e1.seq, 1, "seq must be 1 (one distinct candidate existed)");
876        assert_eq!(e1.id, "cand1");
877    }
878}