Skip to main content

lex_store/
store.rs

1//! `Store` — content-addressed code repository.
2//!
3//! The filesystem is the source of truth. All operations read/write JSON
4//! files under `<root>/stages/<SigId>/`. There is no SQLite cache: every
5//! query walks the directory and parses what's needed. `cargo test`
6//! runs aren't perf-critical and the §4.6 acceptance requires the
7//! rebuild-from-filesystem property anyway.
8
9use crate::branches::DEFAULT_BRANCH;
10use crate::model::*;
11use lex_ast::{sig_id, stage_id, Stage};
12use serde::de::DeserializeOwned;
13use serde::Serialize;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18#[derive(Debug, thiserror::Error)]
19pub enum StoreError {
20    #[error("io error: {0}")]
21    Io(#[from] std::io::Error),
22    #[error("serialization error: {0}")]
23    Serde(#[from] serde_json::Error),
24    #[error("imports cannot be published as stages")]
25    CannotPublishImport,
26    #[error("unknown stage_id `{0}`")]
27    UnknownStage(String),
28    #[error("unknown sig_id `{0}`")]
29    UnknownSig(String),
30    #[error("invalid lifecycle transition: {0}")]
31    InvalidTransition(String),
32    #[error("unknown branch `{0}`")]
33    UnknownBranch(String),
34    #[error(transparent)]
35    Apply(#[from] lex_vcs::ApplyError),
36    /// The candidate program — i.e. the source the caller is
37    /// publishing — doesn't typecheck. The branch head is unchanged
38    /// and no op records are persisted. Issue #130's "always-valid
39    /// HEAD" invariant: the gate runs before any side effect, so a
40    /// type-broken publish leaves no footprint.
41    #[error("type errors in published program: {} error(s)", .0.len())]
42    TypeError(Vec<lex_types::TypeError>),
43}
44
45/// The outcome returned by [`Store::publish_program`].
46#[derive(Debug, Clone, serde::Serialize)]
47pub struct PublishOutcome {
48    pub ops: Vec<PublishOp>,
49    pub head_op: Option<lex_vcs::OpId>,
50}
51
52/// One applied operation within a [`PublishOutcome`].
53#[derive(Debug, Clone, serde::Serialize)]
54pub struct PublishOp {
55    pub op_id: lex_vcs::OpId,
56    pub kind: serde_json::Value,
57}
58
59/// One entry in the per-`SigId` stage history surfaced by
60/// `Store::sig_history`. Newest-first ordering is the responsibility
61/// of the producer.
62#[derive(Debug, Clone, serde::Serialize, PartialEq)]
63pub struct StageHistoryEntry {
64    pub stage_id: String,
65    pub status: StageStatus,
66    /// Wall-clock seconds of the most recent transition.
67    pub last_at: u64,
68    /// Wall-clock seconds when this stage was first written to the
69    /// store (its initial Draft transition). `None` for stages
70    /// whose lifecycle log doesn't include an explicit Draft entry
71    /// — shouldn't happen for stages published via `Store::publish`,
72    /// but the type allows hand-edited stores.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub published_at: Option<u64>,
75}
76
77pub struct Store {
78    root: PathBuf,
79}
80
81impl Store {
82    /// Open or create a store rooted at `root`.
83    pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
84        let root = root.as_ref().to_path_buf();
85        fs::create_dir_all(root.join("stages"))?;
86        fs::create_dir_all(root.join("traces"))?;
87        Ok(Self { root })
88    }
89
90    pub fn root(&self) -> &Path { &self.root }
91
92    fn now() -> u64 {
93        SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
94    }
95
96    fn sig_dir(&self, sig: &str) -> PathBuf { self.root.join("stages").join(sig) }
97    fn impl_dir(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("implementations") }
98    fn tests_dir(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("tests") }
99    fn specs_dir(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("specs") }
100    fn lifecycle_path(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("lifecycle.json") }
101
102    // ---- publish ----
103
104    /// Publish a stage as **Draft**. Returns the StageId.
105    /// Idempotent: republishing the same canonical AST returns the same
106    /// StageId without writing duplicates.
107    pub fn publish(&self, stage: &Stage) -> Result<String, StoreError> {
108        let sig = sig_id(stage).ok_or(StoreError::CannotPublishImport)?;
109        let stage_id = stage_id(stage).ok_or(StoreError::CannotPublishImport)?;
110        let name = stage_name(stage).to_string();
111
112        fs::create_dir_all(self.impl_dir(&sig))?;
113        fs::create_dir_all(self.tests_dir(&sig))?;
114        fs::create_dir_all(self.specs_dir(&sig))?;
115
116        let ast_path = self.impl_dir(&sig).join(format!("{}.ast.json", stage_id));
117        let meta_path = self.impl_dir(&sig).join(format!("{}.metadata.json", stage_id));
118
119        if !ast_path.exists() {
120            write_canonical_json(&ast_path, stage)?;
121        }
122        if !meta_path.exists() {
123            let metadata = Metadata {
124                stage_id: stage_id.clone(),
125                sig_id: sig.clone(),
126                name,
127                published_at: Self::now(),
128                note: None,
129            };
130            write_canonical_json(&meta_path, &metadata)?;
131        }
132
133        // Lifecycle: append a Draft transition for first publish.
134        let mut life = self.read_lifecycle(&sig).unwrap_or_else(|_| Lifecycle {
135            sig_id: sig.clone(),
136            ..Default::default()
137        });
138        if !life.transitions.iter().any(|t| t.stage_id == stage_id) {
139            life.transitions.push(Transition {
140                stage_id: stage_id.clone(),
141                from: StageStatus::Draft, // synthesized; "from" of first transition is itself
142                to: StageStatus::Draft,
143                at: Self::now(),
144                reason: None,
145            });
146            self.write_lifecycle(&sig, &life)?;
147        }
148        Ok(stage_id)
149    }
150
151    // ---- lifecycle ----
152
153    pub fn activate(&self, stage_id: &str) -> Result<(), StoreError> {
154        let (sig, mut life) = self.lookup_lifecycle(stage_id)?;
155        // Demote any currently-Active impls for this SigId to Deprecated.
156        let active = life.current_active().map(|s| s.to_string());
157        if let Some(prev) = active {
158            if prev != stage_id {
159                life.transitions.push(Transition {
160                    stage_id: prev,
161                    from: StageStatus::Active,
162                    to: StageStatus::Deprecated,
163                    at: Self::now(),
164                    reason: Some("superseded".into()),
165                });
166            }
167        }
168        let cur = life.status_of(stage_id);
169        if cur == Some(StageStatus::Tombstone) {
170            return Err(StoreError::InvalidTransition("tombstoned cannot be activated".into()));
171        }
172        life.transitions.push(Transition {
173            stage_id: stage_id.into(),
174            from: cur.unwrap_or(StageStatus::Draft),
175            to: StageStatus::Active,
176            at: Self::now(),
177            reason: None,
178        });
179        self.write_lifecycle(&sig, &life)
180    }
181
182    pub fn deprecate(&self, stage_id: &str, reason: impl Into<String>) -> Result<(), StoreError> {
183        let (sig, mut life) = self.lookup_lifecycle(stage_id)?;
184        let cur = life.status_of(stage_id).ok_or_else(|| StoreError::UnknownStage(stage_id.into()))?;
185        if cur != StageStatus::Active {
186            return Err(StoreError::InvalidTransition(format!("{cur:?} ⇒ Deprecated")));
187        }
188        life.transitions.push(Transition {
189            stage_id: stage_id.into(),
190            from: cur,
191            to: StageStatus::Deprecated,
192            at: Self::now(),
193            reason: Some(reason.into()),
194        });
195        self.write_lifecycle(&sig, &life)
196    }
197
198    pub fn tombstone(&self, stage_id: &str) -> Result<(), StoreError> {
199        let (sig, mut life) = self.lookup_lifecycle(stage_id)?;
200        let cur = life.status_of(stage_id).ok_or_else(|| StoreError::UnknownStage(stage_id.into()))?;
201        if cur != StageStatus::Deprecated {
202            return Err(StoreError::InvalidTransition(format!("{cur:?} ⇒ Tombstone")));
203        }
204        life.transitions.push(Transition {
205            stage_id: stage_id.into(),
206            from: cur,
207            to: StageStatus::Tombstone,
208            at: Self::now(),
209            reason: None,
210        });
211        self.write_lifecycle(&sig, &life)
212    }
213
214    // ---- queries ----
215
216    /// The current Active StageId for a signature, or `None`.
217    pub fn resolve_sig(&self, sig: &str) -> Result<Option<String>, StoreError> {
218        let life = match self.read_lifecycle(sig) {
219            Ok(l) => l,
220            Err(_) => return Ok(None),
221        };
222        Ok(life.current_active().map(|s| s.to_string()))
223    }
224
225    /// Per-stage history for a SigId, ordered chronologically by
226    /// the *last* transition timestamp. Returns one entry per
227    /// distinct StageId that has ever been published under `sig`.
228    /// `Ok(vec![])` if the SigId doesn't exist in the store.
229    ///
230    /// Used by `lex blame` to render "where does this fn come from".
231    pub fn sig_history(&self, sig: &str) -> Result<Vec<StageHistoryEntry>, StoreError> {
232        let life = match self.read_lifecycle(sig) {
233            Ok(l) => l,
234            Err(_) => return Ok(Vec::new()),
235        };
236        // Collapse transitions: latest status + last_at per stage,
237        // plus the timestamp of the first Draft transition (≈ when
238        // the stage was published) when one exists.
239        let mut by_stage: indexmap::IndexMap<String, StageHistoryEntry> =
240            indexmap::IndexMap::new();
241        for t in &life.transitions {
242            let entry = by_stage.entry(t.stage_id.clone()).or_insert(StageHistoryEntry {
243                stage_id: t.stage_id.clone(),
244                status: t.to,
245                last_at: t.at,
246                published_at: None,
247            });
248            entry.status = t.to;
249            entry.last_at = t.at;
250            if t.from == StageStatus::Draft && entry.published_at.is_none() {
251                entry.published_at = Some(t.at);
252            }
253            if t.to == StageStatus::Draft && entry.published_at.is_none() {
254                // Initial publication: Draft is the *destination*.
255                entry.published_at = Some(t.at);
256            }
257        }
258        let mut out: Vec<StageHistoryEntry> = by_stage.into_values().collect();
259        // Sort newest first so `lex blame` shows recent activity at top.
260        out.sort_by_key(|e| std::cmp::Reverse(e.last_at));
261        Ok(out)
262    }
263
264    pub fn get_ast(&self, stage_id: &str) -> Result<Stage, StoreError> {
265        let (sig, _) = self.lookup_lifecycle(stage_id)?;
266        let path = self.impl_dir(&sig).join(format!("{}.ast.json", stage_id));
267        let bytes = fs::read(&path)?;
268        Ok(serde_json::from_slice(&bytes)?)
269    }
270
271    pub fn get_metadata(&self, stage_id: &str) -> Result<Metadata, StoreError> {
272        let (sig, _) = self.lookup_lifecycle(stage_id)?;
273        let path = self.impl_dir(&sig).join(format!("{}.metadata.json", stage_id));
274        let bytes = fs::read(&path)?;
275        Ok(serde_json::from_slice(&bytes)?)
276    }
277
278    pub fn get_status(&self, stage_id: &str) -> Result<StageStatus, StoreError> {
279        let (_sig, life) = self.lookup_lifecycle(stage_id)?;
280        life.status_of(stage_id).ok_or_else(|| StoreError::UnknownStage(stage_id.into()))
281    }
282
283    pub fn list_stages_by_name(&self, name: &str) -> Result<Vec<String>, StoreError> {
284        // Walk every SigId → check metadata of any implementation; if its
285        // name matches, include the SigId.
286        let mut out = Vec::new();
287        let stages_dir = self.root.join("stages");
288        if !stages_dir.exists() { return Ok(out); }
289        for entry in fs::read_dir(&stages_dir)? {
290            let entry = entry?;
291            let sig_dir = entry.path();
292            if !sig_dir.is_dir() { continue; }
293            let sig = entry.file_name().to_string_lossy().to_string();
294            // Look at any one metadata file under this SigId.
295            let impls = self.impl_dir(&sig);
296            if !impls.exists() { continue; }
297            for f in fs::read_dir(impls)? {
298                let f = f?;
299                let p = f.path();
300                if p.extension().is_some_and(|e| e == "json")
301                    && p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(".metadata.json"))
302                {
303                    if let Ok(bytes) = fs::read(&p) {
304                        if let Ok(m) = serde_json::from_slice::<Metadata>(&bytes) {
305                            if m.name == name {
306                                if !out.contains(&sig) { out.push(sig.clone()); }
307                                break;
308                            }
309                        }
310                    }
311                }
312            }
313        }
314        out.sort();
315        Ok(out)
316    }
317
318    pub fn list_sigs(&self) -> Result<Vec<String>, StoreError> {
319        let stages_dir = self.root.join("stages");
320        let mut out = Vec::new();
321        if !stages_dir.exists() { return Ok(out); }
322        for entry in fs::read_dir(stages_dir)? {
323            let entry = entry?;
324            if entry.file_type()?.is_dir() {
325                out.push(entry.file_name().to_string_lossy().to_string());
326            }
327        }
328        out.sort();
329        Ok(out)
330    }
331
332    // ---- tests/specs as metadata (§4.4) ----
333
334    pub fn attach_test(&self, sig: &str, test: &Test) -> Result<String, StoreError> {
335        if !self.sig_dir(sig).exists() {
336            return Err(StoreError::UnknownSig(sig.into()));
337        }
338        fs::create_dir_all(self.tests_dir(sig))?;
339        let path = self.tests_dir(sig).join(format!("{}.json", test.id));
340        write_canonical_json(&path, test)?;
341        Ok(test.id.clone())
342    }
343
344    pub fn list_tests(&self, sig: &str) -> Result<Vec<Test>, StoreError> {
345        let dir = self.tests_dir(sig);
346        if !dir.exists() { return Ok(Vec::new()); }
347        let mut out = Vec::new();
348        for f in fs::read_dir(dir)? {
349            let f = f?;
350            if f.path().extension().is_some_and(|e| e == "json") {
351                let bytes = fs::read(f.path())?;
352                out.push(serde_json::from_slice(&bytes)?);
353            }
354        }
355        Ok(out)
356    }
357
358    pub fn attach_spec(&self, sig: &str, spec: &Spec) -> Result<String, StoreError> {
359        if !self.sig_dir(sig).exists() {
360            return Err(StoreError::UnknownSig(sig.into()));
361        }
362        fs::create_dir_all(self.specs_dir(sig))?;
363        let path = self.specs_dir(sig).join(format!("{}.json", spec.id));
364        write_canonical_json(&path, spec)?;
365        Ok(spec.id.clone())
366    }
367
368    pub fn list_specs(&self, sig: &str) -> Result<Vec<Spec>, StoreError> {
369        let dir = self.specs_dir(sig);
370        if !dir.exists() { return Ok(Vec::new()); }
371        let mut out = Vec::new();
372        for f in fs::read_dir(dir)? {
373            let f = f?;
374            if f.path().extension().is_some_and(|e| e == "json") {
375                let bytes = fs::read(f.path())?;
376                out.push(serde_json::from_slice(&bytes)?);
377            }
378        }
379        Ok(out)
380    }
381
382    // ---- traces (§4.2 / M7) ----
383
384    fn trace_path(&self, run_id: &str) -> PathBuf {
385        self.root.join("traces").join(run_id).join("trace.json")
386    }
387
388    pub fn save_trace(&self, tree: &lex_trace::TraceTree) -> Result<String, StoreError> {
389        let path = self.trace_path(&tree.run_id);
390        write_canonical_json(&path, tree)?;
391        Ok(tree.run_id.clone())
392    }
393
394    pub fn load_trace(&self, run_id: &str) -> Result<lex_trace::TraceTree, StoreError> {
395        let bytes = fs::read(self.trace_path(run_id))?;
396        Ok(serde_json::from_slice(&bytes)?)
397    }
398
399    pub fn list_traces(&self) -> Result<Vec<String>, StoreError> {
400        let dir = self.root.join("traces");
401        if !dir.exists() { return Ok(Vec::new()); }
402        let mut out = Vec::new();
403        for entry in fs::read_dir(dir)? {
404            let entry = entry?;
405            if entry.file_type()?.is_dir() {
406                out.push(entry.file_name().to_string_lossy().to_string());
407            }
408        }
409        out.sort();
410        Ok(out)
411    }
412
413    // ---- internals ----
414
415    fn lookup_lifecycle(&self, stage_id: &str) -> Result<(String, Lifecycle), StoreError> {
416        // Walk every SigId, find which one contains this StageId.
417        for sig in self.list_sigs()? {
418            if let Ok(life) = self.read_lifecycle(&sig) {
419                if life.transitions.iter().any(|t| t.stage_id == stage_id) {
420                    return Ok((sig, life));
421                }
422            }
423        }
424        Err(StoreError::UnknownStage(stage_id.into()))
425    }
426
427    fn read_lifecycle(&self, sig: &str) -> Result<Lifecycle, StoreError> {
428        let path = self.lifecycle_path(sig);
429        if !path.exists() {
430            return Ok(Lifecycle { sig_id: sig.into(), transitions: Vec::new() });
431        }
432        let bytes = fs::read(&path)?;
433        Ok(serde_json::from_slice(&bytes)?)
434    }
435
436    fn write_lifecycle(&self, sig: &str, life: &Lifecycle) -> Result<(), StoreError> {
437        write_canonical_json(&self.lifecycle_path(sig), life)
438    }
439
440    /// Apply a published program to a branch as a sequence of typed
441    /// operations. Returns the ordered list of op_ids + the new
442    /// head_op. The caller (`lex publish` CLI, `lex serve`'s HTTP
443    /// handler) is responsible for computing the `DiffReport` against
444    /// the current branch head — the diff infrastructure lives in
445    /// `lex-vcs::compute_diff` (previously `lex-cli`) to keep this
446    /// layer from owning diffing logic.
447    ///
448    /// On success: every op in the returned list is durable in the
449    /// op log and the branch's head_op points at the last one.
450    /// On a no-op (no diff): returns empty `ops` and the existing
451    /// `head_op` unchanged.
452    pub fn publish_program(
453        &self,
454        branch: &str,
455        stages: &[lex_ast::Stage],
456        diff: &lex_vcs::DiffReport,
457        new_imports: &lex_vcs::ImportMap,
458        activate: bool,
459    ) -> Result<PublishOutcome, StoreError> {
460        use std::collections::{BTreeMap, BTreeSet};
461
462        // #130's write-time gate: verify the candidate program
463        // typechecks (and effects are correctly declared) before
464        // any disk side-effect. If anything fails, return the
465        // structured envelope and leave the branch head unchanged
466        // — the store's "always-valid HEAD" invariant only holds
467        // because this is the only batch-publish path that
468        // advances heads. Single-op writes via the lower-level
469        // `apply_operation` are not gated yet (#130 follow-up).
470        if let Err(errors) = lex_types::check_program(stages) {
471            return Err(StoreError::TypeError(errors));
472        }
473
474        // Build old-side views from the current branch.
475        let old_head = self.branch_head(branch)?;
476        let old_name_to_sig: BTreeMap<String, String> = old_head.iter()
477            .filter_map(|(sig, stg)| {
478                self.get_metadata(stg).ok().map(|m| (m.name, sig.clone()))
479            })
480            .collect();
481        let old_effects: BTreeMap<String, BTreeSet<String>> = old_head.iter()
482            .filter_map(|(sig, stg)| {
483                let ast = self.get_ast(stg).ok()?;
484                match ast {
485                    lex_ast::Stage::FnDecl(fd) => {
486                        let s: BTreeSet<String> = fd.effects.iter()
487                            .map(|e| e.name.clone()).collect();
488                        Some((sig.clone(), s))
489                    }
490                    _ => None,
491                }
492            })
493            .collect();
494        let old_imports = self.derive_imports_from_oplog(branch)?;
495
496        let op_kinds = lex_vcs::diff_to_ops(lex_vcs::DiffInputs {
497            old_head: &old_head,
498            old_name_to_sig: &old_name_to_sig,
499            old_effects: &old_effects,
500            old_imports: &old_imports,
501            new_stages: stages,
502            new_imports,
503            diff,
504        }).map_err(|e| StoreError::InvalidTransition(format!("diff_to_ops: {e}")))?;
505
506        let mut ops_out: Vec<PublishOp> = Vec::new();
507        let mut last_op_id: Option<lex_vcs::OpId> = None;
508        for kind in op_kinds {
509            // Persist the underlying stage AST/metadata if this op
510            // produces or replaces one.
511            if let Some(stg) = stage_for_kind(&kind, stages) {
512                if !matches!(stg, lex_ast::Stage::Import(_)) {
513                    self.publish(stg)?;
514                    if activate {
515                        if let Some(stage_id_str) = stage_id(stg) {
516                            let _ = self.activate(&stage_id_str);
517                        }
518                    }
519                }
520            }
521            let transition = transition_for_kind(&kind);
522            let attestable = attestable_stage_ids(&transition);
523            let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
524            let op = lex_vcs::Operation::new(
525                kind.clone(),
526                head_now.into_iter().collect::<Vec<_>>(),
527            );
528            let op_id = self.apply_operation(branch, op, transition)?;
529            self.record_typecheck_passed(&attestable, &op_id)?;
530            ops_out.push(PublishOp {
531                op_id: op_id.clone(),
532                kind: serde_json::to_value(&kind)
533                    .map_err(StoreError::Serde)?,
534            });
535            last_op_id = Some(op_id);
536        }
537
538        let head_op = match last_op_id {
539            Some(id) => Some(id),
540            // No ops applied; return whatever the head was already.
541            None => self.get_branch(branch)?.and_then(|b| b.head_op),
542        };
543
544        Ok(PublishOutcome {
545            ops: ops_out,
546            head_op,
547        })
548    }
549
550    pub fn derive_imports_from_oplog(
551        &self,
552        branch: &str,
553    ) -> Result<lex_vcs::ImportMap, StoreError> {
554        use lex_vcs::OperationKind::*;
555        let log = lex_vcs::OpLog::open(self.root())?;
556        let head = match self.get_branch(branch)?.and_then(|b| b.head_op) {
557            Some(h) => h,
558            None => return Ok(Default::default()),
559        };
560        let mut out: lex_vcs::ImportMap = Default::default();
561        for r in log.walk_forward(&head, None)? {
562            match r.op.kind {
563                AddImport { in_file, module } => {
564                    out.entry(in_file).or_default().insert(module);
565                }
566                RemoveImport { in_file, module } => {
567                    if let Some(set) = out.get_mut(&in_file) { set.remove(&module); }
568                }
569                _ => {}
570            }
571        }
572        Ok(out)
573    }
574
575    /// Apply an operation to a branch and advance its head_op.
576    ///
577    /// The single advance path. Validates parents via `lex_vcs::apply`,
578    /// persists the operation via the op log, then atomically advances
579    /// the branch file's head_op via `set_branch_head_op`.
580    ///
581    /// Errors:
582    /// - `UnknownBranch`: branch does not exist (no op is persisted).
583    /// - `Apply(ApplyError::StaleParent)`: the op's parents don't
584    ///   match the branch head — head is unchanged. Callers that
585    ///   want retry-on-stale (e.g. `lex publish` re-running against
586    ///   a moved head) match on this variant explicitly.
587    /// - `Apply(ApplyError::UnknownMergeParent)`: a merge op's
588    ///   second parent isn't in the log.
589    /// - `Io`: filesystem error during persist or branch advance.
590    ///
591    /// Crash recovery: between op persist and branch advance, a crash
592    /// can leave an orphan op record in the log with no branch
593    /// pointing at it. The op is content-addressed and cheap to
594    /// re-derive from the same source. See
595    /// Apply a single op against `branch`, gated on the candidate
596    /// program typechecking. The per-op variant of #130's
597    /// write-time gate — counterpart to [`Self::publish_program`]'s
598    /// batch-mode check.
599    ///
600    /// `candidate` is the sequence of `Stage`s that *would* exist
601    /// on this branch after the op is applied. Caller's
602    /// responsibility: today neither `lex-store` nor `lex-vcs`
603    /// reconstruct the candidate from the op + branch state on
604    /// behalf of the caller. The natural callers (HTTP `POST
605    /// /v1/publish` for a single op; agent harnesses driving
606    /// merges via the future #134 API) already have the candidate
607    /// in memory.
608    ///
609    /// On rejection: branch head unchanged, no op record persisted.
610    /// Same atomicity guarantee as the publish path.
611    ///
612    /// # Why a separate method, not a flag on `apply_operation`
613    ///
614    /// The merge engine in `lex-vcs::merge` calls
615    /// `Store::apply_operation` directly to land merge ops, and at
616    /// merge time the resolved program isn't a single `Vec<Stage>`
617    /// the way it is on the publish path — it's a per-sig
618    /// resolution map. Forcing a candidate through `apply_operation`
619    /// would either require the merge engine to assemble one (slow,
620    /// every active stage off disk) or accept `Option<&[Stage]>`
621    /// and silently skip the gate — the second is exactly the kind
622    /// of "secretly opt-out" path #130 is trying to remove. The
623    /// honest split is two methods: `apply_operation` for callers
624    /// that already typecheck their inputs (or don't need to —
625    /// rare, but the merge-resolve case), `apply_operation_checked`
626    /// for everyone else.
627    pub fn apply_operation_checked(
628        &self,
629        branch: &str,
630        op: lex_vcs::Operation,
631        transition: lex_vcs::StageTransition,
632        candidate: &[lex_ast::Stage],
633    ) -> Result<lex_vcs::OpId, StoreError> {
634        if let Err(errors) = lex_types::check_program(candidate) {
635            return Err(StoreError::TypeError(errors));
636        }
637        let attestable = attestable_stage_ids(&transition);
638        let op_id = self.apply_operation(branch, op, transition)?;
639        self.record_typecheck_passed(&attestable, &op_id)?;
640        Ok(op_id)
641    }
642
643    /// Open the attestation log rooted at this store. The log lives
644    /// under `<root>/attestations/`; opening is idempotent and cheap
645    /// (`fs::create_dir_all`). Exposed publicly so consumers — `lex
646    /// blame --with-evidence`, `GET /v1/stage/<id>/attestations` —
647    /// can read what the store gate emitted without round-tripping
648    /// through this crate's API surface.
649    pub fn attestation_log(&self) -> Result<lex_vcs::AttestationLog, StoreError> {
650        Ok(lex_vcs::AttestationLog::open(self.root())?)
651    }
652
653    /// Emit one `TypeCheck::Passed` attestation per stage produced by
654    /// a successful gated apply. Idempotent on `attestation_id` —
655    /// re-running the same gate run dedups via content addressing.
656    ///
657    /// Failure modes: `io::Error` from the attestation log (disk
658    /// full, perms). The op has already landed by the time this
659    /// runs; an error here means the op is durable but the evidence
660    /// is missing. We propagate so the caller sees the partial
661    /// state rather than silently swallowing — re-attesting the
662    /// same op against the same op_id is idempotent (content
663    /// addressing) so a retry is safe once the underlying issue is
664    /// fixed.
665    fn record_typecheck_passed(
666        &self,
667        stage_ids: &[String],
668        op_id: &lex_vcs::OpId,
669    ) -> Result<(), StoreError> {
670        if stage_ids.is_empty() {
671            return Ok(());
672        }
673        let log = self.attestation_log()?;
674        for stage_id in stage_ids {
675            let attestation = lex_vcs::Attestation::new(
676                stage_id.clone(),
677                Some(op_id.clone()),
678                None,
679                lex_vcs::AttestationKind::TypeCheck,
680                lex_vcs::AttestationResult::Passed,
681                typecheck_producer(),
682                None,
683            );
684            log.put(&attestation)?;
685        }
686        Ok(())
687    }
688
689    /// `set_branch_head_op` for the durability story on the branch
690    /// file itself.
691    pub fn apply_operation(
692        &self,
693        branch: &str,
694        op: lex_vcs::Operation,
695        transition: lex_vcs::StageTransition,
696    ) -> Result<lex_vcs::OpId, StoreError> {
697        // Pre-check: refuse to persist any op against a branch that
698        // doesn't exist. Without this, applying against a non-default
699        // ghost branch would write the op record (succeeding via
700        // lex_vcs::apply on a None head) and only fail at
701        // set_branch_head_op below — leaving an orphan op in the log
702        // with no branch pointing at it.
703        if branch != DEFAULT_BRANCH && self.get_branch(branch)?.is_none() {
704            return Err(StoreError::UnknownBranch(branch.into()));
705        }
706        let log = lex_vcs::OpLog::open(self.root())?;
707        let head_op = self.get_branch(branch)?.and_then(|b| b.head_op);
708        let new_head = lex_vcs::apply(&log, head_op.as_ref(), op, transition)
709            .map_err(|e| match e {
710                lex_vcs::ApplyError::Persist(io) => StoreError::Io(io),
711                other => StoreError::Apply(other),
712            })?;
713        self.set_branch_head_op(branch, new_head.op_id.clone())?;
714        Ok(new_head.op_id)
715    }
716}
717
718fn stage_name(stage: &Stage) -> &str {
719    match stage {
720        Stage::FnDecl(fd) => &fd.name,
721        Stage::TypeDecl(td) => &td.name,
722        Stage::Import(i) => &i.alias,
723    }
724}
725
726fn stage_for_kind<'a>(
727    kind: &lex_vcs::OperationKind,
728    stages: &'a [lex_ast::Stage],
729) -> Option<&'a lex_ast::Stage> {
730    use lex_vcs::OperationKind::*;
731    let target_sig = match kind {
732        AddFunction { sig_id, .. } | ModifyBody { sig_id, .. }
733        | ChangeEffectSig { sig_id, .. } | AddType { sig_id, .. }
734        | ModifyType { sig_id, .. } => Some(sig_id.clone()),
735        RenameSymbol { to, .. } => Some(to.clone()),
736        _ => None,
737    };
738    let target_sig = target_sig?;
739    stages.iter().find(|s| sig_id(s).as_deref() == Some(target_sig.as_str()))
740}
741
742fn transition_for_kind(kind: &lex_vcs::OperationKind) -> lex_vcs::StageTransition {
743    use lex_vcs::OperationKind::*;
744    use lex_vcs::StageTransition;
745    match kind {
746        AddFunction { sig_id, stage_id, .. }
747        | AddType { sig_id, stage_id } => StageTransition::Create {
748            sig_id: sig_id.clone(), stage_id: stage_id.clone(),
749        },
750        RemoveFunction { sig_id, last_stage_id }
751        | RemoveType { sig_id, last_stage_id } => StageTransition::Remove {
752            sig_id: sig_id.clone(), last: last_stage_id.clone(),
753        },
754        ModifyBody { sig_id, from_stage_id, to_stage_id }
755        | ChangeEffectSig { sig_id, from_stage_id, to_stage_id, .. }
756        | ModifyType { sig_id, from_stage_id, to_stage_id } => StageTransition::Replace {
757            sig_id: sig_id.clone(),
758            from: from_stage_id.clone(),
759            to:   to_stage_id.clone(),
760        },
761        RenameSymbol { from, to, body_stage_id } => StageTransition::Rename {
762            from: from.clone(), to: to.clone(),
763            body_stage_id: body_stage_id.clone(),
764        },
765        AddImport { .. } | RemoveImport { .. } => StageTransition::ImportOnly,
766        Merge { .. } => StageTransition::Merge { entries: Default::default() },
767    }
768}
769
770/// Producer identity for TypeCheck attestations emitted by the
771/// store-write gate. Pinned to this crate's name + version so an
772/// attestation produced by a different `lex-store` revision is
773/// distinguishable (content-hashed `produced_by`).
774fn typecheck_producer() -> lex_vcs::ProducerDescriptor {
775    lex_vcs::ProducerDescriptor {
776        tool: "lex-store".into(),
777        version: env!("CARGO_PKG_VERSION").into(),
778        model: None,
779    }
780}
781
782/// The set of stage_ids a transition introduces. These are the
783/// stages a successful TypeCheck pass attests *about* — the new
784/// head produced by Create/Replace, the renamed body, or the per-
785/// sig resolution of a Merge. Removes and ImportOnly produce no
786/// attestable stage; the program typechecks but no specific stage
787/// is the subject of the claim.
788fn attestable_stage_ids(transition: &lex_vcs::StageTransition) -> Vec<String> {
789    use lex_vcs::StageTransition::*;
790    match transition {
791        Create { stage_id, .. } => vec![stage_id.clone()],
792        Replace { to, .. } => vec![to.clone()],
793        Rename { body_stage_id, .. } => vec![body_stage_id.clone()],
794        Merge { entries } => entries
795            .values()
796            .filter_map(|opt| opt.clone())
797            .collect(),
798        Remove { .. } | ImportOnly => Vec::new(),
799    }
800}
801
802fn write_canonical_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
803    let v = serde_json::to_value(value)?;
804    let s = lex_ast::canon_json::to_canonical_string(&v);
805    if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
806    fs::write(path, s)?;
807    Ok(())
808}
809
810#[allow(dead_code)]
811fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T, StoreError> {
812    let bytes = fs::read(path)?;
813    Ok(serde_json::from_slice(&bytes)?)
814}