Skip to main content

mimir_librarian/
drafts.rs

1//! Prose memory drafts — the librarian's input unit.
2//!
3//! A draft is a piece of prose an agent, adapter, or operator wrote,
4//! intended to be captured as durable Mimir memory after librarian
5//! validation. Drafts are untrusted. They carry scope-model metadata
6//! and provenance, then live on the filesystem under a state-directory
7//! flow so the librarian's processing is crash-safe: a draft's
8//! directory tells you its lifecycle state, and state transitions are
9//! atomic renames. Processing claim markers record when a draft entered
10//! `processing/` so stale recovery is based on claim age, not original
11//! submission age.
12//!
13//! ```text
14//! drafts/pending/<id>.json       ─ waiting for a librarian run
15//! drafts/processing/<id>.json    ─ currently being structured
16//! drafts/accepted/<id>.json    ─ successfully written to the log
17//! drafts/skipped/<id>.json     ─ intentionally ignored / duplicate
18//! drafts/failed/<id>.json        ─ retry budget exhausted
19//! drafts/quarantined/<id>.json ─ unsafe or unresolved, review only
20//! ```
21//!
22//! Draft IDs are content-addressed over the raw text plus stable
23//! provenance identity fields. Identical sweeps of the same source
24//! produce the same ID, but identical text from different agents or
25//! files remains distinct so provenance is never collapsed away.
26
27use std::fmt;
28use std::fs;
29use std::io::ErrorKind;
30use std::path::{Path, PathBuf};
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35
36use crate::LibrarianError;
37
38/// Current on-disk draft schema version.
39pub const DRAFT_SCHEMA_VERSION: u32 = 2;
40const PROCESSING_CLAIM_EXT: &str = "claim";
41
42/// A prose memory draft staged for librarian processing.
43///
44/// Constructed from raw text plus [`DraftMetadata`]. The [`DraftId`]
45/// is derived deterministically from raw text plus stable provenance
46/// identity fields.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Draft {
49    id: DraftId,
50    metadata: DraftMetadata,
51    submitted_at: SystemTime,
52    raw_text: String,
53}
54
55impl Draft {
56    /// Construct a draft from raw text + legacy source + timestamp.
57    ///
58    /// New callers should prefer [`Draft::with_metadata`] so the
59    /// scope-model fields are explicit.
60    #[must_use]
61    pub fn new(raw_text: String, source: &DraftSource, submitted_at: SystemTime) -> Self {
62        let metadata = DraftMetadata::from_source(source, submitted_at);
63        Self::with_metadata(raw_text, metadata)
64    }
65
66    /// Construct a draft from raw text plus explicit scope-model
67    /// metadata.
68    #[must_use]
69    pub fn with_metadata(raw_text: String, metadata: DraftMetadata) -> Self {
70        let id = DraftId::from_raw_text_and_metadata(&raw_text, &metadata);
71        let submitted_at = metadata.submitted_at;
72        Self {
73            id,
74            metadata,
75            submitted_at,
76            raw_text,
77        }
78    }
79
80    /// Content-addressed identifier for this draft.
81    #[must_use]
82    pub fn id(&self) -> DraftId {
83        self.id
84    }
85
86    /// Scope-model metadata and provenance for this draft.
87    #[must_use]
88    pub fn metadata(&self) -> &DraftMetadata {
89        &self.metadata
90    }
91
92    /// When the draft was staged for processing.
93    #[must_use]
94    pub fn submitted_at(&self) -> SystemTime {
95        self.submitted_at
96    }
97
98    /// The raw prose the draft carries. Never logged.
99    #[must_use]
100    pub fn prose(&self) -> &str {
101        &self.raw_text
102    }
103
104    /// The raw text the draft carries. Same content as [`Draft::prose`],
105    /// named for the v2 draft schema.
106    #[must_use]
107    pub fn raw_text(&self) -> &str {
108        &self.raw_text
109    }
110}
111
112/// Content-addressed identifier for a draft. First 8 bytes of the
113/// SHA-256 of the prose, rendered as a 16-character lowercase hex
114/// string on display.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub struct DraftId([u8; 8]);
117
118impl DraftId {
119    /// Derive a draft ID from prose content via SHA-256.
120    #[must_use]
121    pub fn from_prose(prose: &str) -> Self {
122        let digest = Sha256::digest(prose.as_bytes());
123        let mut bytes = [0u8; 8];
124        bytes.copy_from_slice(&digest[..8]);
125        Self(bytes)
126    }
127
128    /// Derive a draft ID from raw text plus stable provenance fields.
129    #[must_use]
130    pub fn from_raw_text_and_metadata(raw_text: &str, metadata: &DraftMetadata) -> Self {
131        let mut hasher = Sha256::new();
132        hasher.update(raw_text.as_bytes());
133        hasher.update([0]);
134        hasher.update(metadata.source_surface.as_str().as_bytes());
135        hasher.update([0]);
136        update_optional(&mut hasher, metadata.source_agent.as_deref());
137        update_optional(&mut hasher, metadata.source_project.as_deref());
138        update_optional(&mut hasher, metadata.operator.as_deref());
139        update_optional(&mut hasher, metadata.provenance_uri.as_deref());
140        let digest = hasher.finalize();
141        let mut bytes = [0u8; 8];
142        bytes.copy_from_slice(&digest[..8]);
143        Self(bytes)
144    }
145
146    /// The raw 8-byte identifier.
147    #[must_use]
148    pub fn as_bytes(&self) -> [u8; 8] {
149        self.0
150    }
151
152    /// Lower-case 16-character hex encoding.
153    #[must_use]
154    pub fn to_hex(&self) -> String {
155        let mut out = String::with_capacity(16);
156        for byte in self.0 {
157            use std::fmt::Write as _;
158            // `write!` on a `String` is infallible; the `ok()` swallow
159            // satisfies `clippy::unwrap_used` without panicking.
160            write!(&mut out, "{byte:02x}").ok();
161        }
162        out
163    }
164}
165
166fn update_optional(hasher: &mut Sha256, value: Option<&str>) {
167    if let Some(v) = value {
168        hasher.update(v.as_bytes());
169    }
170    hasher.update([0]);
171}
172
173/// Metadata required by `scope-model.md` § 4 for every raw draft.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct DraftMetadata {
176    /// Surface that submitted or exposed the raw memory.
177    pub source_surface: DraftSourceSurface,
178    /// Agent identity, when known (`claude`, `codex`, etc.).
179    pub source_agent: Option<String>,
180    /// Project or workspace identity, when known.
181    pub source_project: Option<String>,
182    /// Operator identity, when known.
183    pub operator: Option<String>,
184    /// Stable URI/path/event id for the source material.
185    pub provenance_uri: Option<String>,
186    /// Optional caller-provided context tags.
187    pub context_tags: Vec<String>,
188    /// When this draft entered Mimir's draft surface.
189    pub submitted_at: SystemTime,
190}
191
192impl DraftMetadata {
193    /// Construct metadata with only the required surface + timestamp.
194    #[must_use]
195    pub fn new(source_surface: DraftSourceSurface, submitted_at: SystemTime) -> Self {
196        Self {
197            source_surface,
198            source_agent: None,
199            source_project: None,
200            operator: None,
201            provenance_uri: None,
202            context_tags: Vec::new(),
203            submitted_at,
204        }
205    }
206
207    /// Convert the older coarse [`DraftSource`] enum into v2 metadata.
208    #[must_use]
209    pub fn from_source(source: &DraftSource, submitted_at: SystemTime) -> Self {
210        match source {
211            DraftSource::Directory { path } => {
212                let mut metadata = Self::new(DraftSourceSurface::Directory, submitted_at);
213                metadata.provenance_uri = Some(path_to_file_uri(path));
214                metadata
215            }
216            DraftSource::AutoMemorySweep { file } => {
217                let mut metadata = Self::new(DraftSourceSurface::ClaudeMemory, submitted_at);
218                metadata.source_agent = Some("claude".to_string());
219                metadata.provenance_uri = Some(path_to_file_uri(file));
220                metadata
221            }
222            DraftSource::CodexMemorySweep { file } => {
223                let mut metadata = Self::new(DraftSourceSurface::CodexMemory, submitted_at);
224                metadata.source_agent = Some("codex".to_string());
225                metadata.provenance_uri = Some(path_to_file_uri(file));
226                metadata
227            }
228            DraftSource::McpSubmit { workspace } => {
229                let mut metadata = Self::new(DraftSourceSurface::Mcp, submitted_at);
230                metadata.source_project = Some(workspace.clone());
231                metadata
232            }
233            DraftSource::RepoHandoff { file } => {
234                let mut metadata = Self::new(DraftSourceSurface::RepoHandoff, submitted_at);
235                metadata.provenance_uri = Some(path_to_file_uri(file));
236                metadata
237            }
238            DraftSource::CliSubmit => Self::new(DraftSourceSurface::Cli, submitted_at),
239        }
240    }
241}
242
243/// Source surface that exposed a draft to Mimir.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum DraftSourceSurface {
247    /// File under the configured drafts directory.
248    Directory,
249    /// Claude native memory file sweep.
250    ClaudeMemory,
251    /// Codex memory file sweep.
252    CodexMemory,
253    /// MCP submission tool.
254    Mcp,
255    /// Librarian CLI submission.
256    Cli,
257    /// Repo-local handoff/status document.
258    RepoHandoff,
259    /// Future harness or persistent-agent export.
260    AgentExport,
261    /// Governed consensus quorum episode/result artifact.
262    ConsensusQuorum,
263    /// GitHub Copilot CLI local session-store database.
264    CopilotSessionStore,
265}
266
267impl DraftSourceSurface {
268    /// Parse a CLI/config spelling.
269    #[must_use]
270    pub fn parse(value: &str) -> Option<Self> {
271        match value {
272            "directory" => Some(Self::Directory),
273            "claude_memory" | "claude-memory" => Some(Self::ClaudeMemory),
274            "codex_memory" | "codex-memory" => Some(Self::CodexMemory),
275            "mcp" => Some(Self::Mcp),
276            "cli" => Some(Self::Cli),
277            "repo_handoff" | "repo-handoff" => Some(Self::RepoHandoff),
278            "agent_export" | "agent-export" => Some(Self::AgentExport),
279            "consensus_quorum" | "consensus-quorum" => Some(Self::ConsensusQuorum),
280            "copilot_session_store" | "copilot-session-store" => Some(Self::CopilotSessionStore),
281            _ => None,
282        }
283    }
284
285    /// Stable schema string.
286    #[must_use]
287    pub fn as_str(self) -> &'static str {
288        match self {
289            Self::Directory => "directory",
290            Self::ClaudeMemory => "claude_memory",
291            Self::CodexMemory => "codex_memory",
292            Self::Mcp => "mcp",
293            Self::Cli => "cli",
294            Self::RepoHandoff => "repo_handoff",
295            Self::AgentExport => "agent_export",
296            Self::ConsensusQuorum => "consensus_quorum",
297            Self::CopilotSessionStore => "copilot_session_store",
298        }
299    }
300}
301
302impl fmt::Display for DraftId {
303    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304        f.write_str(&self.to_hex())
305    }
306}
307
308/// Where a draft came from. Drives provenance + retention policy
309/// (e.g. `AutoMemorySweep` drafts may be deleted after successful
310/// commit; `CliSubmit` drafts may be retained for operator review).
311#[derive(Debug, Clone, PartialEq, Eq)]
312#[non_exhaustive]
313pub enum DraftSource {
314    /// Draft came from a file under the configured `drafts_dir`.
315    Directory {
316        /// Absolute path of the source file.
317        path: PathBuf,
318    },
319    /// Draft came from sweeping Claude's auto-memory directory.
320    AutoMemorySweep {
321        /// Absolute path of the auto-memory file.
322        file: PathBuf,
323    },
324    /// Draft came from sweeping Codex's memory directory.
325    CodexMemorySweep {
326        /// Absolute path of the Codex memory file.
327        file: PathBuf,
328    },
329    /// Draft was submitted via the `mimir_submit_draft` MCP tool
330    /// (a future addition; not yet wired).
331    McpSubmit {
332        /// The workspace identifier the submit was scoped to.
333        workspace: String,
334    },
335    /// Draft came from an opted-in repo-local handoff/status file.
336    RepoHandoff {
337        /// Absolute path of the source file.
338        file: PathBuf,
339    },
340    /// Draft was submitted via `mimir-librarian submit` CLI.
341    CliSubmit,
342}
343
344/// Lifecycle state of a draft on the filesystem.
345///
346/// State transitions are atomic directory renames; the on-disk
347/// location of the file IS the ground truth for state.
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
349pub enum DraftState {
350    /// Not yet processed by any librarian run.
351    Pending,
352    /// A librarian run is currently processing this draft.
353    Processing,
354    /// Successfully written to the canonical log.
355    Accepted,
356    /// Intentionally skipped (for example exact duplicate).
357    Skipped,
358    /// Retry budget exhausted; operator review required.
359    Failed,
360    /// Unsafe, conflicting, or unresolved; review only.
361    Quarantined,
362}
363
364impl DraftState {
365    /// The directory name under `drafts_dir` where files in this
366    /// state live.
367    #[must_use]
368    pub fn dir_name(self) -> &'static str {
369        match self {
370            Self::Pending => "pending",
371            Self::Processing => "processing",
372            Self::Accepted => "accepted",
373            Self::Skipped => "skipped",
374            Self::Failed => "failed",
375            Self::Quarantined => "quarantined",
376        }
377    }
378}
379
380/// Result of a successful draft lifecycle transition.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct DraftTransition {
383    /// Draft moved by this transition.
384    pub id: DraftId,
385    /// Source lifecycle state.
386    pub from: DraftState,
387    /// Target lifecycle state.
388    pub to: DraftState,
389    /// Path occupied before the atomic rename.
390    pub source_path: PathBuf,
391    /// Path occupied after the atomic rename.
392    pub target_path: PathBuf,
393}
394
395/// Filesystem-backed draft surface.
396#[derive(Debug, Clone)]
397pub struct DraftStore {
398    root: PathBuf,
399}
400
401impl DraftStore {
402    /// Construct a store rooted at `drafts_dir`.
403    #[must_use]
404    pub fn new(root: impl AsRef<Path>) -> Self {
405        Self {
406            root: root.as_ref().to_path_buf(),
407        }
408    }
409
410    /// Root drafts directory.
411    #[must_use]
412    pub fn root(&self) -> &Path {
413        &self.root
414    }
415
416    /// Directory for a lifecycle state.
417    #[must_use]
418    pub fn state_dir(&self, state: DraftState) -> PathBuf {
419        self.root.join(state.dir_name())
420    }
421
422    /// Canonical path for a draft in a lifecycle state.
423    #[must_use]
424    pub fn path_for(&self, state: DraftState, id: DraftId) -> PathBuf {
425        self.state_dir(state).join(format!("{id}.json"))
426    }
427
428    /// Ensure all lifecycle directories exist.
429    ///
430    /// # Errors
431    ///
432    /// Returns [`LibrarianError::DraftIo`] when a lifecycle directory
433    /// cannot be created.
434    pub fn ensure_dirs(&self) -> Result<(), LibrarianError> {
435        for state in DraftState::ALL {
436            fs::create_dir_all(self.state_dir(state))?;
437        }
438        Ok(())
439    }
440
441    /// Submit a draft into `pending/` as a v2 JSON envelope.
442    ///
443    /// If the target file already exists, submission is treated as
444    /// idempotent and the existing path is returned.
445    ///
446    /// # Errors
447    ///
448    /// Returns [`LibrarianError::DraftIo`] if the directory or file
449    /// operations fail, or [`LibrarianError::DraftJson`] if the draft
450    /// envelope cannot be encoded.
451    pub fn submit(&self, draft: &Draft) -> Result<PathBuf, LibrarianError> {
452        self.ensure_dirs()?;
453        let target = self.path_for(DraftState::Pending, draft.id());
454        if target.exists() {
455            return Ok(target);
456        }
457
458        let tmp = target.with_file_name(format!(".{id}.json.tmp", id = draft.id()));
459        let bytes = serde_json::to_vec_pretty(&DraftFileV2::from_draft(draft))?;
460        fs::write(&tmp, bytes)?;
461        if target.exists() {
462            return Ok(target);
463        }
464        fs::rename(&tmp, &target)?;
465        Ok(target)
466    }
467
468    /// Load one draft from a state directory.
469    ///
470    /// # Errors
471    ///
472    /// Returns [`LibrarianError::DraftIo`] if the file cannot be read,
473    /// [`LibrarianError::DraftJson`] if it is not valid JSON,
474    /// [`LibrarianError::UnsupportedDraftSchema`] for unknown schema
475    /// versions, or [`LibrarianError::DraftIdMismatch`] if the stored
476    /// ID does not match the envelope.
477    pub fn load(&self, state: DraftState, id: DraftId) -> Result<Draft, LibrarianError> {
478        Self::load_path(&self.path_for(state, id))
479    }
480
481    /// List drafts in one state directory.
482    ///
483    /// # Errors
484    ///
485    /// Returns the same errors as [`DraftStore::load`] for any listed
486    /// draft, and [`LibrarianError::DraftIo`] if the state directory
487    /// cannot be read.
488    pub fn list(&self, state: DraftState) -> Result<Vec<Draft>, LibrarianError> {
489        self.ensure_dirs()?;
490        let mut paths = Vec::new();
491        for entry in fs::read_dir(self.state_dir(state))? {
492            let path = entry?.path();
493            if path.extension().and_then(|s| s.to_str()) == Some("json") {
494                paths.push(path);
495            }
496        }
497        paths.sort();
498
499        let mut drafts = Vec::with_capacity(paths.len());
500        for path in paths {
501            drafts.push(Self::load_path(&path)?);
502        }
503        Ok(drafts)
504    }
505
506    /// Move a draft from one lifecycle state to another.
507    ///
508    /// The state graph is intentionally small:
509    ///
510    /// - `pending -> processing` claims work for a run.
511    /// - `processing -> accepted | skipped | failed | quarantined`
512    ///   finishes work.
513    /// - `processing -> pending` recovers stale in-flight work after
514    ///   a crash or abandoned run.
515    ///
516    /// The file move is a single filesystem rename. The method refuses
517    /// to overwrite an existing target path.
518    ///
519    /// # Errors
520    ///
521    /// Returns [`LibrarianError::InvalidDraftTransition`] for edges
522    /// outside the lifecycle graph, [`LibrarianError::DraftNotFound`]
523    /// when the source file is absent, [`LibrarianError::DraftAlreadyExists`]
524    /// when the target path is already occupied, or the same load / I/O
525    /// errors as [`DraftStore::load`].
526    pub fn transition(
527        &self,
528        id: DraftId,
529        from: DraftState,
530        to: DraftState,
531    ) -> Result<DraftTransition, LibrarianError> {
532        self.ensure_dirs()?;
533        if !is_valid_transition(from, to) {
534            return Err(LibrarianError::InvalidDraftTransition { from, to });
535        }
536
537        let source_path = self.path_for(from, id);
538        if !source_path.exists() {
539            return Err(LibrarianError::DraftNotFound { state: from, id });
540        }
541
542        let draft = Self::load_path(&source_path)?;
543        if draft.id() != id {
544            return Err(LibrarianError::DraftIdMismatch {
545                declared: id.to_hex(),
546                computed: draft.id().to_hex(),
547            });
548        }
549
550        let target_path = self.path_for(to, id);
551        if target_path.exists() {
552            return Err(LibrarianError::DraftAlreadyExists { state: to, id });
553        }
554
555        if to == DraftState::Processing {
556            self.write_processing_claim_marker(id)?;
557        }
558
559        if let Err(err) = fs::rename(&source_path, &target_path) {
560            if to == DraftState::Processing {
561                self.remove_processing_claim_marker(id)?;
562            }
563            return Err(err.into());
564        }
565
566        if from == DraftState::Processing {
567            self.remove_processing_claim_marker(id)?;
568        }
569        Ok(DraftTransition {
570            id,
571            from,
572            to,
573            source_path,
574            target_path,
575        })
576    }
577
578    /// Recover stale drafts from `processing/` back to `pending/`.
579    ///
580    /// A processing draft is stale when its claim marker modified time
581    /// is at or before `stale_before`. Callers usually pass
582    /// `SystemTime::now() - processing_timeout`; tests can pass a future
583    /// cutoff to recover all processing drafts deterministically.
584    ///
585    /// # Errors
586    ///
587    /// Returns the same errors as [`DraftStore::transition`] for any
588    /// stale draft that cannot be moved, plus [`LibrarianError::DraftIo`]
589    /// for directory iteration or metadata errors.
590    pub fn recover_stale_processing(
591        &self,
592        stale_before: SystemTime,
593    ) -> Result<Vec<DraftTransition>, LibrarianError> {
594        self.ensure_dirs()?;
595        let mut stale = Vec::new();
596        for entry in fs::read_dir(self.state_dir(DraftState::Processing))? {
597            let path = entry?.path();
598            if path.extension().and_then(|s| s.to_str()) != Some("json") {
599                continue;
600            }
601            let draft = Self::load_path(&path)?;
602            let modified = self.processing_claim_modified_at(draft.id(), &path)?;
603            if modified <= stale_before {
604                stale.push((path, draft.id()));
605            }
606        }
607        stale.sort_by(|left, right| left.0.cmp(&right.0));
608
609        let mut recovered = Vec::with_capacity(stale.len());
610        for (_, id) in stale {
611            recovered.push(self.transition(id, DraftState::Processing, DraftState::Pending)?);
612        }
613        Ok(recovered)
614    }
615
616    fn load_path(path: &Path) -> Result<Draft, LibrarianError> {
617        let text = fs::read_to_string(path)?;
618        let file: DraftFileV2 = serde_json::from_str(&text)?;
619        file.into_draft()
620    }
621
622    fn processing_claim_path(&self, id: DraftId) -> PathBuf {
623        self.state_dir(DraftState::Processing).join(format!(
624            "{}.{}",
625            id.to_hex(),
626            PROCESSING_CLAIM_EXT
627        ))
628    }
629
630    fn write_processing_claim_marker(&self, id: DraftId) -> Result<(), LibrarianError> {
631        fs::write(self.processing_claim_path(id), b"claimed\n")?;
632        Ok(())
633    }
634
635    fn remove_processing_claim_marker(&self, id: DraftId) -> Result<(), LibrarianError> {
636        match fs::remove_file(self.processing_claim_path(id)) {
637            Ok(()) => Ok(()),
638            Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
639            Err(err) => Err(err.into()),
640        }
641    }
642
643    fn processing_claim_modified_at(
644        &self,
645        id: DraftId,
646        draft_path: &Path,
647    ) -> Result<SystemTime, LibrarianError> {
648        match fs::metadata(self.processing_claim_path(id)) {
649            Ok(metadata) => Ok(metadata.modified()?),
650            Err(err) if err.kind() == ErrorKind::NotFound => {
651                Ok(fs::metadata(draft_path)?.modified()?)
652            }
653            Err(err) => Err(err.into()),
654        }
655    }
656}
657
658fn is_valid_transition(from: DraftState, to: DraftState) -> bool {
659    matches!(
660        (from, to),
661        (DraftState::Pending, DraftState::Processing)
662            | (
663                DraftState::Processing,
664                DraftState::Pending
665                    | DraftState::Accepted
666                    | DraftState::Skipped
667                    | DraftState::Failed
668                    | DraftState::Quarantined,
669            )
670    )
671}
672
673impl DraftState {
674    /// All known lifecycle states.
675    pub const ALL: [Self; 6] = [
676        Self::Pending,
677        Self::Processing,
678        Self::Accepted,
679        Self::Skipped,
680        Self::Failed,
681        Self::Quarantined,
682    ];
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize)]
686struct DraftFileV2 {
687    schema_version: u32,
688    id: String,
689    source_surface: DraftSourceSurface,
690    source_agent: Option<String>,
691    source_project: Option<String>,
692    operator: Option<String>,
693    provenance_uri: Option<String>,
694    context_tags: Vec<String>,
695    submitted_at_unix_ms: u64,
696    raw_text: String,
697}
698
699impl DraftFileV2 {
700    fn from_draft(draft: &Draft) -> Self {
701        let metadata = draft.metadata();
702        Self {
703            schema_version: DRAFT_SCHEMA_VERSION,
704            id: draft.id().to_hex(),
705            source_surface: metadata.source_surface,
706            source_agent: metadata.source_agent.clone(),
707            source_project: metadata.source_project.clone(),
708            operator: metadata.operator.clone(),
709            provenance_uri: metadata.provenance_uri.clone(),
710            context_tags: metadata.context_tags.clone(),
711            submitted_at_unix_ms: system_time_to_unix_ms(metadata.submitted_at),
712            raw_text: draft.raw_text().to_string(),
713        }
714    }
715
716    fn into_draft(self) -> Result<Draft, LibrarianError> {
717        if self.schema_version != DRAFT_SCHEMA_VERSION {
718            return Err(LibrarianError::UnsupportedDraftSchema {
719                version: self.schema_version,
720            });
721        }
722        let metadata = DraftMetadata {
723            source_surface: self.source_surface,
724            source_agent: self.source_agent,
725            source_project: self.source_project,
726            operator: self.operator,
727            provenance_uri: self.provenance_uri,
728            context_tags: self.context_tags,
729            submitted_at: unix_ms_to_system_time(self.submitted_at_unix_ms),
730        };
731        let draft = Draft::with_metadata(self.raw_text, metadata);
732        let computed = draft.id().to_hex();
733        if self.id != computed {
734            return Err(LibrarianError::DraftIdMismatch {
735                declared: self.id,
736                computed,
737            });
738        }
739        Ok(draft)
740    }
741}
742
743fn system_time_to_unix_ms(time: SystemTime) -> u64 {
744    match time.duration_since(UNIX_EPOCH) {
745        Ok(duration) => u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
746        Err(_) => 0,
747    }
748}
749
750fn unix_ms_to_system_time(ms: u64) -> SystemTime {
751    UNIX_EPOCH + Duration::from_millis(ms)
752}
753
754fn path_to_file_uri(path: &Path) -> String {
755    format!("file://{}", path.display())
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn draft_id_is_content_addressed() {
764        let id_a = DraftId::from_prose("hello world");
765        let id_b = DraftId::from_prose("hello world");
766        let id_c = DraftId::from_prose("hello world!");
767        assert_eq!(id_a, id_b, "identical prose must produce identical IDs");
768        assert_ne!(id_a, id_c, "different prose must produce different IDs");
769    }
770
771    #[test]
772    fn draft_id_hex_is_16_chars() {
773        let id = DraftId::from_prose("anything");
774        let hex = id.to_hex();
775        assert_eq!(hex.len(), 16);
776        assert!(hex
777            .chars()
778            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
779    }
780
781    #[test]
782    fn draft_id_display_matches_hex() {
783        let id = DraftId::from_prose("anything");
784        assert_eq!(format!("{id}"), id.to_hex());
785    }
786
787    #[test]
788    fn draft_state_dir_names_are_distinct() {
789        let names: std::collections::HashSet<_> =
790            DraftState::ALL.iter().map(|s| s.dir_name()).collect();
791        assert_eq!(
792            names.len(),
793            DraftState::ALL.len(),
794            "every state must have a distinct dir name"
795        );
796    }
797
798    #[test]
799    fn draft_constructor_derives_id_from_text_and_metadata() {
800        let prose = "Alain is the owner of Mimir.";
801        let draft = Draft::new(
802            prose.to_string(),
803            &DraftSource::CliSubmit,
804            SystemTime::UNIX_EPOCH,
805        );
806        assert_eq!(
807            draft.id(),
808            DraftId::from_raw_text_and_metadata(prose, draft.metadata())
809        );
810        assert_eq!(draft.prose(), prose);
811        assert_eq!(draft.metadata().source_surface, DraftSourceSurface::Cli);
812    }
813
814    #[test]
815    fn draft_metadata_carries_scope_model_fields() {
816        let mut metadata =
817            DraftMetadata::new(DraftSourceSurface::CodexMemory, SystemTime::UNIX_EPOCH);
818        metadata.source_agent = Some("codex".to_string());
819        metadata.source_project = Some("buildepicshit/Mimir".to_string());
820        metadata.operator = Some("AlainDor".to_string());
821        metadata.provenance_uri =
822            Some("file:///home/hasnobeef/.codex/memories/mimir.md".to_string());
823        metadata.context_tags = vec!["mimir".to_string(), "scope-model".to_string()];
824
825        let draft = Draft::with_metadata(
826            "remember the governed scope invariant".to_string(),
827            metadata,
828        );
829
830        assert_eq!(
831            draft.metadata().source_surface,
832            DraftSourceSurface::CodexMemory
833        );
834        assert_eq!(draft.metadata().source_agent.as_deref(), Some("codex"));
835        assert_eq!(
836            draft.metadata().source_project.as_deref(),
837            Some("buildepicshit/Mimir")
838        );
839        assert_eq!(draft.metadata().operator.as_deref(), Some("AlainDor"));
840        assert_eq!(draft.metadata().context_tags.len(), 2);
841    }
842
843    #[test]
844    fn draft_id_distinguishes_same_text_from_different_provenance() {
845        let raw = "Use governed promotion for ecosystem memory.";
846        let mut claude =
847            DraftMetadata::new(DraftSourceSurface::ClaudeMemory, SystemTime::UNIX_EPOCH);
848        claude.provenance_uri =
849            Some("file:///home/hasnobeef/.claude/projects/mimir/memory/a.md".into());
850        let mut codex = DraftMetadata::new(DraftSourceSurface::CodexMemory, SystemTime::UNIX_EPOCH);
851        codex.provenance_uri = Some("file:///home/hasnobeef/.codex/memories/mimir.md".into());
852
853        let claude_draft = Draft::with_metadata(raw.to_string(), claude);
854        let codex_draft = Draft::with_metadata(raw.to_string(), codex);
855
856        assert_ne!(
857            claude_draft.id(),
858            codex_draft.id(),
859            "same text from different provenance must not collapse into one draft"
860        );
861    }
862
863    #[test]
864    fn draft_state_dir_names_cover_scope_model_lifecycle() {
865        let states = [
866            DraftState::Pending,
867            DraftState::Processing,
868            DraftState::Accepted,
869            DraftState::Skipped,
870            DraftState::Failed,
871            DraftState::Quarantined,
872        ];
873        let names: std::collections::HashSet<_> = states.iter().map(|s| s.dir_name()).collect();
874        assert_eq!(names.len(), states.len());
875        assert_eq!(DraftState::Accepted.dir_name(), "accepted");
876        assert_eq!(DraftState::Quarantined.dir_name(), "quarantined");
877    }
878
879    #[test]
880    fn draft_store_submits_v2_json_to_pending() -> Result<(), Box<dyn std::error::Error>> {
881        let tmp = tempfile::tempdir()?;
882        let store = DraftStore::new(tmp.path());
883        let mut metadata = DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH);
884        metadata.operator = Some("AlainDor".to_string());
885        metadata.provenance_uri = Some("cli://mimir-librarian/submit".to_string());
886        let draft =
887            Draft::with_metadata("Mimir should govern memory scopes.".to_string(), metadata);
888
889        let path = store.submit(&draft)?;
890        assert_eq!(path, store.path_for(DraftState::Pending, draft.id()));
891        assert!(path.exists());
892
893        let saved = std::fs::read_to_string(&path)?;
894        assert!(saved.contains("\"schema_version\": 2"));
895        assert!(saved.contains("\"source_surface\": \"cli\""));
896        assert!(saved.contains("\"operator\": \"AlainDor\""));
897
898        let loaded = store.load(DraftState::Pending, draft.id())?;
899        assert_eq!(loaded.id(), draft.id());
900        assert_eq!(loaded.raw_text(), draft.raw_text());
901        assert_eq!(loaded.metadata().source_surface, DraftSourceSurface::Cli);
902        Ok(())
903    }
904
905    #[test]
906    fn draft_store_submit_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
907        let tmp = tempfile::tempdir()?;
908        let store = DraftStore::new(tmp.path());
909        let draft = Draft::with_metadata(
910            "Repeated sweeps should not duplicate a draft.".to_string(),
911            DraftMetadata::new(DraftSourceSurface::ClaudeMemory, SystemTime::UNIX_EPOCH),
912        );
913
914        let first = store.submit(&draft)?;
915        let second = store.submit(&draft)?;
916
917        assert_eq!(first, second);
918        assert_eq!(store.list(DraftState::Pending)?.len(), 1);
919        Ok(())
920    }
921
922    #[test]
923    fn draft_store_moves_pending_to_processing_then_terminal(
924    ) -> Result<(), Box<dyn std::error::Error>> {
925        let tmp = tempfile::tempdir()?;
926        let store = DraftStore::new(tmp.path());
927        let draft = Draft::with_metadata(
928            "Draft lifecycle movement should be atomic and visible.".to_string(),
929            DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH),
930        );
931        store.submit(&draft)?;
932
933        let claimed = store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
934        assert_eq!(claimed.id, draft.id());
935        assert_eq!(claimed.from, DraftState::Pending);
936        assert_eq!(claimed.to, DraftState::Processing);
937        assert!(!store.path_for(DraftState::Pending, draft.id()).exists());
938        assert!(store.path_for(DraftState::Processing, draft.id()).exists());
939        assert!(store.processing_claim_path(draft.id()).exists());
940        assert_eq!(store.list(DraftState::Pending)?.len(), 0);
941        assert_eq!(store.list(DraftState::Processing)?.len(), 1);
942
943        let accepted =
944            store.transition(draft.id(), DraftState::Processing, DraftState::Accepted)?;
945        assert_eq!(accepted.to, DraftState::Accepted);
946        assert!(!store.path_for(DraftState::Processing, draft.id()).exists());
947        assert!(store.path_for(DraftState::Accepted, draft.id()).exists());
948        assert!(!store.processing_claim_path(draft.id()).exists());
949        let loaded = store.load(DraftState::Accepted, draft.id())?;
950        assert_eq!(loaded.raw_text(), draft.raw_text());
951        assert_eq!(store.list(DraftState::Processing)?.len(), 0);
952        assert_eq!(store.list(DraftState::Accepted)?.len(), 1);
953        Ok(())
954    }
955
956    #[test]
957    fn draft_store_rejects_invalid_lifecycle_transition() -> Result<(), Box<dyn std::error::Error>>
958    {
959        let tmp = tempfile::tempdir()?;
960        let store = DraftStore::new(tmp.path());
961        let draft = Draft::with_metadata(
962            "Terminal states should only be reached from processing.".to_string(),
963            DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH),
964        );
965        store.submit(&draft)?;
966
967        let err = match store.transition(draft.id(), DraftState::Pending, DraftState::Accepted) {
968            Err(err) => err,
969            Ok(transition) => {
970                return Err(
971                    format!("pending -> accepted must be rejected, got {transition:?}").into(),
972                );
973            }
974        };
975        assert!(matches!(
976            err,
977            LibrarianError::InvalidDraftTransition {
978                from: DraftState::Pending,
979                to: DraftState::Accepted
980            }
981        ));
982        assert!(store.path_for(DraftState::Pending, draft.id()).exists());
983        assert!(!store.path_for(DraftState::Accepted, draft.id()).exists());
984        Ok(())
985    }
986
987    #[test]
988    fn draft_store_allows_every_processing_terminal_state() -> Result<(), Box<dyn std::error::Error>>
989    {
990        let tmp = tempfile::tempdir()?;
991        let store = DraftStore::new(tmp.path());
992        for terminal in [
993            DraftState::Accepted,
994            DraftState::Skipped,
995            DraftState::Failed,
996            DraftState::Quarantined,
997        ] {
998            let draft = Draft::with_metadata(
999                format!("Draft should finish as {}.", terminal.dir_name()),
1000                DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH),
1001            );
1002            store.submit(&draft)?;
1003            store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
1004
1005            let finished = store.transition(draft.id(), DraftState::Processing, terminal)?;
1006            assert_eq!(finished.to, terminal);
1007            assert!(store.path_for(terminal, draft.id()).exists());
1008            assert_eq!(store.load(terminal, draft.id())?.id(), draft.id());
1009        }
1010        assert_eq!(store.list(DraftState::Processing)?.len(), 0);
1011        Ok(())
1012    }
1013
1014    #[test]
1015    fn draft_store_rejects_transition_when_target_exists() -> Result<(), Box<dyn std::error::Error>>
1016    {
1017        let tmp = tempfile::tempdir()?;
1018        let store = DraftStore::new(tmp.path());
1019        let draft = Draft::with_metadata(
1020            "Draft transition should never overwrite a target file.".to_string(),
1021            DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH),
1022        );
1023        store.submit(&draft)?;
1024        store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
1025        std::fs::copy(
1026            store.path_for(DraftState::Processing, draft.id()),
1027            store.path_for(DraftState::Accepted, draft.id()),
1028        )?;
1029
1030        let err = match store.transition(draft.id(), DraftState::Processing, DraftState::Accepted) {
1031            Err(err) => err,
1032            Ok(transition) => {
1033                return Err(format!(
1034                    "transition must not overwrite an existing terminal draft, got {transition:?}"
1035                )
1036                .into());
1037            }
1038        };
1039        assert!(matches!(
1040            err,
1041            LibrarianError::DraftAlreadyExists {
1042                state: DraftState::Accepted,
1043                id: existing
1044            } if existing == draft.id()
1045        ));
1046        assert!(store.path_for(DraftState::Processing, draft.id()).exists());
1047        Ok(())
1048    }
1049
1050    #[test]
1051    fn draft_store_reports_missing_transition_source() -> Result<(), Box<dyn std::error::Error>> {
1052        let tmp = tempfile::tempdir()?;
1053        let store = DraftStore::new(tmp.path());
1054        let id = DraftId::from_prose("missing");
1055
1056        let err = match store.transition(id, DraftState::Pending, DraftState::Processing) {
1057            Err(err) => err,
1058            Ok(transition) => {
1059                return Err(format!(
1060                    "missing pending draft should be an explicit error, got {transition:?}"
1061                )
1062                .into());
1063            }
1064        };
1065        assert!(matches!(
1066            err,
1067            LibrarianError::DraftNotFound {
1068                state: DraftState::Pending,
1069                id: missing
1070            } if missing == id
1071        ));
1072        Ok(())
1073    }
1074
1075    #[test]
1076    fn draft_store_recovers_stale_processing_back_to_pending(
1077    ) -> Result<(), Box<dyn std::error::Error>> {
1078        let tmp = tempfile::tempdir()?;
1079        let store = DraftStore::new(tmp.path());
1080        let draft = Draft::with_metadata(
1081            "Crash recovery should return stale processing drafts to pending.".to_string(),
1082            DraftMetadata::new(DraftSourceSurface::CodexMemory, SystemTime::UNIX_EPOCH),
1083        );
1084        store.submit(&draft)?;
1085        store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
1086
1087        let cutoff = SystemTime::now() + Duration::from_secs(60);
1088        let recovered = store.recover_stale_processing(cutoff)?;
1089        assert_eq!(recovered.len(), 1);
1090        assert_eq!(recovered[0].id, draft.id());
1091        assert_eq!(recovered[0].from, DraftState::Processing);
1092        assert_eq!(recovered[0].to, DraftState::Pending);
1093        assert_eq!(store.list(DraftState::Processing)?.len(), 0);
1094        assert_eq!(store.list(DraftState::Pending)?.len(), 1);
1095
1096        let recovered_again = store.recover_stale_processing(cutoff)?;
1097        assert!(
1098            recovered_again.is_empty(),
1099            "recovery should be idempotent once no drafts remain in processing"
1100        );
1101        Ok(())
1102    }
1103
1104    #[test]
1105    fn draft_store_keeps_fresh_processing_drafts_in_place() -> Result<(), Box<dyn std::error::Error>>
1106    {
1107        let tmp = tempfile::tempdir()?;
1108        let store = DraftStore::new(tmp.path());
1109        let draft = Draft::with_metadata(
1110            "Fresh in-flight work should not be recovered early.".to_string(),
1111            DraftMetadata::new(DraftSourceSurface::ClaudeMemory, SystemTime::UNIX_EPOCH),
1112        );
1113        store.submit(&draft)?;
1114        store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
1115
1116        let recovered = store.recover_stale_processing(SystemTime::UNIX_EPOCH)?;
1117        assert!(recovered.is_empty());
1118        assert_eq!(store.list(DraftState::Processing)?.len(), 1);
1119        assert_eq!(store.list(DraftState::Pending)?.len(), 0);
1120        Ok(())
1121    }
1122
1123    #[test]
1124    fn source_surface_parse_accepts_cli_spellings() {
1125        assert_eq!(
1126            DraftSourceSurface::parse("codex-memory"),
1127            Some(DraftSourceSurface::CodexMemory)
1128        );
1129        assert_eq!(
1130            DraftSourceSurface::parse("codex_memory"),
1131            Some(DraftSourceSurface::CodexMemory)
1132        );
1133        assert_eq!(
1134            DraftSourceSurface::parse("consensus-quorum"),
1135            Some(DraftSourceSurface::ConsensusQuorum)
1136        );
1137        assert_eq!(
1138            DraftSourceSurface::parse("consensus_quorum"),
1139            Some(DraftSourceSurface::ConsensusQuorum)
1140        );
1141        assert_eq!(DraftSourceSurface::parse("unknown"), None);
1142    }
1143}