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("unknown op_id `{0}`")]
35    UnknownOp(lex_vcs::OpId),
36    /// A typed AST transform (#280) — e.g. `ReplaceMatchArm` — was
37    /// asked to operate on a node it couldn't address (wrong kind,
38    /// out-of-range arm index, unknown NodeId, etc.). Distinct from
39    /// `TypeError` (which means the transform succeeded but its
40    /// output didn't typecheck) so callers can render the right
41    /// error message.
42    #[error("transform failed: {0}")]
43    TransformError(lex_ast::TransformError),
44    #[error(transparent)]
45    Apply(#[from] lex_vcs::ApplyError),
46    /// The candidate program — i.e. the source the caller is
47    /// publishing — doesn't typecheck. The branch head is unchanged
48    /// and no op records are persisted. Issue #130's "always-valid
49    /// HEAD" invariant: the gate runs before any side effect, so a
50    /// type-broken publish leaves no footprint.
51    #[error("type errors in published program: {} error(s)", .0.len())]
52    TypeError(Vec<lex_types::TypeError>),
53    /// The op was persisted but a `required_attestations` rule in
54    /// `policy.json` (#245) refused to advance the branch head past
55    /// it. The op record is durable — re-running with the missing
56    /// attestations recorded will succeed without re-persisting —
57    /// but the branch is unchanged. Surfaced as a structured JSON
58    /// envelope on the HTTP API.
59    #[error(
60        "branch advance blocked: op {} missing attestations: {}",
61        .0.op_id, .0.missing.join(", ")
62    )]
63    BranchAdvanceBlocked(crate::policy::BranchAdvanceBlocked),
64    /// All retry attempts of the CAS branch-head advance failed
65    /// because another writer kept advancing the same branch
66    /// (#262). The op record itself is durable in the op log
67    /// (orphaned), so re-running with backoff would eventually
68    /// land — return `503 Contention { retry_after }` from the
69    /// HTTP API and let the client back off.
70    #[error("branch advance contention on `{branch}`: {attempts} retries exhausted")]
71    Contention { branch: String, attempts: u32 },
72    /// The op was persisted but its stage carries an attestation
73    /// produced by a retroactively quarantined tool (#248). The
74    /// branch head is unchanged. The op record stays in the log
75    /// (audit trail intact); re-running with the producer
76    /// unblocked, or with un-contaminated attestations, succeeds
77    /// without re-persisting the op.
78    #[error(
79        "branch advance blocked: op {} touches stage {} with an attestation from \
80         quarantined producer `{}` (blocked at {}, attestation at {})",
81        .0.op_id, .0.stage_id, .0.tool_id, .0.blocked_at, .0.attestation_at
82    )]
83    ProducerBlocked(crate::policy::ProducerBlocked),
84    /// The op would push its session's monotonic budget over the
85    /// cap configured in `policy.session_budgets` (#292 slice 3).
86    /// The op is *not* persisted; the branch head is unchanged.
87    /// The caller should either start a new session, raise the
88    /// cap, or refactor to fit the budget. HTTP API maps to 503.
89    #[error(
90        "session `{session_id}` budget exceeded: spent_after={spent_after} > cap={cap}"
91    )]
92    BudgetExceeded {
93        session_id: String,
94        cap: u64,
95        spent_after: u64,
96    },
97}
98
99/// The outcome returned by [`Store::publish_program`].
100#[derive(Debug, Clone, serde::Serialize)]
101pub struct PublishOutcome {
102    pub ops: Vec<PublishOp>,
103    pub head_op: Option<lex_vcs::OpId>,
104}
105
106/// One applied operation within a [`PublishOutcome`].
107#[derive(Debug, Clone, serde::Serialize)]
108pub struct PublishOp {
109    pub op_id: lex_vcs::OpId,
110    pub kind: serde_json::Value,
111}
112
113/// One entry in the per-`SigId` stage history surfaced by
114/// `Store::sig_history`. Newest-first ordering is the responsibility
115/// of the producer.
116#[derive(Debug, Clone, serde::Serialize, PartialEq)]
117pub struct StageHistoryEntry {
118    pub stage_id: String,
119    pub status: StageStatus,
120    /// Wall-clock seconds of the most recent transition.
121    pub last_at: u64,
122    /// Wall-clock seconds when this stage was first written to the
123    /// store (its initial Draft transition). `None` for stages
124    /// whose lifecycle log doesn't include an explicit Draft entry
125    /// — shouldn't happen for stages published via `Store::publish`,
126    /// but the type allows hand-edited stores.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub published_at: Option<u64>,
129}
130
131/// Per-candidate metadata surfaced by [`Store::list_candidates`]
132/// (#294). Returned sorted by `op_id` for deterministic output.
133#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
134pub struct CandidateInfo {
135    pub op_id: lex_vcs::OpId,
136    pub stage_id: lex_vcs::StageId,
137    /// Author intent. Always set for `Candidate` ops emitted via
138    /// [`Store::propose_candidate`]; `None` only if a
139    /// hand-written raw op skipped the intent tag.
140    pub intent_id: Option<lex_vcs::IntentId>,
141}
142
143pub struct Store {
144    root: PathBuf,
145}
146
147impl Store {
148    /// Open or create a store rooted at `root`.
149    pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
150        let root = root.as_ref().to_path_buf();
151        fs::create_dir_all(root.join("stages"))?;
152        fs::create_dir_all(root.join("traces"))?;
153        Ok(Self { root })
154    }
155
156    pub fn root(&self) -> &Path { &self.root }
157
158    fn now() -> u64 {
159        SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
160    }
161
162    fn sig_dir(&self, sig: &str) -> PathBuf { self.root.join("stages").join(sig) }
163    fn impl_dir(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("implementations") }
164    fn tests_dir(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("tests") }
165    fn specs_dir(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("specs") }
166    fn lifecycle_path(&self, sig: &str) -> PathBuf { self.sig_dir(sig).join("lifecycle.json") }
167
168    // ---- publish ----
169
170    /// Publish a stage as **Draft**. Returns the StageId.
171    /// Idempotent: republishing the same canonical AST returns the same
172    /// StageId without writing duplicates.
173    pub fn publish(&self, stage: &Stage) -> Result<String, StoreError> {
174        self.publish_signed(stage, None)
175    }
176
177    /// Like [`Self::publish`] but optionally attaches an Ed25519
178    /// signature over the StageId (#227). When `signer` is `Some`,
179    /// the persisted metadata gets a `signature` field that
180    /// downstream consumers can verify via
181    /// [`lex_vcs::verify_stage_id`].
182    ///
183    /// Idempotency: if a metadata file already exists the signature
184    /// is *not* re-written. This preserves "republishing is a no-op"
185    /// even across different signers — promoting a signed stage
186    /// requires a fresh stage hash anyway, so a metadata overwrite
187    /// would be the wrong primitive.
188    pub fn publish_signed(
189        &self,
190        stage: &Stage,
191        signer: Option<&lex_vcs::Keypair>,
192    ) -> Result<String, StoreError> {
193        let sig = sig_id(stage).ok_or(StoreError::CannotPublishImport)?;
194        let stage_id = stage_id(stage).ok_or(StoreError::CannotPublishImport)?;
195        let name = stage_name(stage).to_string();
196
197        fs::create_dir_all(self.impl_dir(&sig))?;
198        fs::create_dir_all(self.tests_dir(&sig))?;
199        fs::create_dir_all(self.specs_dir(&sig))?;
200
201        let ast_path = self.impl_dir(&sig).join(format!("{}.ast.json", stage_id));
202        let delta_path = self.impl_dir(&sig).join(format!("{}.delta.json", stage_id));
203        let meta_path = self.impl_dir(&sig).join(format!("{}.metadata.json", stage_id));
204
205        // #261 slice 3: try delta encoding against the most recent
206        // prior stage in this sig's lifecycle. Falls back to a full
207        // snapshot when (a) no prior stage exists, (b) the diff
208        // ratio is over the threshold, or (c) the delta chain is
209        // already at its cap. The decision is internal — callers
210        // see the same `Stage` object on `get_ast` regardless.
211        if !ast_path.exists() && !delta_path.exists() {
212            self.persist_stage_bytes(&sig, &stage_id, stage, &ast_path, &delta_path)?;
213        }
214        if !meta_path.exists() {
215            let signature = signer.map(|kp| kp.sign_stage_id(&stage_id));
216            let metadata = Metadata {
217                stage_id: stage_id.clone(),
218                sig_id: sig.clone(),
219                name,
220                published_at: Self::now(),
221                note: None,
222                signature,
223            };
224            write_canonical_json(&meta_path, &metadata)?;
225        }
226
227        // Lifecycle: append a Draft transition for first publish.
228        let mut life = self.read_lifecycle(&sig).unwrap_or_else(|_| Lifecycle {
229            sig_id: sig.clone(),
230            ..Default::default()
231        });
232        if !life.transitions.iter().any(|t| t.stage_id == stage_id) {
233            life.transitions.push(Transition {
234                stage_id: stage_id.clone(),
235                from: StageStatus::Draft, // synthesized; "from" of first transition is itself
236                to: StageStatus::Draft,
237                at: Self::now(),
238                reason: None,
239            });
240            self.write_lifecycle(&sig, &life)?;
241        }
242        Ok(stage_id)
243    }
244
245    // ---- lifecycle ----
246
247    pub fn activate(&self, stage_id: &str) -> Result<(), StoreError> {
248        let (sig, mut life) = self.lookup_lifecycle(stage_id)?;
249        // Demote any currently-Active impls for this SigId to Deprecated.
250        let active = life.current_active().map(|s| s.to_string());
251        if let Some(prev) = active {
252            if prev != stage_id {
253                life.transitions.push(Transition {
254                    stage_id: prev,
255                    from: StageStatus::Active,
256                    to: StageStatus::Deprecated,
257                    at: Self::now(),
258                    reason: Some("superseded".into()),
259                });
260            }
261        }
262        let cur = life.status_of(stage_id);
263        if cur == Some(StageStatus::Tombstone) {
264            return Err(StoreError::InvalidTransition("tombstoned cannot be activated".into()));
265        }
266        life.transitions.push(Transition {
267            stage_id: stage_id.into(),
268            from: cur.unwrap_or(StageStatus::Draft),
269            to: StageStatus::Active,
270            at: Self::now(),
271            reason: None,
272        });
273        self.write_lifecycle(&sig, &life)
274    }
275
276    pub fn deprecate(&self, stage_id: &str, reason: impl Into<String>) -> Result<(), StoreError> {
277        let (sig, mut life) = self.lookup_lifecycle(stage_id)?;
278        let cur = life.status_of(stage_id).ok_or_else(|| StoreError::UnknownStage(stage_id.into()))?;
279        if cur != StageStatus::Active {
280            return Err(StoreError::InvalidTransition(format!("{cur:?} ⇒ Deprecated")));
281        }
282        life.transitions.push(Transition {
283            stage_id: stage_id.into(),
284            from: cur,
285            to: StageStatus::Deprecated,
286            at: Self::now(),
287            reason: Some(reason.into()),
288        });
289        self.write_lifecycle(&sig, &life)
290    }
291
292    pub fn tombstone(&self, stage_id: &str) -> Result<(), StoreError> {
293        let (sig, mut life) = self.lookup_lifecycle(stage_id)?;
294        let cur = life.status_of(stage_id).ok_or_else(|| StoreError::UnknownStage(stage_id.into()))?;
295        if cur != StageStatus::Deprecated {
296            return Err(StoreError::InvalidTransition(format!("{cur:?} ⇒ Tombstone")));
297        }
298        life.transitions.push(Transition {
299            stage_id: stage_id.into(),
300            from: cur,
301            to: StageStatus::Tombstone,
302            at: Self::now(),
303            reason: None,
304        });
305        self.write_lifecycle(&sig, &life)
306    }
307
308    // ---- queries ----
309
310    /// The current Active StageId for a signature, or `None`.
311    pub fn resolve_sig(&self, sig: &str) -> Result<Option<String>, StoreError> {
312        let life = match self.read_lifecycle(sig) {
313            Ok(l) => l,
314            Err(_) => return Ok(None),
315        };
316        Ok(life.current_active().map(|s| s.to_string()))
317    }
318
319    /// Per-stage history for a SigId, ordered chronologically by
320    /// the *last* transition timestamp. Returns one entry per
321    /// distinct StageId that has ever been published under `sig`.
322    /// `Ok(vec![])` if the SigId doesn't exist in the store.
323    ///
324    /// Used by `lex blame` to render "where does this fn come from".
325    pub fn sig_history(&self, sig: &str) -> Result<Vec<StageHistoryEntry>, StoreError> {
326        let life = match self.read_lifecycle(sig) {
327            Ok(l) => l,
328            Err(_) => return Ok(Vec::new()),
329        };
330        // Collapse transitions: latest status + last_at per stage,
331        // plus the timestamp of the first Draft transition (≈ when
332        // the stage was published) when one exists.
333        let mut by_stage: indexmap::IndexMap<String, StageHistoryEntry> =
334            indexmap::IndexMap::new();
335        for t in &life.transitions {
336            let entry = by_stage.entry(t.stage_id.clone()).or_insert(StageHistoryEntry {
337                stage_id: t.stage_id.clone(),
338                status: t.to,
339                last_at: t.at,
340                published_at: None,
341            });
342            entry.status = t.to;
343            entry.last_at = t.at;
344            if t.from == StageStatus::Draft && entry.published_at.is_none() {
345                entry.published_at = Some(t.at);
346            }
347            if t.to == StageStatus::Draft && entry.published_at.is_none() {
348                // Initial publication: Draft is the *destination*.
349                entry.published_at = Some(t.at);
350            }
351        }
352        let mut out: Vec<StageHistoryEntry> = by_stage.into_values().collect();
353        // Sort newest first so `lex blame` shows recent activity at top.
354        out.sort_by_key(|e| std::cmp::Reverse(e.last_at));
355        Ok(out)
356    }
357
358    pub fn get_ast(&self, stage_id: &str) -> Result<Stage, StoreError> {
359        let (sig, _) = self.lookup_lifecycle(stage_id)?;
360        let bytes = self.read_stage_canonical_bytes(&sig, stage_id)?;
361        Ok(serde_json::from_slice(&bytes)?)
362    }
363
364    /// Read the canonical bytes of a stage, walking back through
365    /// any delta chain (#261 slice 3). The recursion ends at a
366    /// `<stage_id>.ast.json` file (a full snapshot) or, in the
367    /// degenerate case of a missing chain, with `UnknownStage`.
368    fn read_stage_canonical_bytes(
369        &self,
370        sig: &str,
371        stage_id: &str,
372    ) -> Result<Vec<u8>, StoreError> {
373        let ast_path = self.impl_dir(sig).join(format!("{}.ast.json", stage_id));
374        if ast_path.exists() {
375            return Ok(fs::read(&ast_path)?);
376        }
377        let delta_path = self.impl_dir(sig).join(format!("{}.delta.json", stage_id));
378        if !delta_path.exists() {
379            return Err(StoreError::UnknownStage(stage_id.into()));
380        }
381        let delta_bytes = fs::read(&delta_path)?;
382        let delta: crate::delta::StageDelta = serde_json::from_slice(&delta_bytes)?;
383        let base_bytes = self.read_stage_canonical_bytes(sig, &delta.base_stage_id)?;
384        crate::delta::apply(&base_bytes, &delta)
385            .map_err(|e| StoreError::Io(std::io::Error::new(
386                std::io::ErrorKind::InvalidData,
387                format!("applying delta for {stage_id}: {e}"),
388            )))
389    }
390
391    /// Persist a freshly-published stage's canonical bytes (#261
392    /// slice 3). Tries delta encoding against the most recent
393    /// prior stage in the sig's lifecycle; falls back to a full
394    /// snapshot when no base exists, the diff ratio is too high,
395    /// or the delta chain is already at its cap.
396    fn persist_stage_bytes(
397        &self,
398        sig: &str,
399        stage_id: &str,
400        stage: &Stage,
401        ast_path: &Path,
402        delta_path: &Path,
403    ) -> Result<(), StoreError> {
404        let new_bytes = canonical_bytes(stage)?;
405        if let Some((base_stage_id, base_chain_length)) =
406            self.pick_delta_base(sig, stage_id)?
407        {
408            let base_bytes = self.read_stage_canonical_bytes(sig, &base_stage_id)?;
409            let (prefix, suffix, middle) = crate::delta::splice(&base_bytes, &new_bytes);
410            let chain_length = base_chain_length + 1;
411            if crate::delta::is_worth_encoding(middle.len(), new_bytes.len(), chain_length) {
412                let delta = crate::delta::StageDelta {
413                    base_stage_id,
414                    chain_length,
415                    common_prefix: prefix,
416                    common_suffix: suffix,
417                    middle_hex: hex::encode(&middle),
418                };
419                write_canonical_json(delta_path, &delta)?;
420                return Ok(());
421            }
422        }
423        // Fall through: full snapshot.
424        if let Some(parent) = ast_path.parent() { fs::create_dir_all(parent)?; }
425        fs::write(ast_path, &new_bytes)?;
426        Ok(())
427    }
428
429    /// Pick a base stage for delta encoding from the given sig's
430    /// lifecycle. Returns `(base_stage_id, base_chain_length)` for
431    /// the most-recent non-tombstoned prior stage, or `None` when
432    /// there is no candidate. The chain length is read off the
433    /// base's `.delta.json` (if any) to enforce the cap.
434    fn pick_delta_base(
435        &self,
436        sig: &str,
437        new_stage_id: &str,
438    ) -> Result<Option<(String, usize)>, StoreError> {
439        let life = self.read_lifecycle(sig).ok();
440        let Some(life) = life else { return Ok(None); };
441        // Walk transitions newest-first; pick the first prior
442        // stage that isn't this one and isn't tombstoned.
443        let mut latest_per_stage: indexmap::IndexMap<&str, StageStatus> = indexmap::IndexMap::new();
444        for t in &life.transitions {
445            latest_per_stage.insert(&t.stage_id, t.to);
446        }
447        let mut candidates: Vec<&str> = latest_per_stage
448            .iter()
449            .filter(|(id, status)| {
450                **id != new_stage_id && **status != StageStatus::Tombstone
451            })
452            .map(|(id, _)| *id)
453            .collect();
454        // Reverse to get newest-first (transitions are append-only,
455        // so latest_per_stage's iteration order matches insertion
456        // order, oldest-first).
457        candidates.reverse();
458        let Some(&base) = candidates.first() else { return Ok(None); };
459        let base_chain_length = self.delta_chain_length(sig, base)?;
460        Ok(Some((base.to_string(), base_chain_length)))
461    }
462
463    /// Length of the delta chain ending at `stage_id`. Zero when
464    /// the stage is a full snapshot (`.ast.json` present); the
465    /// stored `chain_length` from `.delta.json` otherwise.
466    fn delta_chain_length(&self, sig: &str, stage_id: &str) -> Result<usize, StoreError> {
467        let ast_path = self.impl_dir(sig).join(format!("{}.ast.json", stage_id));
468        if ast_path.exists() {
469            return Ok(0);
470        }
471        let delta_path = self.impl_dir(sig).join(format!("{}.delta.json", stage_id));
472        if !delta_path.exists() {
473            return Ok(0);
474        }
475        let bytes = fs::read(&delta_path)?;
476        let delta: crate::delta::StageDelta = serde_json::from_slice(&bytes)?;
477        Ok(delta.chain_length)
478    }
479
480    pub fn get_metadata(&self, stage_id: &str) -> Result<Metadata, StoreError> {
481        let (sig, _) = self.lookup_lifecycle(stage_id)?;
482        let path = self.impl_dir(&sig).join(format!("{}.metadata.json", stage_id));
483        let bytes = fs::read(&path)?;
484        Ok(serde_json::from_slice(&bytes)?)
485    }
486
487    pub fn get_status(&self, stage_id: &str) -> Result<StageStatus, StoreError> {
488        let (_sig, life) = self.lookup_lifecycle(stage_id)?;
489        life.status_of(stage_id).ok_or_else(|| StoreError::UnknownStage(stage_id.into()))
490    }
491
492    pub fn list_stages_by_name(&self, name: &str) -> Result<Vec<String>, StoreError> {
493        // Walk every SigId → check metadata of any implementation; if its
494        // name matches, include the SigId.
495        let mut out = Vec::new();
496        let stages_dir = self.root.join("stages");
497        if !stages_dir.exists() { return Ok(out); }
498        for entry in fs::read_dir(&stages_dir)? {
499            let entry = entry?;
500            let sig_dir = entry.path();
501            if !sig_dir.is_dir() { continue; }
502            let sig = entry.file_name().to_string_lossy().to_string();
503            // Look at any one metadata file under this SigId.
504            let impls = self.impl_dir(&sig);
505            if !impls.exists() { continue; }
506            for f in fs::read_dir(impls)? {
507                let f = f?;
508                let p = f.path();
509                if p.extension().is_some_and(|e| e == "json")
510                    && p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(".metadata.json"))
511                {
512                    if let Ok(bytes) = fs::read(&p) {
513                        if let Ok(m) = serde_json::from_slice::<Metadata>(&bytes) {
514                            if m.name == name {
515                                if !out.contains(&sig) { out.push(sig.clone()); }
516                                break;
517                            }
518                        }
519                    }
520                }
521            }
522        }
523        out.sort();
524        Ok(out)
525    }
526
527    pub fn list_sigs(&self) -> Result<Vec<String>, StoreError> {
528        let stages_dir = self.root.join("stages");
529        let mut out = Vec::new();
530        if !stages_dir.exists() { return Ok(out); }
531        for entry in fs::read_dir(stages_dir)? {
532            let entry = entry?;
533            if entry.file_type()?.is_dir() {
534                out.push(entry.file_name().to_string_lossy().to_string());
535            }
536        }
537        out.sort();
538        Ok(out)
539    }
540
541    // ---- tests/specs as metadata (§4.4) ----
542
543    pub fn attach_test(&self, sig: &str, test: &Test) -> Result<String, StoreError> {
544        if !self.sig_dir(sig).exists() {
545            return Err(StoreError::UnknownSig(sig.into()));
546        }
547        fs::create_dir_all(self.tests_dir(sig))?;
548        let path = self.tests_dir(sig).join(format!("{}.json", test.id));
549        write_canonical_json(&path, test)?;
550        Ok(test.id.clone())
551    }
552
553    pub fn list_tests(&self, sig: &str) -> Result<Vec<Test>, StoreError> {
554        let dir = self.tests_dir(sig);
555        if !dir.exists() { return Ok(Vec::new()); }
556        let mut out = Vec::new();
557        for f in fs::read_dir(dir)? {
558            let f = f?;
559            if f.path().extension().is_some_and(|e| e == "json") {
560                let bytes = fs::read(f.path())?;
561                out.push(serde_json::from_slice(&bytes)?);
562            }
563        }
564        Ok(out)
565    }
566
567    pub fn attach_spec(&self, sig: &str, spec: &Spec) -> Result<String, StoreError> {
568        if !self.sig_dir(sig).exists() {
569            return Err(StoreError::UnknownSig(sig.into()));
570        }
571        fs::create_dir_all(self.specs_dir(sig))?;
572        let path = self.specs_dir(sig).join(format!("{}.json", spec.id));
573        write_canonical_json(&path, spec)?;
574        Ok(spec.id.clone())
575    }
576
577    pub fn list_specs(&self, sig: &str) -> Result<Vec<Spec>, StoreError> {
578        let dir = self.specs_dir(sig);
579        if !dir.exists() { return Ok(Vec::new()); }
580        let mut out = Vec::new();
581        for f in fs::read_dir(dir)? {
582            let f = f?;
583            if f.path().extension().is_some_and(|e| e == "json") {
584                let bytes = fs::read(f.path())?;
585                out.push(serde_json::from_slice(&bytes)?);
586            }
587        }
588        Ok(out)
589    }
590
591    // ---- traces (§4.2 / M7) ----
592
593    fn trace_path(&self, run_id: &str) -> PathBuf {
594        self.root.join("traces").join(run_id).join("trace.json")
595    }
596
597    pub fn save_trace(&self, tree: &lex_trace::TraceTree) -> Result<String, StoreError> {
598        let path = self.trace_path(&tree.run_id);
599        write_canonical_json(&path, tree)?;
600        Ok(tree.run_id.clone())
601    }
602
603    pub fn load_trace(&self, run_id: &str) -> Result<lex_trace::TraceTree, StoreError> {
604        let bytes = fs::read(self.trace_path(run_id))?;
605        Ok(serde_json::from_slice(&bytes)?)
606    }
607
608    pub fn list_traces(&self) -> Result<Vec<String>, StoreError> {
609        let dir = self.root.join("traces");
610        if !dir.exists() { return Ok(Vec::new()); }
611        let mut out = Vec::new();
612        for entry in fs::read_dir(dir)? {
613            let entry = entry?;
614            if entry.file_type()?.is_dir() {
615                out.push(entry.file_name().to_string_lossy().to_string());
616            }
617        }
618        out.sort();
619        Ok(out)
620    }
621
622    // ---- internals ----
623
624    fn lookup_lifecycle(&self, stage_id: &str) -> Result<(String, Lifecycle), StoreError> {
625        // Walk every SigId, find which one contains this StageId.
626        for sig in self.list_sigs()? {
627            if let Ok(life) = self.read_lifecycle(&sig) {
628                if life.transitions.iter().any(|t| t.stage_id == stage_id) {
629                    return Ok((sig, life));
630                }
631            }
632        }
633        Err(StoreError::UnknownStage(stage_id.into()))
634    }
635
636    fn read_lifecycle(&self, sig: &str) -> Result<Lifecycle, StoreError> {
637        let path = self.lifecycle_path(sig);
638        if !path.exists() {
639            return Ok(Lifecycle { sig_id: sig.into(), transitions: Vec::new() });
640        }
641        let bytes = fs::read(&path)?;
642        Ok(serde_json::from_slice(&bytes)?)
643    }
644
645    fn write_lifecycle(&self, sig: &str, life: &Lifecycle) -> Result<(), StoreError> {
646        write_canonical_json(&self.lifecycle_path(sig), life)
647    }
648
649    /// Apply a published program to a branch as a sequence of typed
650    /// operations. Returns the ordered list of op_ids + the new
651    /// head_op. The caller (`lex publish` CLI, `lex serve`'s HTTP
652    /// handler) is responsible for computing the `DiffReport` against
653    /// the current branch head — the diff infrastructure lives in
654    /// `lex-vcs::compute_diff` (previously `lex-cli`) to keep this
655    /// layer from owning diffing logic.
656    ///
657    /// On success: every op in the returned list is durable in the
658    /// op log and the branch's head_op points at the last one.
659    /// On a no-op (no diff): returns empty `ops` and the existing
660    /// `head_op` unchanged.
661    pub fn publish_program(
662        &self,
663        branch: &str,
664        stages: &[lex_ast::Stage],
665        diff: &lex_vcs::DiffReport,
666        new_imports: &lex_vcs::ImportMap,
667        activate: bool,
668    ) -> Result<PublishOutcome, StoreError> {
669        self.publish_program_signed(branch, stages, diff, new_imports, activate, None)
670    }
671
672    /// Signed variant of [`Self::publish_program`] (#227). Every
673    /// stage written under this batch gets the same signer; per-stage
674    /// keys aren't supported because the agent identity model treats
675    /// a publish as a single authorial act.
676    pub fn publish_program_signed(
677        &self,
678        branch: &str,
679        stages: &[lex_ast::Stage],
680        diff: &lex_vcs::DiffReport,
681        new_imports: &lex_vcs::ImportMap,
682        activate: bool,
683        signer: Option<&lex_vcs::Keypair>,
684    ) -> Result<PublishOutcome, StoreError> {
685        use std::collections::{BTreeMap, BTreeSet};
686
687        // #130's write-time gate: verify the candidate program
688        // typechecks (and effects are correctly declared) before
689        // any disk side-effect. If anything fails, return the
690        // structured envelope and leave the branch head unchanged
691        // — the store's "always-valid HEAD" invariant only holds
692        // because this is the only batch-publish path that
693        // advances heads. Single-op writes via the lower-level
694        // `apply_operation` are not gated yet (#130 follow-up).
695        if let Err(errors) = lex_types::check_program(stages) {
696            return Err(StoreError::TypeError(errors));
697        }
698
699        // Build old-side views from the current branch.
700        let old_head = self.branch_head(branch)?;
701        let old_name_to_sig: BTreeMap<String, String> = old_head.iter()
702            .filter_map(|(sig, stg)| {
703                self.get_metadata(stg).ok().map(|m| (m.name, sig.clone()))
704            })
705            .collect();
706        let old_effects: BTreeMap<String, BTreeSet<String>> = old_head.iter()
707            .filter_map(|(sig, stg)| {
708                let ast = self.get_ast(stg).ok()?;
709                match ast {
710                    lex_ast::Stage::FnDecl(fd) => {
711                        let s: BTreeSet<String> = fd.effects.iter()
712                            .map(|e| e.name.clone()).collect();
713                        Some((sig.clone(), s))
714                    }
715                    _ => None,
716                }
717            })
718            .collect();
719        let old_imports = self.derive_imports_from_oplog(branch)?;
720
721        let op_kinds = lex_vcs::diff_to_ops(lex_vcs::DiffInputs {
722            old_head: &old_head,
723            old_name_to_sig: &old_name_to_sig,
724            old_effects: &old_effects,
725            old_imports: &old_imports,
726            new_stages: stages,
727            new_imports,
728            diff,
729        }).map_err(|e| StoreError::InvalidTransition(format!("diff_to_ops: {e}")))?;
730
731        let mut ops_out: Vec<PublishOp> = Vec::new();
732        let mut last_op_id: Option<lex_vcs::OpId> = None;
733        for kind in op_kinds {
734            // Persist the underlying stage AST/metadata if this op
735            // produces or replaces one.
736            if let Some(stg) = stage_for_kind(&kind, stages) {
737                if !matches!(stg, lex_ast::Stage::Import(_)) {
738                    self.publish_signed(stg, signer)?;
739                    if activate {
740                        if let Some(stage_id_str) = stage_id(stg) {
741                            let _ = self.activate(&stage_id_str);
742                        }
743                    }
744                }
745            }
746            let transition = transition_for_kind(&kind);
747            let attestable = attestable_stage_ids(&transition);
748            let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
749            let op = lex_vcs::Operation::new(
750                kind.clone(),
751                head_now.into_iter().collect::<Vec<_>>(),
752            );
753            let op_id = self.apply_operation(branch, op, transition)?;
754            self.record_typecheck_passed(&attestable, &op_id)?;
755            ops_out.push(PublishOp {
756                op_id: op_id.clone(),
757                kind: serde_json::to_value(&kind)
758                    .map_err(StoreError::Serde)?,
759            });
760            last_op_id = Some(op_id);
761        }
762
763        let head_op = match last_op_id {
764            Some(id) => Some(id),
765            // No ops applied; return whatever the head was already.
766            None => self.get_branch(branch)?.and_then(|b| b.head_op),
767        };
768
769        Ok(PublishOutcome {
770            ops: ops_out,
771            head_op,
772        })
773    }
774
775    pub fn derive_imports_from_oplog(
776        &self,
777        branch: &str,
778    ) -> Result<lex_vcs::ImportMap, StoreError> {
779        use lex_vcs::OperationKind::*;
780        let log = lex_vcs::OpLog::open(self.root())?;
781        let head = match self.get_branch(branch)?.and_then(|b| b.head_op) {
782            Some(h) => h,
783            None => return Ok(Default::default()),
784        };
785        let mut out: lex_vcs::ImportMap = Default::default();
786        for r in log.walk_forward(&head, None)? {
787            match r.op.kind {
788                AddImport { in_file, module } => {
789                    out.entry(in_file).or_default().insert(module);
790                }
791                RemoveImport { in_file, module } => {
792                    if let Some(set) = out.get_mut(&in_file) { set.remove(&module); }
793                }
794                _ => {}
795            }
796        }
797        Ok(out)
798    }
799
800    /// Apply an operation to a branch and advance its head_op.
801    ///
802    /// The single advance path. Validates parents via `lex_vcs::apply`,
803    /// persists the operation via the op log, then atomically advances
804    /// the branch file's head_op via `set_branch_head_op`.
805    ///
806    /// Errors:
807    /// - `UnknownBranch`: branch does not exist (no op is persisted).
808    /// - `Apply(ApplyError::StaleParent)`: the op's parents don't
809    ///   match the branch head — head is unchanged. Callers that
810    ///   want retry-on-stale (e.g. `lex publish` re-running against
811    ///   a moved head) match on this variant explicitly.
812    /// - `Apply(ApplyError::UnknownMergeParent)`: a merge op's
813    ///   second parent isn't in the log.
814    /// - `Io`: filesystem error during persist or branch advance.
815    ///
816    /// Crash recovery: between op persist and branch advance, a crash
817    /// can leave an orphan op record in the log with no branch
818    /// pointing at it. The op is content-addressed and cheap to
819    /// re-derive from the same source. See
820    /// Apply a single op against `branch`, gated on the candidate
821    /// program typechecking. The per-op variant of #130's
822    /// write-time gate — counterpart to [`Self::publish_program`]'s
823    /// batch-mode check.
824    ///
825    /// `candidate` is the sequence of `Stage`s that *would* exist
826    /// on this branch after the op is applied. Caller's
827    /// responsibility: today neither `lex-store` nor `lex-vcs`
828    /// reconstruct the candidate from the op + branch state on
829    /// behalf of the caller. The natural callers (HTTP `POST
830    /// /v1/publish` for a single op; agent harnesses driving
831    /// merges via the future #134 API) already have the candidate
832    /// in memory.
833    ///
834    /// On rejection: branch head unchanged, no op record persisted.
835    /// Same atomicity guarantee as the publish path.
836    ///
837    /// # Why a separate method, not a flag on `apply_operation`
838    ///
839    /// The merge engine in `lex-vcs::merge` calls
840    /// `Store::apply_operation` directly to land merge ops, and at
841    /// merge time the resolved program isn't a single `Vec<Stage>`
842    /// the way it is on the publish path — it's a per-sig
843    /// resolution map. Forcing a candidate through `apply_operation`
844    /// would either require the merge engine to assemble one (slow,
845    /// every active stage off disk) or accept `Option<&[Stage]>`
846    /// and silently skip the gate — the second is exactly the kind
847    /// of "secretly opt-out" path #130 is trying to remove. The
848    /// honest split is two methods: `apply_operation` for callers
849    /// that already typecheck their inputs (or don't need to —
850    /// rare, but the merge-resolve case), `apply_operation_checked`
851    /// for everyone else.
852    pub fn apply_operation_checked(
853        &self,
854        branch: &str,
855        op: lex_vcs::Operation,
856        transition: lex_vcs::StageTransition,
857        candidate: &[lex_ast::Stage],
858    ) -> Result<lex_vcs::OpId, StoreError> {
859        if let Err(errors) = lex_types::check_program(candidate) {
860            // #281: emit a `RepairHint` attestation against each
861            // candidate stage the transition was about to produce.
862            // The op record itself isn't persisted (the gate is
863            // pre-persistence), but the candidate stage IS — the
864            // transform-flow methods publish before this call.
865            // The attached hint lets `lex repair <op_id>` and
866            // future LLM-assisted apply paths read the structured
867            // errors without re-running the typecheck.
868            let attestable = attestable_stage_ids(&transition);
869            let failed_op_id = op.op_id();
870            let _ = self.record_repair_hint(&attestable, &failed_op_id, &errors);
871            return Err(StoreError::TypeError(errors));
872        }
873        // #292 slice 3: per-session budget gate. After typecheck
874        // passes, refuse the op if it would push its session's
875        // monotonic spend over the configured cap. Sessions
876        // without an intent_id, or with an intent whose session
877        // has no cap configured, sail through.
878        self.check_session_budget(&op)?;
879        let attestable = attestable_stage_ids(&transition);
880        let op_effects = op_declared_effects(&op.kind);
881        // #262: CAS retry loop. Single-parent ops can be safely
882        // re-persisted under a new parent on contention (the kind
883        // is invariant; only `parents` changes). Merge ops (already
884        // 2-parent) come through the merge engine which has its own
885        // coordination; we don't retry them here — we'll see the
886        // first attempt's CAS fail and surface Contention.
887        self.cas_retry_advance(branch, op, transition, |new_head| {
888            self.record_typecheck_passed(&attestable, &new_head.op_id)?;
889            self.run_required_attestations_gate(
890                branch, &new_head.op_id, &attestable, &op_effects,
891            )
892        })
893    }
894
895    /// Open the attestation log rooted at this store. The log lives
896    /// under `<root>/attestations/`; opening is idempotent and cheap
897    /// (`fs::create_dir_all`). Exposed publicly so consumers — `lex
898    /// blame --with-evidence`, `GET /v1/stage/<id>/attestations` —
899    /// can read what the store gate emitted without round-tripping
900    /// through this crate's API surface.
901    /// Recompute a producer's trust score from its recent
902    /// attestation history and emit a fresh `ProducerTrust`
903    /// attestation (#293). Score = `passed / (passed + failed
904    /// + inconclusive)` over the last `window` attestations
905    /// produced by `tool_id`, expressed in thousandths
906    /// (`0..=1000`).
907    ///
908    /// Refuses to grant trust when the tool has an active
909    /// `ProducerBlock` — the block wins as a hard veto. Returns
910    /// `Ok(None)` for "no attestations to score" (a brand-new
911    /// producer); the caller can choose how to handle it
912    /// (typically: skip the publish until evidence accrues).
913    ///
914    /// `granted_by` is the identity of the actor running the
915    /// recompute (typically the human admin, or "lex-ci-bot"
916    /// for an automated nightly).
917    pub fn recompute_producer_trust(
918        &self,
919        tool_id: &str,
920        window: usize,
921        granted_by: &str,
922    ) -> Result<Option<lex_vcs::AttestationId>, StoreError> {
923        let log = self.attestation_log()?;
924        let all = log.list_all()?;
925        // Hard veto: don't grant trust to a blocked tool.
926        if lex_vcs::active_producer_block(&all, tool_id).is_some() {
927            return Err(StoreError::InvalidTransition(format!(
928                "cannot recompute trust for `{tool_id}` — \
929                 producer is currently blocked"
930            )));
931        }
932        // Filter to attestations from this tool, newest-first by
933        // timestamp, then take the window.
934        let mut from_tool: Vec<&lex_vcs::Attestation> = all.iter()
935            .filter(|a| a.produced_by.tool == tool_id)
936            // Ignore self-referential trust attestations (we're
937            // scoring evidence, not previous trust statements).
938            .filter(|a| !matches!(a.kind,
939                lex_vcs::AttestationKind::ProducerTrust { .. }
940                | lex_vcs::AttestationKind::TrustWaived { .. }))
941            .collect();
942        from_tool.sort_by_key(|a| std::cmp::Reverse(a.timestamp));
943        from_tool.truncate(window);
944        if from_tool.is_empty() {
945            return Ok(None);
946        }
947        let (mut passed, mut total) = (0u64, 0u64);
948        for a in &from_tool {
949            total += 1;
950            if matches!(a.result, lex_vcs::AttestationResult::Passed) {
951                passed += 1;
952            }
953        }
954        let score = if total == 0 {
955            0
956        } else {
957            let raw = (passed as f64) * 1000.0 / (total as f64);
958            raw.round().clamp(0.0, 1000.0) as u32
959        };
960        let head_op = self.list_branches()?
961            .into_iter()
962            .find_map(|b| self.get_branch(&b).ok().flatten().and_then(|x| x.head_op))
963            .unwrap_or_else(|| "fresh".into());
964        let evidence = format!("window={window}, sample={}, head_op={head_op:.16}", from_tool.len());
965        let attestation = lex_vcs::Attestation::new(
966            tool_id.to_string(),
967            None,
968            None,
969            lex_vcs::AttestationKind::ProducerTrust {
970                tool_id: tool_id.into(),
971                score_thousandths: score,
972                evidence,
973                granted_by: granted_by.into(),
974            },
975            lex_vcs::AttestationResult::Passed,
976            producer_trust_producer(),
977            None,
978        );
979        let id = attestation.attestation_id.clone();
980        log.put(&attestation)?;
981        Ok(Some(id))
982    }
983
984    pub fn attestation_log(&self) -> Result<lex_vcs::AttestationLog, StoreError> {
985        Ok(lex_vcs::AttestationLog::open(self.root())?)
986    }
987
988    /// Emit one `TypeCheck::Passed` attestation per stage produced by
989    /// a successful gated apply. Idempotent on `attestation_id` —
990    /// re-running the same gate run dedups via content addressing.
991    ///
992    /// Failure modes: `io::Error` from the attestation log (disk
993    /// full, perms). The op has already landed by the time this
994    /// runs; an error here means the op is durable but the evidence
995    /// is missing. We propagate so the caller sees the partial
996    /// state rather than silently swallowing — re-attesting the
997    /// same op against the same op_id is idempotent (content
998    /// addressing) so a retry is safe once the underlying issue is
999    /// fixed.
1000    fn record_typecheck_passed(
1001        &self,
1002        stage_ids: &[String],
1003        op_id: &lex_vcs::OpId,
1004    ) -> Result<(), StoreError> {
1005        if stage_ids.is_empty() {
1006            return Ok(());
1007        }
1008        let log = self.attestation_log()?;
1009        for stage_id in stage_ids {
1010            let attestation = lex_vcs::Attestation::new(
1011                stage_id.clone(),
1012                Some(op_id.clone()),
1013                None,
1014                lex_vcs::AttestationKind::TypeCheck,
1015                lex_vcs::AttestationResult::Passed,
1016                typecheck_producer(),
1017                None,
1018            );
1019            log.put(&attestation)?;
1020        }
1021        Ok(())
1022    }
1023
1024    /// Consult `policy.session_budgets` for the op's session
1025    /// (resolved via `op.intent_id → Intent.session_id`) and
1026    /// refuse if applying would push the session's monotonic spend
1027    /// over the configured cap (#292 slice 3).
1028    ///
1029    /// Ops without an `intent_id`, or whose intent has no
1030    /// configured cap, return Ok without any disk read.
1031    fn check_session_budget(
1032        &self,
1033        op: &lex_vcs::Operation,
1034    ) -> Result<(), StoreError> {
1035        let Some(intent_id) = op.intent_id.as_deref() else { return Ok(()); };
1036        let intent_log = lex_vcs::IntentLog::open(self.root())?;
1037        let Some(intent) = intent_log.get(&intent_id.to_string())? else {
1038            // Dangling intent — treat as "no session" and let it
1039            // sail through. Slice 1's ledger already documents
1040            // this as graceful-degradation semantics.
1041            return Ok(());
1042        };
1043        let policy = crate::policy::load(self.root())?.unwrap_or_default();
1044        let Some(cap) = policy.session_budgets.cap_for(&intent.session_id) else {
1045            return Ok(());
1046        };
1047        // Recompute the session's current spend + the contribution
1048        // from this op. Re-running the ledger walk on every gated
1049        // op is O(branch history); see #292 slice 1's note about
1050        // a future on-disk cache.
1051        let current = self.session_budget(&intent.session_id)?;
1052        let increment = crate::budget::monotonic_spend_of(&op.kind);
1053        let spent_after = current.spent.saturating_add(increment);
1054        if spent_after > cap {
1055            return Err(StoreError::BudgetExceeded {
1056                session_id: intent.session_id,
1057                cap,
1058                spent_after,
1059            });
1060        }
1061        Ok(())
1062    }
1063
1064    /// Emit `RepairHint` attestations for a TypeError-rejected op
1065    /// (#281). One per candidate stage in the transition. The hint
1066    /// records the *would-be* op_id (deterministic, content-
1067    /// addressed even though the op record was never persisted)
1068    /// and the structured errors.
1069    ///
1070    /// #306 slice 3: `suggested_transform` is populated from the
1071    /// static (rule_tag → likely_transform) table for the *first*
1072    /// error in the batch. The LLM-driven `lex repair --apply`
1073    /// flow can still overwrite this with a higher-quality
1074    /// suggestion; the static value is the floor, not the ceiling.
1075    ///
1076    /// Best-effort: a write failure here is swallowed by the
1077    /// caller (the original `TypeError` is the load-bearing
1078    /// signal; missing the hint is recoverable on a retry).
1079    fn record_repair_hint(
1080        &self,
1081        stage_ids: &[String],
1082        failed_op_id: &lex_vcs::OpId,
1083        errors: &[lex_types::TypeError],
1084    ) -> Result<(), StoreError> {
1085        if stage_ids.is_empty() {
1086            return Ok(());
1087        }
1088        let errors_json = serde_json::to_value(errors)
1089            .map_err(StoreError::Serde)?;
1090        // #306 slice 3: look up the static suggested_transform for
1091        // the first error's rule_tag. Multiple errors per op are
1092        // possible — when they fire in lockstep (e.g. one bad let
1093        // binding propagates to several use sites), the first
1094        // error's rule_tag is usually the load-bearing one to fix.
1095        let suggested_transform = errors
1096            .first()
1097            .and_then(|e| lex_types::suggested_transform_for(e.rule_tag()));
1098        let log = self.attestation_log()?;
1099        for stage_id in stage_ids {
1100            let attestation = lex_vcs::Attestation::new(
1101                stage_id.clone(),
1102                None,  // the failed op was never persisted; not the
1103                       // attestation's op_id (which is for a
1104                       // *successful* op).
1105                None,
1106                lex_vcs::AttestationKind::RepairHint {
1107                    failed_op_id: failed_op_id.clone(),
1108                    errors: errors_json.clone(),
1109                    suggested_transform: suggested_transform.clone(),
1110                },
1111                lex_vcs::AttestationResult::Failed {
1112                    detail: format!("op {} rejected: {} type error(s)",
1113                        failed_op_id, errors.len()),
1114                },
1115                repair_hint_producer(),
1116                None,
1117            );
1118            log.put(&attestation)?;
1119        }
1120        Ok(())
1121    }
1122
1123    /// Emit `Trace` attestations linking an already-committed `op`
1124    /// to the run that produced it (#257). One attestation per
1125    /// produced stage (matching the `TypeCheck` emission contract
1126    /// — see [`Self::apply_operation_checked`]) with
1127    /// `op_id: Some(op_id)` set, so `lex trace --op <op_id>`
1128    /// surfaces the run.
1129    ///
1130    /// Returns the number of attestations emitted (zero for ops
1131    /// that produce no attestable stage, e.g. `Remove` /
1132    /// `ImportOnly`).
1133    ///
1134    /// Idempotent: re-emitting for the same
1135    /// `(run_id, root_target, op_id, stage_id, producer, result)`
1136    /// tuple dedups via content addressing.
1137    ///
1138    /// `op_id` must already exist in the op log — an unknown op
1139    /// surfaces as `StoreError::UnknownOp`.
1140    pub fn record_op_trace(
1141        &self,
1142        run_id: &str,
1143        root_target: &str,
1144        op_id: &lex_vcs::OpId,
1145        result: lex_vcs::AttestationResult,
1146        producer: lex_vcs::ProducerDescriptor,
1147    ) -> Result<usize, StoreError> {
1148        let log = lex_vcs::OpLog::open(self.root())?;
1149        let rec = log.get(op_id)?
1150            .ok_or_else(|| StoreError::UnknownOp(op_id.clone()))?;
1151        let stage_ids = attestable_stage_ids(&rec.produces);
1152        if stage_ids.is_empty() {
1153            return Ok(0);
1154        }
1155        let attlog = self.attestation_log()?;
1156        let mut emitted = 0;
1157        for stage_id in stage_ids {
1158            let attestation = lex_vcs::Attestation::new(
1159                stage_id,
1160                Some(op_id.clone()),
1161                None,
1162                lex_vcs::AttestationKind::Trace {
1163                    run_id: run_id.into(),
1164                    root_target: root_target.into(),
1165                },
1166                result.clone(),
1167                producer.clone(),
1168                None,
1169            );
1170            attlog.put(&attestation)?;
1171            emitted += 1;
1172        }
1173        Ok(emitted)
1174    }
1175
1176    /// Walk `ops_since(branch_head, base)` and emit per-stage
1177    /// `Trace` attestations for each new op, linking them to the
1178    /// run that produced them (#257). Used by `lex run --trace`
1179    /// after the VM exits: snapshot `base = branch_head` before
1180    /// the run, then call this with the post-run head.
1181    ///
1182    /// `base = None` means "every op currently reachable from the
1183    /// branch head" — generally not what you want for a single
1184    /// run; pass the pre-run head.
1185    ///
1186    /// Returns the total number of attestations emitted across
1187    /// every new op. Zero is the common case (the run committed no
1188    /// ops).
1189    ///
1190    /// Idempotent on the per-op level via [`Self::record_op_trace`].
1191    pub fn record_run_committed_ops_since(
1192        &self,
1193        run_id: &str,
1194        root_target: &str,
1195        branch: &str,
1196        base: Option<&lex_vcs::OpId>,
1197        result: lex_vcs::AttestationResult,
1198        producer: lex_vcs::ProducerDescriptor,
1199    ) -> Result<usize, StoreError> {
1200        let head = match self.get_branch(branch)?.and_then(|b| b.head_op) {
1201            Some(h) => h,
1202            None => return Ok(0),
1203        };
1204        let log = lex_vcs::OpLog::open(self.root())?;
1205        let new_ops = log.ops_since(&head, base)?;
1206        let mut total = 0;
1207        for rec in new_ops {
1208            total += self.record_op_trace(
1209                run_id, root_target, &rec.op_id,
1210                result.clone(), producer.clone(),
1211            )?;
1212        }
1213        Ok(total)
1214    }
1215
1216    /// Apply a typed `ReplaceMatchArm` transform (#280) and emit a
1217    /// `OperationKind::ReplaceMatchArm` op that records the
1218    /// semantic shape of the edit, not just the byte effect.
1219    ///
1220    /// Steps:
1221    ///   1. Load the source stage's canonical bytes (delta-aware).
1222    ///   2. Run [`lex_ast::replace_match_arm`] to produce the new
1223    ///      `Stage`. Pure function, no I/O.
1224    ///   3. Publish the new stage. Idempotent on the
1225    ///      content-addressed `to_stage_id`.
1226    ///   4. Assemble the candidate program (every active stage on
1227    ///      the branch, with the rewritten one swapped in) and call
1228    ///      [`Self::apply_operation_checked`] — re-typechecks and
1229    ///      runs every existing gate (TypeCheck attestation,
1230    ///      required_attestations, producer-block walk-back).
1231    ///
1232    /// Failure modes:
1233    ///   * [`StoreError::TransformError`] — transform didn't apply.
1234    ///     The branch is unchanged; no stage published.
1235    ///   * [`StoreError::TypeError`] — transform produced an
1236    ///     ill-typed program. The new stage is on disk (idempotent
1237    ///     on its content hash) but the branch is unchanged. Same
1238    ///     "publish without advance" semantics as #245.
1239    ///   * Everything else from `apply_operation_checked`.
1240    pub fn apply_replace_match_arm(
1241        &self,
1242        branch: &str,
1243        from_stage_id: &str,
1244        match_node: &lex_ast::NodeId,
1245        arm_index: usize,
1246        new_body: lex_ast::CExpr,
1247    ) -> Result<lex_vcs::OpId, StoreError> {
1248        let from_stage = self.get_ast(from_stage_id)?;
1249        let new_stage = lex_ast::replace_match_arm(
1250            &from_stage, match_node, arm_index, new_body,
1251        ).map_err(StoreError::TransformError)?;
1252        let sig = lex_ast::sig_id(&from_stage)
1253            .ok_or(StoreError::CannotPublishImport)?;
1254        let to_stage_id = self.publish(&new_stage)?;
1255        if to_stage_id == from_stage_id {
1256            // No-op transform — the new body was structurally
1257            // identical to the old. Refuse rather than advancing
1258            // the branch with an empty edit.
1259            return Err(StoreError::InvalidTransition(format!(
1260                "replace_match_arm produced the same stage_id `{from_stage_id}`"
1261            )));
1262        }
1263
1264        // Assemble the candidate program: every active stage on
1265        // the branch, with `from_stage_id` swapped for `new_stage`.
1266        let head = self.branch_head(branch)?;
1267        let mut candidate: Vec<lex_ast::Stage> = Vec::with_capacity(head.len());
1268        for (other_sig, other_stage_id) in &head {
1269            if other_sig == &sig {
1270                candidate.push(new_stage.clone());
1271            } else {
1272                candidate.push(self.get_ast(other_stage_id)?);
1273            }
1274        }
1275        // If the source sig isn't on the current branch head, the
1276        // transform is operating on a stage that hasn't been added
1277        // yet — refuse rather than risking a candidate program
1278        // that doesn't reflect the branch's actual state.
1279        if !head.contains_key(&sig) {
1280            return Err(StoreError::InvalidTransition(format!(
1281                "sig `{sig}` not on branch `{branch}`'s head"
1282            )));
1283        }
1284
1285        // #247: budget delta captured for `lex op log --budget-drift`.
1286        let from_budget = budget_of_stage(&from_stage);
1287        let to_budget = budget_of_stage(&new_stage);
1288
1289        let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
1290        let kind = lex_vcs::OperationKind::ReplaceMatchArm {
1291            sig_id: sig.clone(),
1292            from_stage_id: from_stage_id.to_string(),
1293            to_stage_id: to_stage_id.clone(),
1294            match_node: match_node.as_str().to_string(),
1295            arm_index,
1296            from_budget,
1297            to_budget,
1298        };
1299        let transition = lex_vcs::StageTransition::Replace {
1300            sig_id: sig.clone(),
1301            from: from_stage_id.to_string(),
1302            to: to_stage_id.clone(),
1303        };
1304        let op = lex_vcs::Operation::new(
1305            kind,
1306            head_now.into_iter().collect::<Vec<_>>(),
1307        );
1308        self.apply_operation_checked(branch, op, transition, &candidate)
1309    }
1310
1311    /// Apply a typed `RenameLocal` transform (#280) — rename a
1312    /// `let`-bound local within a fn body and emit a matching
1313    /// `OperationKind::RenameLocal`. Same end-to-end shape as
1314    /// [`Self::apply_replace_match_arm`]; see that method for the
1315    /// failure-mode taxonomy.
1316    pub fn apply_rename_local(
1317        &self,
1318        branch: &str,
1319        from_stage_id: &str,
1320        let_node: &lex_ast::NodeId,
1321        new_name: &str,
1322    ) -> Result<lex_vcs::OpId, StoreError> {
1323        let from_stage = self.get_ast(from_stage_id)?;
1324        // Read the old name before running the transform, so the
1325        // op log records the rename target rather than just the
1326        // new value.
1327        let old_name = read_let_name(&from_stage, let_node)
1328            .map_err(StoreError::TransformError)?;
1329        let new_stage = lex_ast::rename_local(&from_stage, let_node, new_name)
1330            .map_err(StoreError::TransformError)?;
1331        let sig = lex_ast::sig_id(&from_stage)
1332            .ok_or(StoreError::CannotPublishImport)?;
1333        let to_stage_id = self.publish(&new_stage)?;
1334        if to_stage_id == from_stage_id {
1335            return Err(StoreError::InvalidTransition(format!(
1336                "rename_local produced the same stage_id `{from_stage_id}`"
1337            )));
1338        }
1339        let head = self.branch_head(branch)?;
1340        let mut candidate: Vec<lex_ast::Stage> = Vec::with_capacity(head.len());
1341        for (other_sig, other_stage_id) in &head {
1342            if other_sig == &sig {
1343                candidate.push(new_stage.clone());
1344            } else {
1345                candidate.push(self.get_ast(other_stage_id)?);
1346            }
1347        }
1348        if !head.contains_key(&sig) {
1349            return Err(StoreError::InvalidTransition(format!(
1350                "sig `{sig}` not on branch `{branch}`'s head"
1351            )));
1352        }
1353        let from_budget = budget_of_stage(&from_stage);
1354        let to_budget = budget_of_stage(&new_stage);
1355        let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
1356        let kind = lex_vcs::OperationKind::RenameLocal {
1357            sig_id: sig.clone(),
1358            from_stage_id: from_stage_id.to_string(),
1359            to_stage_id: to_stage_id.clone(),
1360            let_node: let_node.as_str().to_string(),
1361            old_name,
1362            new_name: new_name.to_string(),
1363            from_budget,
1364            to_budget,
1365        };
1366        let transition = lex_vcs::StageTransition::Replace {
1367            sig_id: sig.clone(),
1368            from: from_stage_id.to_string(),
1369            to: to_stage_id.clone(),
1370        };
1371        let op = lex_vcs::Operation::new(
1372            kind,
1373            head_now.into_iter().collect::<Vec<_>>(),
1374        );
1375        self.apply_operation_checked(branch, op, transition, &candidate)
1376    }
1377
1378    /// Apply a typed `InlineLet` transform (#280) — eliminate a
1379    /// `let x := v; body` by substituting `v` for every unshadowed
1380    /// `x` in `body`, then replacing the `Let` node with the
1381    /// substituted body. Same end-to-end shape as
1382    /// [`Self::apply_replace_match_arm`].
1383    pub fn apply_inline_let(
1384        &self,
1385        branch: &str,
1386        from_stage_id: &str,
1387        let_node: &lex_ast::NodeId,
1388    ) -> Result<lex_vcs::OpId, StoreError> {
1389        let from_stage = self.get_ast(from_stage_id)?;
1390        let binding_name = read_let_name(&from_stage, let_node)
1391            .map_err(StoreError::TransformError)?;
1392        let new_stage = lex_ast::inline_let(&from_stage, let_node)
1393            .map_err(StoreError::TransformError)?;
1394        let sig = lex_ast::sig_id(&from_stage)
1395            .ok_or(StoreError::CannotPublishImport)?;
1396        let to_stage_id = self.publish(&new_stage)?;
1397        if to_stage_id == from_stage_id {
1398            return Err(StoreError::InvalidTransition(format!(
1399                "inline_let produced the same stage_id `{from_stage_id}`"
1400            )));
1401        }
1402        let head = self.branch_head(branch)?;
1403        let mut candidate: Vec<lex_ast::Stage> = Vec::with_capacity(head.len());
1404        for (other_sig, other_stage_id) in &head {
1405            if other_sig == &sig {
1406                candidate.push(new_stage.clone());
1407            } else {
1408                candidate.push(self.get_ast(other_stage_id)?);
1409            }
1410        }
1411        if !head.contains_key(&sig) {
1412            return Err(StoreError::InvalidTransition(format!(
1413                "sig `{sig}` not on branch `{branch}`'s head"
1414            )));
1415        }
1416        let from_budget = budget_of_stage(&from_stage);
1417        let to_budget = budget_of_stage(&new_stage);
1418        let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
1419        let kind = lex_vcs::OperationKind::InlineLet {
1420            sig_id: sig.clone(),
1421            from_stage_id: from_stage_id.to_string(),
1422            to_stage_id: to_stage_id.clone(),
1423            let_node: let_node.as_str().to_string(),
1424            binding_name,
1425            from_budget,
1426            to_budget,
1427        };
1428        let transition = lex_vcs::StageTransition::Replace {
1429            sig_id: sig.clone(),
1430            from: from_stage_id.to_string(),
1431            to: to_stage_id.clone(),
1432        };
1433        let op = lex_vcs::Operation::new(
1434            kind,
1435            head_now.into_iter().collect::<Vec<_>>(),
1436        );
1437        self.apply_operation_checked(branch, op, transition, &candidate)
1438    }
1439
1440    /// Apply a typed `ExtractFunction` transform (#280 slice 4) —
1441    /// extract a sub-expression of `from_stage_id`'s body into a
1442    /// new top-level fn defined by `spec`, and emit two ops tied
1443    /// together by a shared synthetic Intent so `lex op log
1444    /// --intent <id>` groups them.
1445    ///
1446    /// The two ops:
1447    ///   1. `AddFunction { sig_id: <new_fn_sig>, stage_id: <new_fn_stage> }`
1448    ///   2. `ModifyBody { sig_id: <source_sig>, from_stage_id, to_stage_id: <modified> }`
1449    ///
1450    /// The shared Intent's prompt is structured (`extract_function:
1451    /// <new_fn_name>` plus the source identity) so downstream
1452    /// tooling can recover the typed-transform shape from the
1453    /// op-log + intent-log join.
1454    ///
1455    /// Returns `(add_fn_op_id, modify_body_op_id)`.
1456    pub fn apply_extract_function(
1457        &self,
1458        branch: &str,
1459        from_stage_id: &str,
1460        expr_node: &lex_ast::NodeId,
1461        spec: lex_ast::ExtractFnSpec,
1462    ) -> Result<(lex_vcs::OpId, lex_vcs::OpId), StoreError> {
1463        let from_stage = self.get_ast(from_stage_id)?;
1464        let new_fn_name = spec.name.clone();
1465        let (modified_stage, new_fn_stage) =
1466            lex_ast::extract_function(&from_stage, expr_node, spec)
1467                .map_err(StoreError::TransformError)?;
1468
1469        let source_sig = lex_ast::sig_id(&from_stage)
1470            .ok_or(StoreError::CannotPublishImport)?;
1471        let new_fn_sig = lex_ast::sig_id(&new_fn_stage)
1472            .ok_or(StoreError::CannotPublishImport)?;
1473        if source_sig == new_fn_sig {
1474            return Err(StoreError::InvalidTransition(format!(
1475                "extract_function produced a sig matching the source `{source_sig}`"
1476            )));
1477        }
1478        let new_fn_stage_id = self.publish(&new_fn_stage)?;
1479        let modified_stage_id = self.publish(&modified_stage)?;
1480        if modified_stage_id == from_stage_id {
1481            return Err(StoreError::InvalidTransition(format!(
1482                "extract_function produced the same stage_id `{from_stage_id}` for the source"
1483            )));
1484        }
1485
1486        let head = self.branch_head(branch)?;
1487        if !head.contains_key(&source_sig) {
1488            return Err(StoreError::InvalidTransition(format!(
1489                "sig `{source_sig}` not on branch `{branch}`'s head"
1490            )));
1491        }
1492
1493        // Synthesize an Intent linking the two ops. The session_id
1494        // / model fields here are not load-bearing — they exist to
1495        // make the IntentId content-addressed; downstream tooling
1496        // reads `prompt` to reconstruct the typed-transform shape.
1497        let intent = lex_vcs::Intent::new(
1498            format!(
1499                "[lex.transform.extract_function]\nnew_fn={new_fn_name}\nsource_sig={source_sig}\nfrom_stage={from_stage_id}\nexpr_node={node}",
1500                node = expr_node.as_str(),
1501            ),
1502            "lex-store::apply_extract_function",
1503            lex_vcs::ModelDescriptor {
1504                provider: "lex-store".into(),
1505                name: env!("CARGO_PKG_VERSION").into(),
1506                version: None,
1507            },
1508            None,
1509        );
1510        let intent_id = intent.intent_id.clone();
1511        lex_vcs::IntentLog::open(self.root())?.put(&intent)?;
1512
1513        // Step 1 — emit the AddFunction op for the new fn. Build
1514        // the candidate program by appending the new fn to every
1515        // stage on the current branch head.
1516        let new_fn_effects: std::collections::BTreeSet<String> = match &new_fn_stage {
1517            lex_ast::Stage::FnDecl(fd) => fd.effects.iter()
1518                .map(|e| e.name.clone()).collect(),
1519            _ => Default::default(),
1520        };
1521        let new_fn_budget = budget_of_stage(&new_fn_stage);
1522        let mut candidate_with_new_fn: Vec<lex_ast::Stage> =
1523            Vec::with_capacity(head.len() + 1);
1524        for stage_id in head.values() {
1525            candidate_with_new_fn.push(self.get_ast(stage_id)?);
1526        }
1527        candidate_with_new_fn.push(new_fn_stage.clone());
1528        let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
1529        let add_op = lex_vcs::Operation::new(
1530            lex_vcs::OperationKind::AddFunction {
1531                sig_id: new_fn_sig.clone(),
1532                stage_id: new_fn_stage_id.clone(),
1533                effects: new_fn_effects,
1534                budget_cost: new_fn_budget,
1535            },
1536            head_now.into_iter().collect::<Vec<_>>(),
1537        ).with_intent(intent_id.clone());
1538        let add_transition = lex_vcs::StageTransition::Create {
1539            sig_id: new_fn_sig.clone(),
1540            stage_id: new_fn_stage_id.clone(),
1541        };
1542        let add_op_id = self.apply_operation_checked(
1543            branch, add_op, add_transition, &candidate_with_new_fn,
1544        )?;
1545
1546        // Step 2 — emit the ModifyBody op for the source. Build
1547        // the candidate program by replacing the source's stage
1548        // with `modified_stage` and keeping the new fn alongside.
1549        let from_budget = budget_of_stage(&from_stage);
1550        let to_budget = budget_of_stage(&modified_stage);
1551        let mut candidate_with_modified: Vec<lex_ast::Stage> =
1552            Vec::with_capacity(head.len() + 1);
1553        for (other_sig, other_stage_id) in &head {
1554            if other_sig == &source_sig {
1555                candidate_with_modified.push(modified_stage.clone());
1556            } else {
1557                candidate_with_modified.push(self.get_ast(other_stage_id)?);
1558            }
1559        }
1560        candidate_with_modified.push(new_fn_stage.clone());
1561        let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
1562        let modify_op = lex_vcs::Operation::new(
1563            lex_vcs::OperationKind::ModifyBody {
1564                sig_id: source_sig.clone(),
1565                from_stage_id: from_stage_id.to_string(),
1566                to_stage_id: modified_stage_id.clone(),
1567                from_budget,
1568                to_budget,
1569            },
1570            head_now.into_iter().collect::<Vec<_>>(),
1571        ).with_intent(intent_id);
1572        let modify_transition = lex_vcs::StageTransition::Replace {
1573            sig_id: source_sig,
1574            from: from_stage_id.to_string(),
1575            to: modified_stage_id,
1576        };
1577        let modify_op_id = self.apply_operation_checked(
1578            branch, modify_op, modify_transition, &candidate_with_modified,
1579        )?;
1580
1581        Ok((add_op_id, modify_op_id))
1582    }
1583
1584    /// Propose a stage for `sig_id` without advancing the branch
1585    /// head (#294). Multiple agents can call this concurrently
1586    /// for the same sig — every call lands a fresh `Candidate`
1587    /// op chained off the current head_op. The branch head stays
1588    /// where it was; a later [`Self::promote_candidate`] picks
1589    /// the winner.
1590    ///
1591    /// The caller is responsible for typechecking `new_stage`
1592    /// against whatever program context they consider valid —
1593    /// `propose_candidate` doesn't run the gate. Type errors
1594    /// surface at promotion time, where the candidate is
1595    /// composed back into a candidate program via the standard
1596    /// `apply_operation_checked` path.
1597    ///
1598    /// The stage is published (idempotent on content hash). The
1599    /// `intent_id` is required so downstream consumers can
1600    /// distinguish proposals by author.
1601    pub fn propose_candidate(
1602        &self,
1603        branch: &str,
1604        new_stage: &lex_ast::Stage,
1605        intent_id: &lex_vcs::IntentId,
1606    ) -> Result<lex_vcs::OpId, StoreError> {
1607        let sig = lex_ast::sig_id(new_stage)
1608            .ok_or(StoreError::CannotPublishImport)?;
1609        let stage_id = self.publish(new_stage)?;
1610        let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
1611        let op = lex_vcs::Operation::new(
1612            lex_vcs::OperationKind::Candidate {
1613                sig_id: sig,
1614                stage_id,
1615            },
1616            head_now.into_iter().collect::<Vec<_>>(),
1617        ).with_intent(intent_id.clone());
1618        let transition = lex_vcs::StageTransition::ImportOnly;
1619        self.apply_operation(branch, op, transition)
1620    }
1621
1622    /// List every live `Candidate` op for `sig_id` — i.e. those
1623    /// not yet referenced by any `Promote` op (either as the
1624    /// winner or in the `supersedes` set). Used by `lex stage
1625    /// candidates`. Results are sorted by op_id for
1626    /// reproducibility.
1627    pub fn list_candidates(&self, sig_id: &str) -> Result<Vec<CandidateInfo>, StoreError> {
1628        let log = lex_vcs::OpLog::open(self.root())?;
1629        let all = log.list_all()?;
1630        // Collect the set of candidate op_ids referenced by any
1631        // Promote for this sig. Those candidates are no longer
1632        // live.
1633        let mut referenced: std::collections::BTreeSet<lex_vcs::OpId> = Default::default();
1634        for rec in &all {
1635            if let lex_vcs::OperationKind::Promote { sig_id: s, winner_candidate, supersedes, .. } = &rec.op.kind {
1636                if s != sig_id { continue; }
1637                referenced.insert(winner_candidate.clone());
1638                for sup in supersedes { referenced.insert(sup.clone()); }
1639            }
1640        }
1641        let mut out: Vec<CandidateInfo> = Vec::new();
1642        for rec in all {
1643            let lex_vcs::OperationKind::Candidate { sig_id: s, stage_id } = &rec.op.kind
1644                else { continue };
1645            if s != sig_id { continue; }
1646            if referenced.contains(&rec.op_id) { continue; }
1647            out.push(CandidateInfo {
1648                op_id: rec.op_id.clone(),
1649                stage_id: stage_id.clone(),
1650                intent_id: rec.op.intent_id.clone(),
1651            });
1652        }
1653        out.sort_by(|a, b| a.op_id.cmp(&b.op_id));
1654        Ok(out)
1655    }
1656
1657    /// Promote a previously-landed `Candidate` op as the new
1658    /// branch head for its sig (#294). Emits a `Promote` op
1659    /// listing every other live `Candidate` for the same sig
1660    /// in its `supersedes` field. After this lands,
1661    /// [`Self::list_candidates`] returns an empty set for the
1662    /// sig.
1663    ///
1664    /// Re-typechecks the candidate program (winner stage + the
1665    /// rest of the branch) through `apply_operation_checked`, so
1666    /// a candidate that doesn't compose with the current branch
1667    /// state surfaces as `StoreError::TypeError`.
1668    pub fn promote_candidate(
1669        &self,
1670        branch: &str,
1671        candidate_op_id: &lex_vcs::OpId,
1672    ) -> Result<lex_vcs::OpId, StoreError> {
1673        let log = lex_vcs::OpLog::open(self.root())?;
1674        let candidate_rec = log.get(candidate_op_id)?
1675            .ok_or_else(|| StoreError::UnknownOp(candidate_op_id.clone()))?;
1676        let (sig, winner_stage_id) = match &candidate_rec.op.kind {
1677            lex_vcs::OperationKind::Candidate { sig_id, stage_id } =>
1678                (sig_id.clone(), stage_id.clone()),
1679            other => return Err(StoreError::InvalidTransition(format!(
1680                "op `{candidate_op_id}` is a `{:?}`, not a Candidate", other
1681            ))),
1682        };
1683
1684        // Gather every OTHER live candidate for this sig — the
1685        // ones this Promote will supersede.
1686        let live = self.list_candidates(&sig)?;
1687        let mut supersedes: Vec<lex_vcs::OpId> = live.iter()
1688            .filter(|c| &c.op_id != candidate_op_id)
1689            .map(|c| c.op_id.clone())
1690            .collect();
1691        supersedes.sort();
1692
1693        // Assemble candidate program: winner stage in place of
1694        // the sig's current head (if any), plus every other sig
1695        // unchanged.
1696        let head = self.branch_head(branch)?;
1697        let winner_stage = self.get_ast(&winner_stage_id)?;
1698        let mut candidate_program: Vec<lex_ast::Stage> = Vec::with_capacity(head.len() + 1);
1699        let mut found = false;
1700        for (other_sig, other_stage_id) in &head {
1701            if other_sig == &sig {
1702                candidate_program.push(winner_stage.clone());
1703                found = true;
1704            } else {
1705                candidate_program.push(self.get_ast(other_stage_id)?);
1706            }
1707        }
1708        if !found {
1709            // Sig doesn't have a head yet — append the winner
1710            // stage to make it a Create.
1711            candidate_program.push(winner_stage.clone());
1712        }
1713        let from_stage_id = head.get(&sig).cloned();
1714        // Budget delta from old head to winner — same shape as
1715        // ModifyBody.
1716        let from_budget = from_stage_id.as_deref()
1717            .and_then(|s| self.get_ast(s).ok())
1718            .and_then(|s| budget_of_stage(&s));
1719        let to_budget = budget_of_stage(&winner_stage);
1720
1721        let head_now = self.get_branch(branch)?.and_then(|b| b.head_op);
1722        let op = lex_vcs::Operation::new(
1723            lex_vcs::OperationKind::Promote {
1724                sig_id: sig.clone(),
1725                winner_candidate: candidate_op_id.clone(),
1726                winner_stage_id: winner_stage_id.clone(),
1727                supersedes,
1728                from_stage_id: from_stage_id.clone(),
1729                from_budget,
1730                to_budget,
1731            },
1732            head_now.into_iter().collect::<Vec<_>>(),
1733        );
1734        let transition = match &from_stage_id {
1735            Some(from) => lex_vcs::StageTransition::Replace {
1736                sig_id: sig,
1737                from: from.clone(),
1738                to: winner_stage_id,
1739            },
1740            None => lex_vcs::StageTransition::Create {
1741                sig_id: sig,
1742                stage_id: winner_stage_id,
1743            },
1744        };
1745        self.apply_operation_checked(branch, op, transition, &candidate_program)
1746    }
1747
1748    /// `set_branch_head_op` for the durability story on the branch
1749    /// file itself.
1750    pub fn apply_operation(
1751        &self,
1752        branch: &str,
1753        op: lex_vcs::Operation,
1754        transition: lex_vcs::StageTransition,
1755    ) -> Result<lex_vcs::OpId, StoreError> {
1756        let attestable = attestable_stage_ids(&transition);
1757        let op_effects = op_declared_effects(&op.kind);
1758        self.cas_retry_advance(branch, op, transition, |new_head| {
1759            self.run_required_attestations_gate(
1760                branch, &new_head.op_id, &attestable, &op_effects,
1761            )
1762        })
1763    }
1764
1765    /// CAS retry loop for #262. Single-parent ops are rebuilt on
1766    /// each iteration with the current branch head as parent;
1767    /// the per-iteration callback runs the gate (and TypeCheck
1768    /// emission, for the checked path) between persist and CAS.
1769    /// Merge ops (with 2 parents already set) skip the rebuild —
1770    /// their parents are caller-supplied and meaningful — and get
1771    /// a single attempt; on CAS failure they surface `Contention`.
1772    fn cas_retry_advance<F>(
1773        &self,
1774        branch: &str,
1775        op: lex_vcs::Operation,
1776        transition: lex_vcs::StageTransition,
1777        mut between_persist_and_cas: F,
1778    ) -> Result<lex_vcs::OpId, StoreError>
1779    where
1780        F: FnMut(&lex_vcs::NewHead) -> Result<(), StoreError>,
1781    {
1782        // 32 retries handles up to ~32 concurrent writers racing on
1783        // the same branch tip. Beyond that, surfacing `Contention`
1784        // is the right signal — clients should back off or batch.
1785        const MAX_ATTEMPTS: u32 = 32;
1786        // Single-parent ops can be rebuilt on retry; merge ops
1787        // can't (their two parents are meaningful, supplied by the
1788        // merge engine). For merges, single attempt: if CAS
1789        // fails, surface Contention.
1790        let is_rebuildable = op.parents.len() <= 1;
1791        let kind = op.kind.clone();
1792        let intent_id = op.intent_id.clone();
1793
1794        let mut last_io_err: Option<StoreError> = None;
1795        let mut current_op = op;
1796        let current_transition = transition;
1797        // Only rebuild on retries — attempt 1 honors the caller's
1798        // exact op so a user-supplied bogus parent (parents =
1799        // ["someone-else"]) surfaces as `StaleParent` instead of
1800        // being silently corrected.
1801        //
1802        // Exception (#262 follow-up): an op with `parents = []`
1803        // means "I don't care; chain off whatever the current
1804        // head is." Under concurrent apply, attempt 1 can read
1805        // `head_op = Some(opA)` after a sibling writer landed,
1806        // and the persist's parent check fails StaleParent
1807        // unprompted. Rebuild attempt 1 for the empty-parents
1808        // case so the legitimate-race path retries cleanly.
1809        let mut rebuilt_already = false;
1810        for attempt in 1..=MAX_ATTEMPTS {
1811            // Read the current head BEFORE we persist — this is
1812            // the value we'll compare against in the CAS.
1813            let parent = self
1814                .get_branch(branch)?
1815                .and_then(|b| b.head_op);
1816
1817            // Rebuild the op against the current head, but only
1818            // on retries (not the caller's first attempt) and
1819            // only for single-parent operations. Multi-parent
1820            // (merge) ops are passed through unchanged.
1821            //
1822            // Empty-parents ops also rebuild on attempt 1 (see
1823            // the exception note above) so concurrent apply
1824            // doesn't false-positive on StaleParent.
1825            let should_rebuild = is_rebuildable
1826                && (rebuilt_already
1827                    || (current_op.parents.is_empty() && parent.is_some()));
1828            if should_rebuild {
1829                current_op = lex_vcs::Operation {
1830                    kind: kind.clone(),
1831                    parents: parent.iter().cloned().collect(),
1832                    intent_id: intent_id.clone(),
1833                };
1834            }
1835
1836            // Persist (idempotent). On `StaleParent` from a retry
1837            // attempt (where we already rebuilt), the head changed
1838            // between our `get_branch` and this `lex_vcs::apply`
1839            // — race; rebuild and continue. On `StaleParent` from
1840            // attempt 1 (caller's input), propagate.
1841            let new_head = match self.persist_op_only_with_parent(
1842                branch,
1843                parent.as_ref(),
1844                current_op.clone(),
1845                current_transition.clone(),
1846            ) {
1847                Ok(nh) => nh,
1848                Err(StoreError::Apply(lex_vcs::ApplyError::StaleParent { .. }))
1849                    if is_rebuildable && rebuilt_already =>
1850                {
1851                    rebuilt_already = true;
1852                    continue;
1853                }
1854                Err(e) => return Err(e),
1855            };
1856
1857            // Run the caller's between-persist-and-cas hook
1858            // (TypeCheck emission + gate). If this fails, the op
1859            // record is durable but orphaned — same semantics as
1860            // pre-#262.
1861            between_persist_and_cas(&new_head)?;
1862
1863            // CAS the branch head. On success: done. On mismatch:
1864            // someone advanced in parallel; retry.
1865            match self.set_branch_head_op_cas(branch, parent, new_head.op_id.clone()) {
1866                Ok(()) => return Ok(new_head.op_id),
1867                Err(crate::branches::CasFailed::Mismatch { .. }) if is_rebuildable => {
1868                    // Try again with the new head as parent.
1869                    rebuilt_already = true;
1870                    continue;
1871                }
1872                Err(crate::branches::CasFailed::Mismatch { .. }) => {
1873                    // Merge op: surface immediately — we can't
1874                    // rebuild without rerunning the merge engine.
1875                    let _ = attempt;
1876                    return Err(StoreError::Contention {
1877                        branch: branch.into(),
1878                        attempts: 1,
1879                    });
1880                }
1881                Err(crate::branches::CasFailed::UnknownBranch(b)) => {
1882                    return Err(StoreError::UnknownBranch(b));
1883                }
1884                Err(crate::branches::CasFailed::Io(e)) => {
1885                    last_io_err = Some(StoreError::Io(std::io::Error::other(e)));
1886                    continue;
1887                }
1888            }
1889        }
1890        // Retries exhausted. Prefer surfacing the most recent IO
1891        // error if we hit one; otherwise it's pure CAS contention.
1892        match last_io_err {
1893            Some(e) => Err(e),
1894            None => Err(StoreError::Contention {
1895                branch: branch.into(),
1896                attempts: MAX_ATTEMPTS,
1897            }),
1898        }
1899    }
1900
1901    /// Persist an op against an explicitly-supplied parent. Used
1902    /// by the CAS retry loop in `cas_retry_advance` so the
1903    /// `lex_vcs::apply` parent check matches what we read at the
1904    /// top of the loop iteration (avoids a TOCTOU race against
1905    /// `persist_op_only`'s second read).
1906    fn persist_op_only_with_parent(
1907        &self,
1908        branch: &str,
1909        parent: Option<&lex_vcs::OpId>,
1910        op: lex_vcs::Operation,
1911        transition: lex_vcs::StageTransition,
1912    ) -> Result<lex_vcs::NewHead, StoreError> {
1913        if branch != DEFAULT_BRANCH && self.get_branch(branch)?.is_none() {
1914            return Err(StoreError::UnknownBranch(branch.into()));
1915        }
1916        let log = lex_vcs::OpLog::open(self.root())?;
1917        lex_vcs::apply(&log, parent, op, transition).map_err(|e| match e {
1918            lex_vcs::ApplyError::Persist(io) => StoreError::Io(io),
1919            other => StoreError::Apply(other),
1920        })
1921    }
1922
1923
1924    /// Run the `required_attestations` gate (#245) and the
1925    /// retroactive producer-block gate (#248) over a single op
1926    /// against the store's `policy.json` and attestation log.
1927    ///
1928    /// Failure modes (in order):
1929    ///
1930    /// 1. Producer-block first: if any attestation on the op's
1931    ///    stage is from a quarantined tool, refuse with
1932    ///    `ProducerBlocked` (#248). Surfaces *before* the
1933    ///    required-attestations gate so a clearly-malicious record
1934    ///    isn't masked by a missing-Spec error.
1935    /// 2. Required-attestations next: if any required attestation
1936    ///    kind is missing, refuse with `BranchAdvanceBlocked`
1937    ///    (#245).
1938    ///
1939    /// Loads the policy / attestation log lazily; with no policy
1940    /// file and no `ProducerBlock` attestations the gate is a no-op
1941    /// (default-permissive — matches pre-#245 stores).
1942    fn run_required_attestations_gate(
1943        &self,
1944        branch: &str,
1945        op_id: &lex_vcs::OpId,
1946        stage_ids: &[String],
1947        op_effects: &std::collections::BTreeSet<String>,
1948    ) -> Result<(), StoreError> {
1949        // Build the candidate slice for the new op. Ops with no
1950        // attestable stage (imports, empty merges) get a single
1951        // `None`-stage tuple; both gates skip those.
1952        let new_op_candidate: Vec<(lex_vcs::OpId, Option<String>, std::collections::BTreeSet<String>)> =
1953            if stage_ids.is_empty() {
1954                vec![(op_id.clone(), None, op_effects.clone())]
1955            } else {
1956                stage_ids
1957                    .iter()
1958                    .map(|sid| (op_id.clone(), Some(sid.clone()), op_effects.clone()))
1959                    .collect()
1960            };
1961        let attest_log = self.attestation_log()?;
1962
1963        // #248 + #256: producer-block gate, walk-back style.
1964        //
1965        // The naive #248 gate only checked the new op's stage. That
1966        // missed contamination on ancestors — once `lex attest
1967        // retro-block` lands, every previously-gated op stays in
1968        // the chain even though its attestations are now from a
1969        // quarantined producer.
1970        //
1971        // #256 fixes this by walking the chain from `head_op` back
1972        // to `last_gate_checkpoint` (or genesis when the checkpoint
1973        // is invalidated), collecting each ancestor's attestable
1974        // stages, and running `check_producer_block` on the
1975        // combined set. After a successful advance,
1976        // `set_branch_head_op` moves the checkpoint to the new
1977        // head (steady-state O(new ops) per advance).
1978        let walk_back_candidate = self.collect_ancestor_candidates(branch)?;
1979        let mut producer_block_candidate = walk_back_candidate;
1980        producer_block_candidate.extend(new_op_candidate.iter().cloned());
1981        crate::policy::check_producer_block(&attest_log, &producer_block_candidate)
1982            .map_err(StoreError::ProducerBlocked)?;
1983
1984        // #245: required-attestations gate. Forward-going only —
1985        // only the new op is checked. Walking back makes no sense
1986        // here: the policy is "this advance must carry these
1987        // attestations," not "every prior op must have."
1988        let policy = match crate::policy::load(self.root())? {
1989            Some(p) if !p.required_attestations.is_empty() => p,
1990            _ => return Ok(()),
1991        };
1992        let waivers = crate::policy::check_required_attestations(
1993            &attest_log, &new_op_candidate, &policy,
1994        ).map_err(StoreError::BranchAdvanceBlocked)?;
1995        // #293: emit one `TrustWaived` attestation per waiver so
1996        // the audit trail records every skip. Idempotent on
1997        // attestation_id (content-addressed dedup) — re-running
1998        // the gate with the same state writes the same files.
1999        for w in waivers {
2000            let att = lex_vcs::Attestation::new(
2001                w.stage_id,
2002                Some(op_id.clone()),
2003                None,
2004                lex_vcs::AttestationKind::TrustWaived {
2005                    producer: w.producer,
2006                    score_thousandths: w.score_thousandths,
2007                    threshold_thousandths: w.threshold_thousandths,
2008                    kind_tag: w.kind_tag,
2009                },
2010                lex_vcs::AttestationResult::Passed,
2011                trust_waived_producer(),
2012                None,
2013            );
2014            attest_log.put(&att)?;
2015        }
2016        Ok(())
2017    }
2018
2019    /// Walk the branch from `head_op` back to `last_gate_checkpoint`
2020    /// (exclusive) and return the `(op_id, stage_id, op_effects)`
2021    /// tuples for every attestable stage touched by an ancestor
2022    /// (#256). Empty when the branch is fresh, when the checkpoint
2023    /// equals the head, or when the head is None.
2024    fn collect_ancestor_candidates(
2025        &self,
2026        branch: &str,
2027    ) -> Result<Vec<GateCandidate>, StoreError> {
2028        let b = match self.get_branch(branch)? {
2029            Some(b) => b,
2030            None => return Ok(Vec::new()),
2031        };
2032        let Some(head) = b.head_op else { return Ok(Vec::new()); };
2033        if Some(&head) == b.last_gate_checkpoint.as_ref() {
2034            // Steady-state common case: previous advance left the
2035            // checkpoint at head. Nothing to re-walk.
2036            return Ok(Vec::new());
2037        }
2038
2039        let log = lex_vcs::OpLog::open(self.root())?;
2040        let walk = log.walk_back(&head, None)?;
2041        let stop_at = b.last_gate_checkpoint.clone();
2042        let mut out = Vec::new();
2043        for rec in walk {
2044            if Some(&rec.op_id) == stop_at.as_ref() {
2045                break;
2046            }
2047            let stages = attestable_stage_ids(&rec.produces);
2048            let effects = op_declared_effects(&rec.op.kind);
2049            if stages.is_empty() {
2050                out.push((rec.op_id.clone(), None, effects));
2051            } else {
2052                for sid in stages {
2053                    out.push((rec.op_id.clone(), Some(sid), effects.clone()));
2054                }
2055            }
2056        }
2057        Ok(out)
2058    }
2059}
2060
2061fn stage_name(stage: &Stage) -> &str {
2062    match stage {
2063        Stage::FnDecl(fd) => &fd.name,
2064        Stage::TypeDecl(td) => &td.name,
2065        Stage::Import(i) => &i.alias,
2066    }
2067}
2068
2069fn stage_for_kind<'a>(
2070    kind: &lex_vcs::OperationKind,
2071    stages: &'a [lex_ast::Stage],
2072) -> Option<&'a lex_ast::Stage> {
2073    use lex_vcs::OperationKind::*;
2074    let target_sig = match kind {
2075        AddFunction { sig_id, .. } | ModifyBody { sig_id, .. }
2076        | ChangeEffectSig { sig_id, .. } | AddType { sig_id, .. }
2077        | ModifyType { sig_id, .. } => Some(sig_id.clone()),
2078        RenameSymbol { to, .. } => Some(to.clone()),
2079        _ => None,
2080    };
2081    let target_sig = target_sig?;
2082    stages.iter().find(|s| sig_id(s).as_deref() == Some(target_sig.as_str()))
2083}
2084
2085fn transition_for_kind(kind: &lex_vcs::OperationKind) -> lex_vcs::StageTransition {
2086    use lex_vcs::OperationKind::*;
2087    use lex_vcs::StageTransition;
2088    match kind {
2089        AddFunction { sig_id, stage_id, .. }
2090        | AddType { sig_id, stage_id } => StageTransition::Create {
2091            sig_id: sig_id.clone(), stage_id: stage_id.clone(),
2092        },
2093        RemoveFunction { sig_id, last_stage_id }
2094        | RemoveType { sig_id, last_stage_id } => StageTransition::Remove {
2095            sig_id: sig_id.clone(), last: last_stage_id.clone(),
2096        },
2097        ModifyBody { sig_id, from_stage_id, to_stage_id, .. }
2098        | ChangeEffectSig { sig_id, from_stage_id, to_stage_id, .. }
2099        | ModifyType { sig_id, from_stage_id, to_stage_id }
2100        | ReplaceMatchArm { sig_id, from_stage_id, to_stage_id, .. }
2101        | RenameLocal { sig_id, from_stage_id, to_stage_id, .. }
2102        | InlineLet { sig_id, from_stage_id, to_stage_id, .. } => StageTransition::Replace {
2103            sig_id: sig_id.clone(),
2104            from: from_stage_id.clone(),
2105            to:   to_stage_id.clone(),
2106        },
2107        RenameSymbol { from, to, body_stage_id } => StageTransition::Rename {
2108            from: from.clone(), to: to.clone(),
2109            body_stage_id: body_stage_id.clone(),
2110        },
2111        AddImport { .. } | RemoveImport { .. } => StageTransition::ImportOnly,
2112        Merge { .. } => StageTransition::Merge { entries: Default::default() },
2113        // #294: a Candidate proposes a stage without advancing
2114        // the branch. ImportOnly keeps the branch head untouched
2115        // — the stage IS published on disk (Store::propose_candidate
2116        // calls publish before apply), but no head delta lands.
2117        Candidate { .. } => StageTransition::ImportOnly,
2118        // A Promote advances the head exactly like ModifyBody
2119        // (or Create when the sig had no head). The winner
2120        // stage is the new branch state for that sig.
2121        Promote { sig_id, winner_stage_id, from_stage_id, .. } => match from_stage_id {
2122            Some(from) => StageTransition::Replace {
2123                sig_id: sig_id.clone(),
2124                from: from.clone(),
2125                to: winner_stage_id.clone(),
2126            },
2127            None => StageTransition::Create {
2128                sig_id: sig_id.clone(),
2129                stage_id: winner_stage_id.clone(),
2130            },
2131        },
2132    }
2133}
2134
2135/// Producer identity for TypeCheck attestations emitted by the
2136/// store-write gate. Pinned to this crate's name + version so an
2137/// attestation produced by a different `lex-store` revision is
2138/// distinguishable (content-hashed `produced_by`).
2139fn typecheck_producer() -> lex_vcs::ProducerDescriptor {
2140    lex_vcs::ProducerDescriptor {
2141        tool: "lex-store".into(),
2142        version: env!("CARGO_PKG_VERSION").into(),
2143        model: None,
2144    }
2145}
2146
2147/// Producer identity for `RepairHint` attestations emitted by
2148/// `apply_operation_checked` on TypeError (#281). Distinct tool
2149/// name from `typecheck_producer` so consumers can filter the
2150/// activity feed for repair hints without scanning kinds.
2151fn repair_hint_producer() -> lex_vcs::ProducerDescriptor {
2152    lex_vcs::ProducerDescriptor {
2153        tool: "lex-store::repair_hint".into(),
2154        version: env!("CARGO_PKG_VERSION").into(),
2155        model: None,
2156    }
2157}
2158
2159/// Producer identity for `TrustWaived` attestations emitted by
2160/// the `required_attestations` gate on a trust-driven waiver
2161/// (#293). Distinct from `typecheck_producer` and `repair_hint`
2162/// so the audit trail clearly shows "the gate let this advance
2163/// through because trust > threshold."
2164fn trust_waived_producer() -> lex_vcs::ProducerDescriptor {
2165    lex_vcs::ProducerDescriptor {
2166        tool: "lex-store::trust_waived".into(),
2167        version: env!("CARGO_PKG_VERSION").into(),
2168        model: None,
2169    }
2170}
2171
2172/// Producer identity for `ProducerTrust` attestations emitted by
2173/// [`Store::recompute_producer_trust`]. The score-derivation
2174/// recompute is its own machine-emittable kind, distinct from
2175/// the gate-side `TrustWaived` emit (#293).
2176fn producer_trust_producer() -> lex_vcs::ProducerDescriptor {
2177    lex_vcs::ProducerDescriptor {
2178        tool: "lex-store::producer_trust".into(),
2179        version: env!("CARGO_PKG_VERSION").into(),
2180        model: None,
2181    }
2182}
2183
2184/// The set of stage_ids a transition introduces. These are the
2185/// stages a successful TypeCheck pass attests *about* — the new
2186/// head produced by Create/Replace, the renamed body, or the per-
2187/// sig resolution of a Merge. Removes and ImportOnly produce no
2188/// attestable stage; the program typechecks but no specific stage
2189/// is the subject of the claim.
2190/// One row of input to the producer-block / required-attestations
2191/// gates: `(op_id, stage_id, op_effects)`. The `stage_id` is
2192/// `None` for ops that don't touch a stage (imports, empty
2193/// merges) — the gate skips those.
2194type GateCandidate = (lex_vcs::OpId, Option<String>, std::collections::BTreeSet<String>);
2195
2196/// Effect set declared *by the operation itself* (#245). Used by
2197/// the `required_attestations` gate's `EffectsIntersect` clause.
2198///
2199/// Only `AddFunction` and `ChangeEffectSig` carry an effect set in
2200/// their op payload; for everything else this returns the empty
2201/// set, which means `EffectsIntersect` rules don't fire on those
2202/// ops. `Always` rules continue to fire regardless. A future
2203/// improvement is to extract effects from the candidate `Stage`
2204/// for `ModifyBody` ops, but the typed-effects-on-ops path (#247)
2205/// is the cleaner solution and lands separately.
2206fn op_declared_effects(kind: &lex_vcs::OperationKind) -> std::collections::BTreeSet<String> {
2207    use lex_vcs::OperationKind::*;
2208    match kind {
2209        AddFunction { effects, .. } => effects.clone(),
2210        ChangeEffectSig { to_effects, .. } => to_effects.clone(),
2211        _ => std::collections::BTreeSet::new(),
2212    }
2213}
2214
2215fn attestable_stage_ids(transition: &lex_vcs::StageTransition) -> Vec<String> {
2216    use lex_vcs::StageTransition::*;
2217    match transition {
2218        Create { stage_id, .. } => vec![stage_id.clone()],
2219        Replace { to, .. } => vec![to.clone()],
2220        Rename { body_stage_id, .. } => vec![body_stage_id.clone()],
2221        Merge { entries } => entries
2222            .values()
2223            .filter_map(|opt| opt.clone())
2224            .collect(),
2225        Remove { .. } | ImportOnly => Vec::new(),
2226    }
2227}
2228
2229fn write_canonical_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
2230    let v = serde_json::to_value(value)?;
2231    let s = lex_ast::canon_json::to_canonical_string(&v);
2232    if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
2233    fs::write(path, s)?;
2234    Ok(())
2235}
2236
2237/// Read the `name` of the `Let` expression at `let_node` inside
2238/// `stage`'s body. Used by [`Store::apply_rename_local`] to record
2239/// the rename source. Returns the same `TransformError` shapes as
2240/// the transformer itself so callers see a consistent error
2241/// vocabulary.
2242fn read_let_name(
2243    stage: &Stage,
2244    let_node: &lex_ast::NodeId,
2245) -> Result<String, lex_ast::TransformError> {
2246    // The transformer is itself a pure function; ask it to perform
2247    // a rename to a sentinel value and read the resulting let's
2248    // original name from the output. Cheaper than duplicating the
2249    // node-walk here, and stays correct as the transform evolves.
2250    //
2251    // We use a sentinel that's invalid as a Lex identifier so even
2252    // if the rename somehow lands, downstream parsing would
2253    // surface it loudly. (The transform path discards the renamed
2254    // value — we only need the *original* name.)
2255    let probed = lex_ast::rename_local(stage, let_node, "__lex_rename_probe__")?;
2256    let Stage::FnDecl(fd) = probed else {
2257        return Err(lex_ast::TransformError::NonFnTarget { stage_kind: "non-FnDecl" });
2258    };
2259    // Walk back to the probed let to read its old name from the
2260    // *original* stage — the probed stage's let has already been
2261    // renamed.
2262    let Stage::FnDecl(orig_fd) = stage else {
2263        return Err(lex_ast::TransformError::NonFnTarget { stage_kind: "non-FnDecl" });
2264    };
2265    // Path-based lookup matches the transformer's navigation.
2266    let path = parse_let_node_path(let_node.as_str())?;
2267    if path.is_empty() {
2268        return Err(lex_ast::TransformError::NotALet {
2269            at: let_node.as_str().into(),
2270            found_kind: "stage_root",
2271        });
2272    }
2273    if path[0] != orig_fd.params.len() + 1 {
2274        return Err(lex_ast::TransformError::UnknownNode {
2275            at: let_node.as_str().into(),
2276        });
2277    }
2278    let inner = &path[1..];
2279    let target = navigate_to_let(&orig_fd.body, inner, let_node.as_str())?;
2280    let _ = fd; // probed stage discarded
2281    Ok(target.to_string())
2282}
2283
2284fn parse_let_node_path(id: &str) -> Result<Vec<usize>, lex_ast::TransformError> {
2285    let s = id.strip_prefix("n_")
2286        .ok_or_else(|| lex_ast::TransformError::BadNodeId(id.into()))?;
2287    let mut parts = s.split('.');
2288    let head = parts.next()
2289        .ok_or_else(|| lex_ast::TransformError::BadNodeId(id.into()))?;
2290    if head != "0" { return Err(lex_ast::TransformError::BadNodeId(id.into())); }
2291    let mut out = Vec::new();
2292    for p in parts {
2293        out.push(p.parse::<usize>()
2294            .map_err(|_| lex_ast::TransformError::BadNodeId(id.into()))?);
2295    }
2296    Ok(out)
2297}
2298
2299fn navigate_to_let<'a>(
2300    root: &'a lex_ast::CExpr,
2301    path: &[usize],
2302    at: &str,
2303) -> Result<&'a str, lex_ast::TransformError> {
2304    use lex_ast::CExpr::*;
2305    let mut current = root;
2306    for &idx in path {
2307        current = match current {
2308            Call { callee, args } => {
2309                if idx == 0 { callee } else { args.get(idx - 1)
2310                    .ok_or_else(|| lex_ast::TransformError::UnknownNode { at: at.into() })? }
2311            }
2312            Let { value, body, .. } => match idx {
2313                0 => value, 1 => body,
2314                _ => return Err(lex_ast::TransformError::UnknownNode { at: at.into() }),
2315            },
2316            Match { scrutinee, arms } => {
2317                if idx == 0 { scrutinee } else {
2318                    let arm_off = idx - 1;
2319                    if arm_off % 2 != 1 {
2320                        return Err(lex_ast::TransformError::UnknownNode { at: at.into() });
2321                    }
2322                    let arm_index = arm_off / 2;
2323                    &arms.get(arm_index)
2324                        .ok_or_else(|| lex_ast::TransformError::UnknownNode { at: at.into() })?
2325                        .body
2326                }
2327            }
2328            Block { statements, result } => {
2329                if idx < statements.len() { &statements[idx] }
2330                else if idx == statements.len() { result }
2331                else { return Err(lex_ast::TransformError::UnknownNode { at: at.into() }); }
2332            }
2333            Constructor { args, .. } | TupleLit { items: args, .. }
2334            | ListLit { items: args, .. } => args.get(idx)
2335                .ok_or_else(|| lex_ast::TransformError::UnknownNode { at: at.into() })?,
2336            RecordLit { fields } => &fields.get(idx)
2337                .ok_or_else(|| lex_ast::TransformError::UnknownNode { at: at.into() })?
2338                .value,
2339            FieldAccess { value, .. } if idx == 0 => value,
2340            Lambda { body, .. } if idx == 0 => body,
2341            BinOp { lhs, rhs, .. } => match idx {
2342                0 => lhs, 1 => rhs,
2343                _ => return Err(lex_ast::TransformError::UnknownNode { at: at.into() }),
2344            },
2345            UnaryOp { expr, .. } if idx == 0 => expr,
2346            Return { value } if idx == 0 => value,
2347            _ => return Err(lex_ast::TransformError::UnknownNode { at: at.into() }),
2348        };
2349    }
2350    let Let { name, .. } = current else {
2351        return Err(lex_ast::TransformError::NotALet {
2352            at: at.into(),
2353            found_kind: lex_cexpr_kind(current),
2354        });
2355    };
2356    Ok(name)
2357}
2358
2359fn lex_cexpr_kind(e: &lex_ast::CExpr) -> &'static str {
2360    use lex_ast::CExpr::*;
2361    match e {
2362        Literal { .. } => "Literal", Var { .. } => "Var",
2363        Call { .. } => "Call", Let { .. } => "Let",
2364        Match { .. } => "Match", Block { .. } => "Block",
2365        Constructor { .. } => "Constructor", RecordLit { .. } => "RecordLit",
2366        TupleLit { .. } => "TupleLit", ListLit { .. } => "ListLit",
2367        FieldAccess { .. } => "FieldAccess", Lambda { .. } => "Lambda",
2368        BinOp { .. } => "BinOp", UnaryOp { .. } => "UnaryOp",
2369        Return { .. } => "Return",
2370    }
2371}
2372
2373/// Extract the declared `[budget(N)]` integer from a stage's
2374/// effect set, if any (#280 + #247). Returns `None` for stages
2375/// that aren't `FnDecl` or don't carry a budget effect — same
2376/// shape as `lex_vcs::budget_from_effects`.
2377fn budget_of_stage(stage: &Stage) -> Option<u64> {
2378    let fd = match stage {
2379        Stage::FnDecl(fd) => fd,
2380        _ => return None,
2381    };
2382    let mut min_cost: Option<u64> = None;
2383    for eff in &fd.effects {
2384        if eff.name != "budget" { continue }
2385        if let Some(lex_ast::EffectArg::Int { value }) = &eff.arg {
2386            let n = *value as u64;
2387            min_cost = Some(min_cost.map(|c| c.min(n)).unwrap_or(n));
2388        }
2389    }
2390    min_cost
2391}
2392
2393/// Serialize a stage to its canonical-JSON byte form. Used by
2394/// `publish_signed` for delta encoding (#261 slice 3) — both the
2395/// "compute the diff" path and the "write a full snapshot"
2396/// fallback need exactly the same bytes.
2397fn canonical_bytes(stage: &Stage) -> Result<Vec<u8>, StoreError> {
2398    let v = serde_json::to_value(stage)?;
2399    Ok(lex_ast::canon_json::to_canonical_string(&v).into_bytes())
2400}
2401
2402#[allow(dead_code)]
2403fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T, StoreError> {
2404    let bytes = fs::read(path)?;
2405    Ok(serde_json::from_slice(&bytes)?)
2406}