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    /// The op was persisted but a `required_attestations` rule in
44    /// `policy.json` (#245) refused to advance the branch head past
45    /// it. The op record is durable — re-running with the missing
46    /// attestations recorded will succeed without re-persisting —
47    /// but the branch is unchanged. Surfaced as a structured JSON
48    /// envelope on the HTTP API.
49    #[error(
50        "branch advance blocked: op {} missing attestations: {}",
51        .0.op_id, .0.missing.join(", ")
52    )]
53    BranchAdvanceBlocked(crate::policy::BranchAdvanceBlocked),
54    /// The op was persisted but its stage carries an attestation
55    /// produced by a retroactively quarantined tool (#248). The
56    /// branch head is unchanged. The op record stays in the log
57    /// (audit trail intact); re-running with the producer
58    /// unblocked, or with un-contaminated attestations, succeeds
59    /// without re-persisting the op.
60    #[error(
61        "branch advance blocked: op {} touches stage {} with an attestation from \
62         quarantined producer `{}` (blocked at {}, attestation at {})",
63        .0.op_id, .0.stage_id, .0.tool_id, .0.blocked_at, .0.attestation_at
64    )]
65    ProducerBlocked(crate::policy::ProducerBlocked),
66}
67
68/// The outcome returned by [`Store::publish_program`].
69#[derive(Debug, Clone, serde::Serialize)]
70pub struct PublishOutcome {
71    pub ops: Vec<PublishOp>,
72    pub head_op: Option<lex_vcs::OpId>,
73}
74
75/// One applied operation within a [`PublishOutcome`].
76#[derive(Debug, Clone, serde::Serialize)]
77pub struct PublishOp {
78    pub op_id: lex_vcs::OpId,
79    pub kind: serde_json::Value,
80}
81
82/// One entry in the per-`SigId` stage history surfaced by
83/// `Store::sig_history`. Newest-first ordering is the responsibility
84/// of the producer.
85#[derive(Debug, Clone, serde::Serialize, PartialEq)]
86pub struct StageHistoryEntry {
87    pub stage_id: String,
88    pub status: StageStatus,
89    /// Wall-clock seconds of the most recent transition.
90    pub last_at: u64,
91    /// Wall-clock seconds when this stage was first written to the
92    /// store (its initial Draft transition). `None` for stages
93    /// whose lifecycle log doesn't include an explicit Draft entry
94    /// — shouldn't happen for stages published via `Store::publish`,
95    /// but the type allows hand-edited stores.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub published_at: Option<u64>,
98}
99
100pub struct Store {
101    root: PathBuf,
102}
103
104impl Store {
105    /// Open or create a store rooted at `root`.
106    pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
107        let root = root.as_ref().to_path_buf();
108        fs::create_dir_all(root.join("stages"))?;
109        fs::create_dir_all(root.join("traces"))?;
110        Ok(Self { root })
111    }
112
113    pub fn root(&self) -> &Path { &self.root }
114
115    fn now() -> u64 {
116        SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
117    }
118
119    fn sig_dir(&self, sig: &str) -> PathBuf { self.root.join("stages").join(sig) }
120    fn impl_dir(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("implementations") }
121    fn tests_dir(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("tests") }
122    fn specs_dir(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("specs") }
123    fn lifecycle_path(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("lifecycle.json") }
124
125    // ---- publish ----
126
127    /// Publish a stage as **Draft**. Returns the StageId.
128    /// Idempotent: republishing the same canonical AST returns the same
129    /// StageId without writing duplicates.
130    pub fn publish(&self, stage: &Stage) -> Result<String, StoreError> {
131        self.publish_signed(stage, None)
132    }
133
134    /// Like [`Self::publish`] but optionally attaches an Ed25519
135    /// signature over the StageId (#227). When `signer` is `Some`,
136    /// the persisted metadata gets a `signature` field that
137    /// downstream consumers can verify via
138    /// [`lex_vcs::verify_stage_id`].
139    ///
140    /// Idempotency: if a metadata file already exists the signature
141    /// is *not* re-written. This preserves "republishing is a no-op"
142    /// even across different signers — promoting a signed stage
143    /// requires a fresh stage hash anyway, so a metadata overwrite
144    /// would be the wrong primitive.
145    pub fn publish_signed(
146        &self,
147        stage: &Stage,
148        signer: Option<&lex_vcs::Keypair>,
149    ) -> Result<String, StoreError> {
150        let sig = sig_id(stage).ok_or(StoreError::CannotPublishImport)?;
151        let stage_id = stage_id(stage).ok_or(StoreError::CannotPublishImport)?;
152        let name = stage_name(stage).to_string();
153
154        fs::create_dir_all(self.impl_dir(&sig))?;
155        fs::create_dir_all(self.tests_dir(&sig))?;
156        fs::create_dir_all(self.specs_dir(&sig))?;
157
158        let ast_path = self.impl_dir(&sig).join(format!("{}.ast.json", stage_id));
159        let meta_path = self.impl_dir(&sig).join(format!("{}.metadata.json", stage_id));
160
161        if !ast_path.exists() {
162            write_canonical_json(&ast_path, stage)?;
163        }
164        if !meta_path.exists() {
165            let signature = signer.map(|kp| kp.sign_stage_id(&stage_id));
166            let metadata = Metadata {
167                stage_id: stage_id.clone(),
168                sig_id: sig.clone(),
169                name,
170                published_at: Self::now(),
171                note: None,
172                signature,
173            };
174            write_canonical_json(&meta_path, &metadata)?;
175        }
176
177        // Lifecycle: append a Draft transition for first publish.
178        let mut life = self.read_lifecycle(&sig).unwrap_or_else(|_| Lifecycle {
179            sig_id: sig.clone(),
180            ..Default::default()
181        });
182        if !life.transitions.iter().any(|t| t.stage_id == stage_id) {
183            life.transitions.push(Transition {
184                stage_id: stage_id.clone(),
185                from: StageStatus::Draft, // synthesized; "from" of first transition is itself
186                to: StageStatus::Draft,
187                at: Self::now(),
188                reason: None,
189            });
190            self.write_lifecycle(&sig, &life)?;
191        }
192        Ok(stage_id)
193    }
194
195    // ---- lifecycle ----
196
197    pub fn activate(&self, stage_id: &str) -> Result<(), StoreError> {
198        let (sig, mut life) = self.lookup_lifecycle(stage_id)?;
199        // Demote any currently-Active impls for this SigId to Deprecated.
200        let active = life.current_active().map(|s| s.to_string());
201        if let Some(prev) = active {
202            if prev != stage_id {
203                life.transitions.push(Transition {
204                    stage_id: prev,
205                    from: StageStatus::Active,
206                    to: StageStatus::Deprecated,
207                    at: Self::now(),
208                    reason: Some("superseded".into()),
209                });
210            }
211        }
212        let cur = life.status_of(stage_id);
213        if cur == Some(StageStatus::Tombstone) {
214            return Err(StoreError::InvalidTransition("tombstoned cannot be activated".into()));
215        }
216        life.transitions.push(Transition {
217            stage_id: stage_id.into(),
218            from: cur.unwrap_or(StageStatus::Draft),
219            to: StageStatus::Active,
220            at: Self::now(),
221            reason: None,
222        });
223        self.write_lifecycle(&sig, &life)
224    }
225
226    pub fn deprecate(&self, stage_id: &str, reason: impl Into<String>) -> Result<(), StoreError> {
227        let (sig, mut life) = self.lookup_lifecycle(stage_id)?;
228        let cur = life.status_of(stage_id).ok_or_else(|| StoreError::UnknownStage(stage_id.into()))?;
229        if cur != StageStatus::Active {
230            return Err(StoreError::InvalidTransition(format!("{cur:?} ⇒ Deprecated")));
231        }
232        life.transitions.push(Transition {
233            stage_id: stage_id.into(),
234            from: cur,
235            to: StageStatus::Deprecated,
236            at: Self::now(),
237            reason: Some(reason.into()),
238        });
239        self.write_lifecycle(&sig, &life)
240    }
241
242    pub fn tombstone(&self, stage_id: &str) -> Result<(), StoreError> {
243        let (sig, mut life) = self.lookup_lifecycle(stage_id)?;
244        let cur = life.status_of(stage_id).ok_or_else(|| StoreError::UnknownStage(stage_id.into()))?;
245        if cur != StageStatus::Deprecated {
246            return Err(StoreError::InvalidTransition(format!("{cur:?} ⇒ Tombstone")));
247        }
248        life.transitions.push(Transition {
249            stage_id: stage_id.into(),
250            from: cur,
251            to: StageStatus::Tombstone,
252            at: Self::now(),
253            reason: None,
254        });
255        self.write_lifecycle(&sig, &life)
256    }
257
258    // ---- queries ----
259
260    /// The current Active StageId for a signature, or `None`.
261    pub fn resolve_sig(&self, sig: &str) -> Result<Option<String>, StoreError> {
262        let life = match self.read_lifecycle(sig) {
263            Ok(l) => l,
264            Err(_) => return Ok(None),
265        };
266        Ok(life.current_active().map(|s| s.to_string()))
267    }
268
269    /// Per-stage history for a SigId, ordered chronologically by
270    /// the *last* transition timestamp. Returns one entry per
271    /// distinct StageId that has ever been published under `sig`.
272    /// `Ok(vec![])` if the SigId doesn't exist in the store.
273    ///
274    /// Used by `lex blame` to render "where does this fn come from".
275    pub fn sig_history(&self, sig: &str) -> Result<Vec<StageHistoryEntry>, StoreError> {
276        let life = match self.read_lifecycle(sig) {
277            Ok(l) => l,
278            Err(_) => return Ok(Vec::new()),
279        };
280        // Collapse transitions: latest status + last_at per stage,
281        // plus the timestamp of the first Draft transition (≈ when
282        // the stage was published) when one exists.
283        let mut by_stage: indexmap::IndexMap<String, StageHistoryEntry> =
284            indexmap::IndexMap::new();
285        for t in &life.transitions {
286            let entry = by_stage.entry(t.stage_id.clone()).or_insert(StageHistoryEntry {
287                stage_id: t.stage_id.clone(),
288                status: t.to,
289                last_at: t.at,
290                published_at: None,
291            });
292            entry.status = t.to;
293            entry.last_at = t.at;
294            if t.from == StageStatus::Draft && entry.published_at.is_none() {
295                entry.published_at = Some(t.at);
296            }
297            if t.to == StageStatus::Draft && entry.published_at.is_none() {
298                // Initial publication: Draft is the *destination*.
299                entry.published_at = Some(t.at);
300            }
301        }
302        let mut out: Vec<StageHistoryEntry> = by_stage.into_values().collect();
303        // Sort newest first so `lex blame` shows recent activity at top.
304        out.sort_by_key(|e| std::cmp::Reverse(e.last_at));
305        Ok(out)
306    }
307
308    pub fn get_ast(&self, stage_id: &str) -> Result<Stage, StoreError> {
309        let (sig, _) = self.lookup_lifecycle(stage_id)?;
310        let path = self.impl_dir(&sig).join(format!("{}.ast.json", stage_id));
311        let bytes = fs::read(&path)?;
312        Ok(serde_json::from_slice(&bytes)?)
313    }
314
315    pub fn get_metadata(&self, stage_id: &str) -> Result<Metadata, StoreError> {
316        let (sig, _) = self.lookup_lifecycle(stage_id)?;
317        let path = self.impl_dir(&sig).join(format!("{}.metadata.json", stage_id));
318        let bytes = fs::read(&path)?;
319        Ok(serde_json::from_slice(&bytes)?)
320    }
321
322    pub fn get_status(&self, stage_id: &str) -> Result<StageStatus, StoreError> {
323        let (_sig, life) = self.lookup_lifecycle(stage_id)?;
324        life.status_of(stage_id).ok_or_else(|| StoreError::UnknownStage(stage_id.into()))
325    }
326
327    pub fn list_stages_by_name(&self, name: &str) -> Result<Vec<String>, StoreError> {
328        // Walk every SigId → check metadata of any implementation; if its
329        // name matches, include the SigId.
330        let mut out = Vec::new();
331        let stages_dir = self.root.join("stages");
332        if !stages_dir.exists() { return Ok(out); }
333        for entry in fs::read_dir(&stages_dir)? {
334            let entry = entry?;
335            let sig_dir = entry.path();
336            if !sig_dir.is_dir() { continue; }
337            let sig = entry.file_name().to_string_lossy().to_string();
338            // Look at any one metadata file under this SigId.
339            let impls = self.impl_dir(&sig);
340            if !impls.exists() { continue; }
341            for f in fs::read_dir(impls)? {
342                let f = f?;
343                let p = f.path();
344                if p.extension().is_some_and(|e| e == "json")
345                    && p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(".metadata.json"))
346                {
347                    if let Ok(bytes) = fs::read(&p) {
348                        if let Ok(m) = serde_json::from_slice::<Metadata>(&bytes) {
349                            if m.name == name {
350                                if !out.contains(&sig) { out.push(sig.clone()); }
351                                break;
352                            }
353                        }
354                    }
355                }
356            }
357        }
358        out.sort();
359        Ok(out)
360    }
361
362    pub fn list_sigs(&self) -> Result<Vec<String>, StoreError> {
363        let stages_dir = self.root.join("stages");
364        let mut out = Vec::new();
365        if !stages_dir.exists() { return Ok(out); }
366        for entry in fs::read_dir(stages_dir)? {
367            let entry = entry?;
368            if entry.file_type()?.is_dir() {
369                out.push(entry.file_name().to_string_lossy().to_string());
370            }
371        }
372        out.sort();
373        Ok(out)
374    }
375
376    // ---- tests/specs as metadata (§4.4) ----
377
378    pub fn attach_test(&self, sig: &str, test: &Test) -> Result<String, StoreError> {
379        if !self.sig_dir(sig).exists() {
380            return Err(StoreError::UnknownSig(sig.into()));
381        }
382        fs::create_dir_all(self.tests_dir(sig))?;
383        let path = self.tests_dir(sig).join(format!("{}.json", test.id));
384        write_canonical_json(&path, test)?;
385        Ok(test.id.clone())
386    }
387
388    pub fn list_tests(&self, sig: &str) -> Result<Vec<Test>, StoreError> {
389        let dir = self.tests_dir(sig);
390        if !dir.exists() { return Ok(Vec::new()); }
391        let mut out = Vec::new();
392        for f in fs::read_dir(dir)? {
393            let f = f?;
394            if f.path().extension().is_some_and(|e| e == "json") {
395                let bytes = fs::read(f.path())?;
396                out.push(serde_json::from_slice(&bytes)?);
397            }
398        }
399        Ok(out)
400    }
401
402    pub fn attach_spec(&self, sig: &str, spec: &Spec) -> Result<String, StoreError> {
403        if !self.sig_dir(sig).exists() {
404            return Err(StoreError::UnknownSig(sig.into()));
405        }
406        fs::create_dir_all(self.specs_dir(sig))?;
407        let path = self.specs_dir(sig).join(format!("{}.json", spec.id));
408        write_canonical_json(&path, spec)?;
409        Ok(spec.id.clone())
410    }
411
412    pub fn list_specs(&self, sig: &str) -> Result<Vec<Spec>, StoreError> {
413        let dir = self.specs_dir(sig);
414        if !dir.exists() { return Ok(Vec::new()); }
415        let mut out = Vec::new();
416        for f in fs::read_dir(dir)? {
417            let f = f?;
418            if f.path().extension().is_some_and(|e| e == "json") {
419                let bytes = fs::read(f.path())?;
420                out.push(serde_json::from_slice(&bytes)?);
421            }
422        }
423        Ok(out)
424    }
425
426    // ---- traces (§4.2 / M7) ----
427
428    fn trace_path(&self, run_id: &str) -> PathBuf {
429        self.root.join("traces").join(run_id).join("trace.json")
430    }
431
432    pub fn save_trace(&self, tree: &lex_trace::TraceTree) -> Result<String, StoreError> {
433        let path = self.trace_path(&tree.run_id);
434        write_canonical_json(&path, tree)?;
435        Ok(tree.run_id.clone())
436    }
437
438    pub fn load_trace(&self, run_id: &str) -> Result<lex_trace::TraceTree, StoreError> {
439        let bytes = fs::read(self.trace_path(run_id))?;
440        Ok(serde_json::from_slice(&bytes)?)
441    }
442
443    pub fn list_traces(&self) -> Result<Vec<String>, StoreError> {
444        let dir = self.root.join("traces");
445        if !dir.exists() { return Ok(Vec::new()); }
446        let mut out = Vec::new();
447        for entry in fs::read_dir(dir)? {
448            let entry = entry?;
449            if entry.file_type()?.is_dir() {
450                out.push(entry.file_name().to_string_lossy().to_string());
451            }
452        }
453        out.sort();
454        Ok(out)
455    }
456
457    // ---- internals ----
458
459    fn lookup_lifecycle(&self, stage_id: &str) -> Result<(String, Lifecycle), StoreError> {
460        // Walk every SigId, find which one contains this StageId.
461        for sig in self.list_sigs()? {
462            if let Ok(life) = self.read_lifecycle(&sig) {
463                if life.transitions.iter().any(|t| t.stage_id == stage_id) {
464                    return Ok((sig, life));
465                }
466            }
467        }
468        Err(StoreError::UnknownStage(stage_id.into()))
469    }
470
471    fn read_lifecycle(&self, sig: &str) -> Result<Lifecycle, StoreError> {
472        let path = self.lifecycle_path(sig);
473        if !path.exists() {
474            return Ok(Lifecycle { sig_id: sig.into(), transitions: Vec::new() });
475        }
476        let bytes = fs::read(&path)?;
477        Ok(serde_json::from_slice(&bytes)?)
478    }
479
480    fn write_lifecycle(&self, sig: &str, life: &Lifecycle) -> Result<(), StoreError> {
481        write_canonical_json(&self.lifecycle_path(sig), life)
482    }
483
484    /// Apply a published program to a branch as a sequence of typed
485    /// operations. Returns the ordered list of op_ids + the new
486    /// head_op. The caller (`lex publish` CLI, `lex serve`'s HTTP
487    /// handler) is responsible for computing the `DiffReport` against
488    /// the current branch head — the diff infrastructure lives in
489    /// `lex-vcs::compute_diff` (previously `lex-cli`) to keep this
490    /// layer from owning diffing logic.
491    ///
492    /// On success: every op in the returned list is durable in the
493    /// op log and the branch's head_op points at the last one.
494    /// On a no-op (no diff): returns empty `ops` and the existing
495    /// `head_op` unchanged.
496    pub fn publish_program(
497        &self,
498        branch: &str,
499        stages: &[lex_ast::Stage],
500        diff: &lex_vcs::DiffReport,
501        new_imports: &lex_vcs::ImportMap,
502        activate: bool,
503    ) -> Result<PublishOutcome, StoreError> {
504        self.publish_program_signed(branch, stages, diff, new_imports, activate, None)
505    }
506
507    /// Signed variant of [`Self::publish_program`] (#227). Every
508    /// stage written under this batch gets the same signer; per-stage
509    /// keys aren't supported because the agent identity model treats
510    /// a publish as a single authorial act.
511    pub fn publish_program_signed(
512        &self,
513        branch: &str,
514        stages: &[lex_ast::Stage],
515        diff: &lex_vcs::DiffReport,
516        new_imports: &lex_vcs::ImportMap,
517        activate: bool,
518        signer: Option<&lex_vcs::Keypair>,
519    ) -> Result<PublishOutcome, StoreError> {
520        use std::collections::{BTreeMap, BTreeSet};
521
522        // #130's write-time gate: verify the candidate program
523        // typechecks (and effects are correctly declared) before
524        // any disk side-effect. If anything fails, return the
525        // structured envelope and leave the branch head unchanged
526        // — the store's "always-valid HEAD" invariant only holds
527        // because this is the only batch-publish path that
528        // advances heads. Single-op writes via the lower-level
529        // `apply_operation` are not gated yet (#130 follow-up).
530        if let Err(errors) = lex_types::check_program(stages) {
531            return Err(StoreError::TypeError(errors));
532        }
533
534        // Build old-side views from the current branch.
535        let old_head = self.branch_head(branch)?;
536        let old_name_to_sig: BTreeMap<String, String> = old_head.iter()
537            .filter_map(|(sig, stg)| {
538                self.get_metadata(stg).ok().map(|m| (m.name, sig.clone()))
539            })
540            .collect();
541        let old_effects: BTreeMap<String, BTreeSet<String>> = old_head.iter()
542            .filter_map(|(sig, stg)| {
543                let ast = self.get_ast(stg).ok()?;
544                match ast {
545                    lex_ast::Stage::FnDecl(fd) => {
546                        let s: BTreeSet<String> = fd.effects.iter()
547                            .map(|e| e.name.clone()).collect();
548                        Some((sig.clone(), s))
549                    }
550                    _ => None,
551                }
552            })
553            .collect();
554        let old_imports = self.derive_imports_from_oplog(branch)?;
555
556        let op_kinds = lex_vcs::diff_to_ops(lex_vcs::DiffInputs {
557            old_head: &old_head,
558            old_name_to_sig: &old_name_to_sig,
559            old_effects: &old_effects,
560            old_imports: &old_imports,
561            new_stages: stages,
562            new_imports,
563            diff,
564        }).map_err(|e| StoreError::InvalidTransition(format!("diff_to_ops: {e}")))?;
565
566        let mut ops_out: Vec<PublishOp> = Vec::new();
567        let mut last_op_id: Option<lex_vcs::OpId> = None;
568        for kind in op_kinds {
569            // Persist the underlying stage AST/metadata if this op
570            // produces or replaces one.
571            if let Some(stg) = stage_for_kind(&kind, stages) {
572                if !matches!(stg, lex_ast::Stage::Import(_)) {
573                    self.publish_signed(stg, signer)?;
574                    if activate {
575                        if let Some(stage_id_str) = stage_id(stg) {
576                            let _ = self.activate(&stage_id_str);
577                        }
578                    }
579                }
580            }
581            let transition = transition_for_kind(&kind);
582            let attestable = attestable_stage_ids(&transition);
583            let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
584            let op = lex_vcs::Operation::new(
585                kind.clone(),
586                head_now.into_iter().collect::<Vec<_>>(),
587            );
588            let op_id = self.apply_operation(branch, op, transition)?;
589            self.record_typecheck_passed(&attestable, &op_id)?;
590            ops_out.push(PublishOp {
591                op_id: op_id.clone(),
592                kind: serde_json::to_value(&kind)
593                    .map_err(StoreError::Serde)?,
594            });
595            last_op_id = Some(op_id);
596        }
597
598        let head_op = match last_op_id {
599            Some(id) => Some(id),
600            // No ops applied; return whatever the head was already.
601            None => self.get_branch(branch)?.and_then(|b| b.head_op),
602        };
603
604        Ok(PublishOutcome {
605            ops: ops_out,
606            head_op,
607        })
608    }
609
610    pub fn derive_imports_from_oplog(
611        &self,
612        branch: &str,
613    ) -> Result<lex_vcs::ImportMap, StoreError> {
614        use lex_vcs::OperationKind::*;
615        let log = lex_vcs::OpLog::open(self.root())?;
616        let head = match self.get_branch(branch)?.and_then(|b| b.head_op) {
617            Some(h) => h,
618            None => return Ok(Default::default()),
619        };
620        let mut out: lex_vcs::ImportMap = Default::default();
621        for r in log.walk_forward(&head, None)? {
622            match r.op.kind {
623                AddImport { in_file, module } => {
624                    out.entry(in_file).or_default().insert(module);
625                }
626                RemoveImport { in_file, module } => {
627                    if let Some(set) = out.get_mut(&in_file) { set.remove(&module); }
628                }
629                _ => {}
630            }
631        }
632        Ok(out)
633    }
634
635    /// Apply an operation to a branch and advance its head_op.
636    ///
637    /// The single advance path. Validates parents via `lex_vcs::apply`,
638    /// persists the operation via the op log, then atomically advances
639    /// the branch file's head_op via `set_branch_head_op`.
640    ///
641    /// Errors:
642    /// - `UnknownBranch`: branch does not exist (no op is persisted).
643    /// - `Apply(ApplyError::StaleParent)`: the op's parents don't
644    ///   match the branch head — head is unchanged. Callers that
645    ///   want retry-on-stale (e.g. `lex publish` re-running against
646    ///   a moved head) match on this variant explicitly.
647    /// - `Apply(ApplyError::UnknownMergeParent)`: a merge op's
648    ///   second parent isn't in the log.
649    /// - `Io`: filesystem error during persist or branch advance.
650    ///
651    /// Crash recovery: between op persist and branch advance, a crash
652    /// can leave an orphan op record in the log with no branch
653    /// pointing at it. The op is content-addressed and cheap to
654    /// re-derive from the same source. See
655    /// Apply a single op against `branch`, gated on the candidate
656    /// program typechecking. The per-op variant of #130's
657    /// write-time gate — counterpart to [`Self::publish_program`]'s
658    /// batch-mode check.
659    ///
660    /// `candidate` is the sequence of `Stage`s that *would* exist
661    /// on this branch after the op is applied. Caller's
662    /// responsibility: today neither `lex-store` nor `lex-vcs`
663    /// reconstruct the candidate from the op + branch state on
664    /// behalf of the caller. The natural callers (HTTP `POST
665    /// /v1/publish` for a single op; agent harnesses driving
666    /// merges via the future #134 API) already have the candidate
667    /// in memory.
668    ///
669    /// On rejection: branch head unchanged, no op record persisted.
670    /// Same atomicity guarantee as the publish path.
671    ///
672    /// # Why a separate method, not a flag on `apply_operation`
673    ///
674    /// The merge engine in `lex-vcs::merge` calls
675    /// `Store::apply_operation` directly to land merge ops, and at
676    /// merge time the resolved program isn't a single `Vec<Stage>`
677    /// the way it is on the publish path — it's a per-sig
678    /// resolution map. Forcing a candidate through `apply_operation`
679    /// would either require the merge engine to assemble one (slow,
680    /// every active stage off disk) or accept `Option<&[Stage]>`
681    /// and silently skip the gate — the second is exactly the kind
682    /// of "secretly opt-out" path #130 is trying to remove. The
683    /// honest split is two methods: `apply_operation` for callers
684    /// that already typecheck their inputs (or don't need to —
685    /// rare, but the merge-resolve case), `apply_operation_checked`
686    /// for everyone else.
687    pub fn apply_operation_checked(
688        &self,
689        branch: &str,
690        op: lex_vcs::Operation,
691        transition: lex_vcs::StageTransition,
692        candidate: &[lex_ast::Stage],
693    ) -> Result<lex_vcs::OpId, StoreError> {
694        if let Err(errors) = lex_types::check_program(candidate) {
695            return Err(StoreError::TypeError(errors));
696        }
697        // Persist the op without advancing the branch head — we want
698        // the TypeCheck attestation visible *before* the gate runs,
699        // so policies that require TypeCheck pass for newly-typed
700        // ops. Then gate, then advance.
701        let attestable = attestable_stage_ids(&transition);
702        let op_effects = op_declared_effects(&op.kind);
703        let new_head = self.persist_op_only(branch, op, transition)?;
704        self.record_typecheck_passed(&attestable, &new_head.op_id)?;
705        self.run_required_attestations_gate(&new_head.op_id, &attestable, &op_effects)?;
706        self.set_branch_head_op(branch, new_head.op_id.clone())?;
707        Ok(new_head.op_id)
708    }
709
710    /// Open the attestation log rooted at this store. The log lives
711    /// under `<root>/attestations/`; opening is idempotent and cheap
712    /// (`fs::create_dir_all`). Exposed publicly so consumers — `lex
713    /// blame --with-evidence`, `GET /v1/stage/<id>/attestations` —
714    /// can read what the store gate emitted without round-tripping
715    /// through this crate's API surface.
716    pub fn attestation_log(&self) -> Result<lex_vcs::AttestationLog, StoreError> {
717        Ok(lex_vcs::AttestationLog::open(self.root())?)
718    }
719
720    /// Emit one `TypeCheck::Passed` attestation per stage produced by
721    /// a successful gated apply. Idempotent on `attestation_id` —
722    /// re-running the same gate run dedups via content addressing.
723    ///
724    /// Failure modes: `io::Error` from the attestation log (disk
725    /// full, perms). The op has already landed by the time this
726    /// runs; an error here means the op is durable but the evidence
727    /// is missing. We propagate so the caller sees the partial
728    /// state rather than silently swallowing — re-attesting the
729    /// same op against the same op_id is idempotent (content
730    /// addressing) so a retry is safe once the underlying issue is
731    /// fixed.
732    fn record_typecheck_passed(
733        &self,
734        stage_ids: &[String],
735        op_id: &lex_vcs::OpId,
736    ) -> Result<(), StoreError> {
737        if stage_ids.is_empty() {
738            return Ok(());
739        }
740        let log = self.attestation_log()?;
741        for stage_id in stage_ids {
742            let attestation = lex_vcs::Attestation::new(
743                stage_id.clone(),
744                Some(op_id.clone()),
745                None,
746                lex_vcs::AttestationKind::TypeCheck,
747                lex_vcs::AttestationResult::Passed,
748                typecheck_producer(),
749                None,
750            );
751            log.put(&attestation)?;
752        }
753        Ok(())
754    }
755
756    /// `set_branch_head_op` for the durability story on the branch
757    /// file itself.
758    pub fn apply_operation(
759        &self,
760        branch: &str,
761        op: lex_vcs::Operation,
762        transition: lex_vcs::StageTransition,
763    ) -> Result<lex_vcs::OpId, StoreError> {
764        let attestable = attestable_stage_ids(&transition);
765        let op_effects = op_declared_effects(&op.kind);
766        let new_head = self.persist_op_only(branch, op, transition)?;
767        // The unchecked path doesn't auto-emit TypeCheck, so a
768        // policy requiring TypeCheck (or any other attestation) on
769        // ops with attestable stages will refuse to advance the
770        // branch unless the caller emitted the attestation
771        // separately first. This is the intended behavior — the
772        // unchecked path opts out of the safety net the gate
773        // depends on.
774        self.run_required_attestations_gate(&new_head.op_id, &attestable, &op_effects)?;
775        self.set_branch_head_op(branch, new_head.op_id.clone())?;
776        Ok(new_head.op_id)
777    }
778
779    /// Persist an op via [`lex_vcs::apply`] but do NOT advance the
780    /// branch head. Internal to the apply pipeline so
781    /// `apply_operation` and `apply_operation_checked` share the
782    /// pre-check + persist sequence without duplicating the
783    /// validation code. The TypeCheck attestation and the
784    /// `required_attestations` gate slot in between this and the
785    /// final `set_branch_head_op` call.
786    fn persist_op_only(
787        &self,
788        branch: &str,
789        op: lex_vcs::Operation,
790        transition: lex_vcs::StageTransition,
791    ) -> Result<lex_vcs::NewHead, StoreError> {
792        // Pre-check: refuse to persist any op against a branch that
793        // doesn't exist. Without this, applying against a non-default
794        // ghost branch would write the op record (succeeding via
795        // lex_vcs::apply on a None head) and only fail at
796        // set_branch_head_op below — leaving an orphan op in the log
797        // with no branch pointing at it.
798        if branch != DEFAULT_BRANCH && self.get_branch(branch)?.is_none() {
799            return Err(StoreError::UnknownBranch(branch.into()));
800        }
801        let log = lex_vcs::OpLog::open(self.root())?;
802        let head_op = self.get_branch(branch)?.and_then(|b| b.head_op);
803        lex_vcs::apply(&log, head_op.as_ref(), op, transition).map_err(|e| match e {
804            lex_vcs::ApplyError::Persist(io) => StoreError::Io(io),
805            other => StoreError::Apply(other),
806        })
807    }
808
809    /// Run the `required_attestations` gate (#245) and the
810    /// retroactive producer-block gate (#248) over a single op
811    /// against the store's `policy.json` and attestation log.
812    ///
813    /// Failure modes (in order):
814    ///
815    /// 1. Producer-block first: if any attestation on the op's
816    ///    stage is from a quarantined tool, refuse with
817    ///    `ProducerBlocked` (#248). Surfaces *before* the
818    ///    required-attestations gate so a clearly-malicious record
819    ///    isn't masked by a missing-Spec error.
820    /// 2. Required-attestations next: if any required attestation
821    ///    kind is missing, refuse with `BranchAdvanceBlocked`
822    ///    (#245).
823    ///
824    /// Loads the policy / attestation log lazily; with no policy
825    /// file and no `ProducerBlock` attestations the gate is a no-op
826    /// (default-permissive — matches pre-#245 stores).
827    fn run_required_attestations_gate(
828        &self,
829        op_id: &lex_vcs::OpId,
830        stage_ids: &[String],
831        op_effects: &std::collections::BTreeSet<String>,
832    ) -> Result<(), StoreError> {
833        // Build the candidate slice once and reuse it for both
834        // gates. Ops with no attestable stage (imports, empty
835        // merges) get a single `None`-stage tuple; both gates skip
836        // those — there's nothing to attest.
837        let candidate: Vec<(lex_vcs::OpId, Option<String>, std::collections::BTreeSet<String>)> =
838            if stage_ids.is_empty() {
839                vec![(op_id.clone(), None, op_effects.clone())]
840            } else {
841                stage_ids
842                    .iter()
843                    .map(|sid| (op_id.clone(), Some(sid.clone()), op_effects.clone()))
844                    .collect()
845            };
846        let attest_log = self.attestation_log()?;
847
848        // #248: producer-block gate. Always evaluated regardless of
849        // policy.json contents — `ProducerBlock` attestations live
850        // in the attestation log, not policy.json.
851        crate::policy::check_producer_block(&attest_log, &candidate)
852            .map_err(StoreError::ProducerBlocked)?;
853
854        // #245: required-attestations gate. Only fires when the
855        // policy declares rules.
856        let policy = match crate::policy::load(self.root())? {
857            Some(p) if !p.required_attestations.is_empty() => p,
858            _ => return Ok(()),
859        };
860        crate::policy::check_required_attestations(&attest_log, &candidate, &policy)
861            .map_err(StoreError::BranchAdvanceBlocked)
862    }
863}
864
865fn stage_name(stage: &Stage) -> &str {
866    match stage {
867        Stage::FnDecl(fd) => &fd.name,
868        Stage::TypeDecl(td) => &td.name,
869        Stage::Import(i) => &i.alias,
870    }
871}
872
873fn stage_for_kind<'a>(
874    kind: &lex_vcs::OperationKind,
875    stages: &'a [lex_ast::Stage],
876) -> Option<&'a lex_ast::Stage> {
877    use lex_vcs::OperationKind::*;
878    let target_sig = match kind {
879        AddFunction { sig_id, .. } | ModifyBody { sig_id, .. }
880        | ChangeEffectSig { sig_id, .. } | AddType { sig_id, .. }
881        | ModifyType { sig_id, .. } => Some(sig_id.clone()),
882        RenameSymbol { to, .. } => Some(to.clone()),
883        _ => None,
884    };
885    let target_sig = target_sig?;
886    stages.iter().find(|s| sig_id(s).as_deref() == Some(target_sig.as_str()))
887}
888
889fn transition_for_kind(kind: &lex_vcs::OperationKind) -> lex_vcs::StageTransition {
890    use lex_vcs::OperationKind::*;
891    use lex_vcs::StageTransition;
892    match kind {
893        AddFunction { sig_id, stage_id, .. }
894        | AddType { sig_id, stage_id } => StageTransition::Create {
895            sig_id: sig_id.clone(), stage_id: stage_id.clone(),
896        },
897        RemoveFunction { sig_id, last_stage_id }
898        | RemoveType { sig_id, last_stage_id } => StageTransition::Remove {
899            sig_id: sig_id.clone(), last: last_stage_id.clone(),
900        },
901        ModifyBody { sig_id, from_stage_id, to_stage_id, .. }
902        | ChangeEffectSig { sig_id, from_stage_id, to_stage_id, .. }
903        | ModifyType { sig_id, from_stage_id, to_stage_id } => StageTransition::Replace {
904            sig_id: sig_id.clone(),
905            from: from_stage_id.clone(),
906            to:   to_stage_id.clone(),
907        },
908        RenameSymbol { from, to, body_stage_id } => StageTransition::Rename {
909            from: from.clone(), to: to.clone(),
910            body_stage_id: body_stage_id.clone(),
911        },
912        AddImport { .. } | RemoveImport { .. } => StageTransition::ImportOnly,
913        Merge { .. } => StageTransition::Merge { entries: Default::default() },
914    }
915}
916
917/// Producer identity for TypeCheck attestations emitted by the
918/// store-write gate. Pinned to this crate's name + version so an
919/// attestation produced by a different `lex-store` revision is
920/// distinguishable (content-hashed `produced_by`).
921fn typecheck_producer() -> lex_vcs::ProducerDescriptor {
922    lex_vcs::ProducerDescriptor {
923        tool: "lex-store".into(),
924        version: env!("CARGO_PKG_VERSION").into(),
925        model: None,
926    }
927}
928
929/// The set of stage_ids a transition introduces. These are the
930/// stages a successful TypeCheck pass attests *about* — the new
931/// head produced by Create/Replace, the renamed body, or the per-
932/// sig resolution of a Merge. Removes and ImportOnly produce no
933/// attestable stage; the program typechecks but no specific stage
934/// is the subject of the claim.
935/// Effect set declared *by the operation itself* (#245). Used by
936/// the `required_attestations` gate's `EffectsIntersect` clause.
937///
938/// Only `AddFunction` and `ChangeEffectSig` carry an effect set in
939/// their op payload; for everything else this returns the empty
940/// set, which means `EffectsIntersect` rules don't fire on those
941/// ops. `Always` rules continue to fire regardless. A future
942/// improvement is to extract effects from the candidate `Stage`
943/// for `ModifyBody` ops, but the typed-effects-on-ops path (#247)
944/// is the cleaner solution and lands separately.
945fn op_declared_effects(kind: &lex_vcs::OperationKind) -> std::collections::BTreeSet<String> {
946    use lex_vcs::OperationKind::*;
947    match kind {
948        AddFunction { effects, .. } => effects.clone(),
949        ChangeEffectSig { to_effects, .. } => to_effects.clone(),
950        _ => std::collections::BTreeSet::new(),
951    }
952}
953
954fn attestable_stage_ids(transition: &lex_vcs::StageTransition) -> Vec<String> {
955    use lex_vcs::StageTransition::*;
956    match transition {
957        Create { stage_id, .. } => vec![stage_id.clone()],
958        Replace { to, .. } => vec![to.clone()],
959        Rename { body_stage_id, .. } => vec![body_stage_id.clone()],
960        Merge { entries } => entries
961            .values()
962            .filter_map(|opt| opt.clone())
963            .collect(),
964        Remove { .. } | ImportOnly => Vec::new(),
965    }
966}
967
968fn write_canonical_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
969    let v = serde_json::to_value(value)?;
970    let s = lex_ast::canon_json::to_canonical_string(&v);
971    if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
972    fs::write(path, s)?;
973    Ok(())
974}
975
976#[allow(dead_code)]
977fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T, StoreError> {
978    let bytes = fs::read(path)?;
979    Ok(serde_json::from_slice(&bytes)?)
980}