Skip to main content

lex_vcs/
operation.rs

1//! The `Operation` enum + `OperationRecord` (operation plus its
2//! causal parents and resulting `OpId`).
3//!
4//! See `lib.rs` for the design context and #129 for the issue.
5
6use indexmap::IndexSet;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::canonical;
11
12/// Signature identity of a function or type — the part that stays
13/// stable across body edits. Wraps the same string identity
14/// `lex-store` uses; we keep it as `String` here so this crate has
15/// no dependency on `lex-store`'s internals.
16pub type SigId = String;
17
18/// Content hash of a single stage (function body, type def, ...).
19/// Same string identity as the file under `<root>/stages/<SigId>/
20/// implementations/<StageId>.ast.json`.
21pub type StageId = String;
22
23/// Identity of an operation. `(kind, payload, parents)` SHA-256 in
24/// lowercase hex (64 chars). Two operations with identical payloads
25/// and parent sets produce identical `OpId`s; the store dedupes on
26/// this.
27pub type OpId = String;
28
29/// Sorted set of effect-kind strings (e.g. `["fs_write", "io"]`).
30/// `BTreeSet` so the canonical form is order-independent for
31/// hashing.
32pub type EffectSet = BTreeSet<String>;
33
34/// Reference to an imported module — either a stdlib name
35/// (`std.io`) or a local path (`./helpers`). Kept as a string so
36/// this crate doesn't pull in `lex-syntax`'s parser.
37pub type ModuleRef = String;
38
39/// The alias a module binds to when the import writes no explicit
40/// `as` — the module reference's last path segment, splitting on
41/// either `.` (stdlib, `std.sql` → `sql`) or `/` (local/package,
42/// `./error` → `error`, `lex-web/lib` → `lib`). Lex actually requires
43/// an explicit alias on every import, so this is not a language
44/// default; it is the convention `AddImport` uses to decide when an
45/// alias can be omitted from the op (keeping the `OpId` stable) and
46/// `export-git` uses to reconstruct it. The two MUST agree, so both
47/// call this one function.
48pub fn default_import_alias(module: &str) -> String {
49    module
50        .rsplit(['.', '/'])
51        .find(|seg| !seg.is_empty())
52        .unwrap_or(module)
53        .to_string()
54}
55
56/// Version tag for the operation canonical form (#244).
57///
58/// The pre-image bytes hashed to derive an `OpId` are not stable
59/// across schema evolutions: adding a field to `OperationKind` or
60/// changing its serde representation rotates every existing `OpId`.
61/// This enum tags the encoding used so a long-lived store can detect
62/// mismatches and migrate explicitly via [`crate::migrate`].
63///
64/// **Today only [`Self::V1`] is in production.** Adding a future
65/// variant requires:
66///
67/// 1. A new arm in [`Operation::canonical_bytes_in`].
68/// 2. An update to the canonical-form spec in [`crate::canonical`].
69/// 3. A `CHANGELOG.md` entry under `### Internal` calling out the
70///    `OpId` rotation.
71/// 4. A migration recipe via [`crate::migrate::plan_migration`] —
72///    the mechanism is encoder-agnostic, but each new variant needs
73///    its own `canonical_bytes_in` arm.
74#[derive(
75    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
76)]
77#[serde(rename_all = "lowercase")]
78pub enum OperationFormat {
79    #[default]
80    V1,
81}
82
83impl OperationFormat {
84    /// The format every newly-emitted op uses today.
85    pub const CURRENT: OperationFormat = OperationFormat::V1;
86
87    /// `true` for the implicit format (V1). Used by the
88    /// `skip_serializing_if` hook on [`OperationRecord::format_version`]
89    /// so existing V1 stores keep byte-identical on-disk JSON —
90    /// adding the version field doesn't itself rotate any `OpId`.
91    pub fn is_implicit(&self) -> bool {
92        matches!(self, OperationFormat::V1)
93    }
94}
95
96/// Effect of applying an operation on a stage's content-addressed
97/// identity. Used as the `produces` field of an [`OperationRecord`]
98/// so consumers can answer "after this op, what's the head stage
99/// for this SigId?" without rerunning the apply step.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(tag = "kind", rename_all = "snake_case")]
102pub enum StageTransition {
103    /// New SigId; produces a stage that didn't exist before.
104    Create { sig_id: SigId, stage_id: StageId },
105    /// Existing SigId; replaces its head stage.
106    Replace { sig_id: SigId, from: StageId, to: StageId },
107    /// SigId removed; no head stage afterwards.
108    Remove { sig_id: SigId, last: StageId },
109    /// SigId renamed; same body hash, different signature identity.
110    Rename { from: SigId, to: SigId, body_stage_id: StageId },
111    /// Import-only change; doesn't touch any stage.
112    ImportOnly,
113    /// Merge op result. `entries` lists only the sigs whose head
114    /// changed relative to the merge op's first parent (`dst_head`):
115    /// `Some(stage_id)` sets the head; `None` removes the sig.
116    /// Sigs unaffected by the merge are not listed.
117    ///
118    /// **Canonical-form contract:** `BTreeMap` is load-bearing —
119    /// iteration is sorted by `SigId`, so on-disk JSON for two
120    /// callers that resolved the same conflicts in different
121    /// orders produces byte-identical output. Switching to
122    /// `HashMap` here would break canonical stability of the
123    /// `OperationRecord` JSON file and is rejected by the
124    /// canonical-form spec in `crate::canonical`.
125    Merge {
126        entries: BTreeMap<SigId, Option<StageId>>,
127    },
128}
129
130impl StageTransition {
131    /// Every stage id this transition references — the content-addressed
132    /// blobs a peer needs alongside the op record to render or replay it.
133    /// Used by `op push`/`pull` to sync stage objects, not just op records.
134    pub fn stage_ids(&self) -> Vec<StageId> {
135        match self {
136            StageTransition::Create { stage_id, .. } => vec![stage_id.clone()],
137            StageTransition::Replace { from, to, .. } => vec![from.clone(), to.clone()],
138            StageTransition::Remove { last, .. } => vec![last.clone()],
139            StageTransition::Rename { body_stage_id, .. } => vec![body_stage_id.clone()],
140            StageTransition::ImportOnly => Vec::new(),
141            StageTransition::Merge { entries } => entries.values().flatten().cloned().collect(),
142        }
143    }
144
145    /// Every `(sig_id, stage_id)` pair this transition references (#986).
146    ///
147    /// A `StageId` hashes the structural signature plus the implementation and
148    /// deliberately **not** the name (#826), so two functions differing only in
149    /// name share one StageId while having two distinct SigIds — and two
150    /// separate ASTs, one stored under each sig. Rendering therefore resolves a
151    /// stage through [`crate`]'s `(sig, stage)` pair, never the id alone.
152    ///
153    /// [`Self::stage_ids`] is consequently not enough for object sync: asking a
154    /// peer "do you have this stage id?" can answer yes while the variant the
155    /// head actually names is absent. Sync paths should use these pairs.
156    pub fn stage_pairs(&self) -> Vec<(SigId, StageId)> {
157        match self {
158            StageTransition::Create { sig_id, stage_id } => {
159                vec![(sig_id.clone(), stage_id.clone())]
160            }
161            StageTransition::Replace { sig_id, from, to } => vec![
162                (sig_id.clone(), from.clone()),
163                (sig_id.clone(), to.clone()),
164            ],
165            StageTransition::Remove { sig_id, last } => vec![(sig_id.clone(), last.clone())],
166            // Only `to` (#992). A SigId covers the declaration's identity, so
167            // the renamed body's AST hashes to the *new* sig and a store files
168            // it there and only there — `(from, body_stage_id)` is a pair no
169            // store can hold. Asking for it made the reconciler demand a blob
170            // that cannot exist and refuse an otherwise valid push. The
171            // transition agrees: `apply_transition` drops `from` and inserts
172            // `to → body_stage_id`, so the head never names `from` either.
173            StageTransition::Rename { to, body_stage_id, .. } => {
174                vec![(to.clone(), body_stage_id.clone())]
175            }
176            StageTransition::ImportOnly => Vec::new(),
177            StageTransition::Merge { entries } => entries
178                .iter()
179                .filter_map(|(sig, stage)| stage.as_ref().map(|st| (sig.clone(), st.clone())))
180                .collect(),
181        }
182    }
183}
184
185/// The kinds of operations that produce stage transitions. Mirrors
186/// the initial set in #129; new kinds (`MoveBetweenFiles`,
187/// `SplitFunction`, `ExtractType`) can be added later as long as
188/// they're appended at the end of this enum or use explicit
189/// `#[serde(rename = "...")]` tags so existing `OpId`s stay stable.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(tag = "op", rename_all = "snake_case")]
192pub enum OperationKind {
193    /// New function published. `effects` is the effect set declared
194    /// in the signature; tracked here (not just inside the stage)
195    /// so #130's write-time gate has a cheap path to check effect
196    /// changes without rehydrating the AST.
197    ///
198    /// `budget_cost` (#247) records the function's declared
199    /// `[budget(N)]` cost. Optional with `skip_serializing_if`, so
200    /// pre-#247 ops without a declared budget continue to hash to
201    /// their original `OpId` (additive serialization, same trick
202    /// `intent_id` uses). `None` means the function declared no
203    /// budget effect; `Some(n)` is the literal `n` from
204    /// `[budget(n)]`.
205    AddFunction {
206        sig_id: SigId,
207        stage_id: StageId,
208        effects: EffectSet,
209        #[serde(default, skip_serializing_if = "Option::is_none")]
210        budget_cost: Option<u64>,
211        /// The package source file this declaration came from
212        /// (`src/schema.lex`), when published as part of a multi-module
213        /// package. `None` for a single-file publish — so those ops
214        /// serialize byte-identically and keep their `OpId` (same
215        /// additive trick as `budget_cost`). Lets `export-git` de-flatten
216        /// a mangled package back into its `src/*.lex` tree (#894) and
217        /// gives `lex blame` per-file provenance.
218        #[serde(default, skip_serializing_if = "Option::is_none")]
219        in_file: Option<String>,
220    },
221    /// Function removed; `last_stage_id` is the head before the
222    /// remove (so blame can walk the predecessor without scanning).
223    RemoveFunction {
224        sig_id: SigId,
225        last_stage_id: StageId,
226    },
227    /// Function body changed; signature unchanged.
228    ///
229    /// `from_budget` / `to_budget` (#247) record the declared
230    /// `[budget(N)]` on each side. Same `Option` + `skip` discipline
231    /// as `AddFunction.budget_cost` — pre-#247 ops keep their
232    /// `OpId`s. The pair is what `lex op log --budget-drift` reads
233    /// to surface "budget grew/shrank" diffs without rehydrating
234    /// stages.
235    ModifyBody {
236        sig_id: SigId,
237        from_stage_id: StageId,
238        to_stage_id: StageId,
239        #[serde(default, skip_serializing_if = "Option::is_none")]
240        from_budget: Option<u64>,
241        #[serde(default, skip_serializing_if = "Option::is_none")]
242        to_budget: Option<u64>,
243        /// The SigId the declaration moves **to**, when the modification
244        /// changed the signature itself (#992) — same field, same reason, as
245        /// [`OperationKind::ChangeEffectSig::to_sig_id`].
246        ///
247        /// A SigId covers the input/output types and the signature-level
248        /// `examples`, not just the name, so a same-name edit to any of those
249        /// is a new sig. The publish diff keys declarations by name and read
250        /// it as a plain modification, so the head kept the *old* sig bound to
251        /// the *new* stage: a pair no store can hold. `None` for a body-only
252        /// change — and for every op written before this field — keeping the
253        /// in-place `Replace` and byte-identical OpIds.
254        #[serde(default, skip_serializing_if = "Option::is_none")]
255        to_sig_id: Option<SigId>,
256    },
257    /// Symbol renamed. The body hash is preserved (`body_stage_id`)
258    /// so two renames of the same body collapse to the same OpId
259    /// and `lex blame` walks the rename as a single causal event
260    /// rather than `delete + add`.
261    RenameSymbol {
262        from: SigId,
263        to: SigId,
264        body_stage_id: StageId,
265    },
266    /// Effect signature changed. Captures both old and new effect
267    /// sets so the write-time gate (#130) can verify importers
268    /// haven't silently broken.
269    ///
270    /// `from_budget` / `to_budget` (#247) capture the declared
271    /// `[budget(N)]` on each side. ChangeEffectSig usually fires
272    /// because the effect *list* changed; #247 makes budget drift
273    /// visible without forcing a full effect-set diff.
274    ChangeEffectSig {
275        sig_id: SigId,
276        from_stage_id: StageId,
277        to_stage_id: StageId,
278        from_effects: EffectSet,
279        to_effects: EffectSet,
280        #[serde(default, skip_serializing_if = "Option::is_none")]
281        from_budget: Option<u64>,
282        #[serde(default, skip_serializing_if = "Option::is_none")]
283        to_budget: Option<u64>,
284        /// The SigId the declaration moves **to** (#992).
285        ///
286        /// A SigId covers the effect row, so changing a function's effects
287        /// changes its sig — the op's own name says as much. Without this the
288        /// transition was a `Replace`, which keeps the *old* sig pointing at
289        /// the *new* stage. But a store files an implementation under the sig
290        /// its own AST hashes to, and that AST declares the new effects, so
291        /// the head entry was unsatisfiable by construction: no store could
292        /// ever hold `(old_sig, new_stage)`. The head then could not be
293        /// rendered, and any release cut from it was born broken —
294        /// `lex-web@0.4.0` is exactly that.
295        ///
296        /// `None` is how every op written before this field existed decodes,
297        /// and it keeps their original `Replace` behaviour so historical logs
298        /// replay unchanged. `skip_serializing_if` keeps those ops
299        /// byte-identical, so their OpIds do not move.
300        #[serde(default, skip_serializing_if = "Option::is_none")]
301        to_sig_id: Option<SigId>,
302    },
303    /// Import added to a file. `in_file` is the canonical path
304    /// (relative to the repo root, forward-slashes) so two
305    /// machines hashing the same edit get the same OpId.
306    AddImport {
307        in_file: String,
308        module: ModuleRef,
309        /// The binding the module is imported under (`import "std.sql"
310        /// as sql` → `"sql"`). Lex requires an alias on every import,
311        /// but this is `None` whenever it equals the module's default
312        /// alias (the last path segment) — the common case — so those
313        /// `AddImport`s serialize exactly as before and keep their
314        /// original `OpId` (additive serialization, same trick as
315        /// `budget_cost`). Only a non-default alias (`import "./error"
316        /// as e`) is carried explicitly. Without it, `export-git`
317        /// cannot reconstruct a compilable module — the reference alone
318        /// doesn't say what name the body binds.
319        #[serde(default, skip_serializing_if = "Option::is_none")]
320        alias: Option<String>,
321    },
322    RemoveImport {
323        in_file: String,
324        module: ModuleRef,
325    },
326    AddType {
327        sig_id: SigId,
328        stage_id: StageId,
329        /// Source file this type came from, for multi-module packages;
330        /// `None` (and omitted) for a single-file publish. See
331        /// `AddFunction::in_file`.
332        #[serde(default, skip_serializing_if = "Option::is_none")]
333        in_file: Option<String>,
334    },
335    RemoveType {
336        sig_id: SigId,
337        last_stage_id: StageId,
338    },
339    ModifyType {
340        sig_id: SigId,
341        from_stage_id: StageId,
342        to_stage_id: StageId,
343        /// The SigId the type moves **to** when its params changed (#992).
344        /// See [`OperationKind::ModifyBody::to_sig_id`].
345        #[serde(default, skip_serializing_if = "Option::is_none")]
346        to_sig_id: Option<SigId>,
347    },
348    /// Merge of two branch heads. Carries only an informational count
349    /// of resolved sigs so two structurally identical merges of
350    /// different sizes don't collide on op_id; the per-sig deltas live
351    /// in `OperationRecord::produces` (`StageTransition::Merge`).
352    Merge {
353        resolved: usize,
354    },
355    /// Typed transform: inlined a `let x := v; body` by
356    /// substituting `v` for every unshadowed `x` in `body`, then
357    /// replacing the entire `Let` node with the substituted body
358    /// (#280). The op records the let-binding's position and the
359    /// inlined name; the actual substituted value lives in the
360    /// content-addressed `to_stage_id` so the op_id stays compact.
361    InlineLet {
362        sig_id: SigId,
363        from_stage_id: StageId,
364        to_stage_id: StageId,
365        let_node: String,
366        binding_name: String,
367        #[serde(default, skip_serializing_if = "Option::is_none")]
368        from_budget: Option<u64>,
369        #[serde(default, skip_serializing_if = "Option::is_none")]
370        to_budget: Option<u64>,
371    },
372    /// Typed transform: renamed a `let`-bound local within a fn
373    /// body (#280). Records the old/new identifiers and the position
374    /// of the let-binding in the AST. Body-shape-stable: the renamed
375    /// stage typically hashes near the original.
376    RenameLocal {
377        sig_id: SigId,
378        from_stage_id: StageId,
379        to_stage_id: StageId,
380        /// Path-style NodeId of the `Let` expression at the time of
381        /// the transform.
382        let_node: String,
383        old_name: String,
384        new_name: String,
385        #[serde(default, skip_serializing_if = "Option::is_none")]
386        from_budget: Option<u64>,
387        #[serde(default, skip_serializing_if = "Option::is_none")]
388        to_budget: Option<u64>,
389    },
390    /// Typed transform: replaced one arm's body in a `Match`
391    /// expression (#280). Semantically a `ModifyBody`, but the op
392    /// records *which* arm changed and *where* in the AST — so the
393    /// op log reads as a semantic edit history rather than as
394    /// opaque hash-to-hash bytes.
395    ///
396    /// `match_node` is the [`lex_ast::ids::NodeId`] of the Match
397    /// expression at the time of the transform. NodeIds aren't
398    /// stable across structural edits — they're audit-trail metadata,
399    /// not re-derivation keys. The authoritative record of the new
400    /// stage is `to_stage_id` (content-addressed).
401    ///
402    /// `from_budget`/`to_budget` follow the same `skip_if_none`
403    /// discipline as [`Self::ModifyBody`]: pre-#280 ops continue
404    /// hashing to their original `OpId`s.
405    ReplaceMatchArm {
406        sig_id: SigId,
407        from_stage_id: StageId,
408        to_stage_id: StageId,
409        /// Path-style NodeId of the Match expression that was
410        /// modified, captured at transform time. See
411        /// [`lex_ast::ids::NodeId`] for the format.
412        match_node: String,
413        arm_index: usize,
414        #[serde(default, skip_serializing_if = "Option::is_none")]
415        from_budget: Option<u64>,
416        #[serde(default, skip_serializing_if = "Option::is_none")]
417        to_budget: Option<u64>,
418    },
419    /// Multi-agent coordination: a stage proposed for a sig
420    /// without advancing the branch (#294). Multiple agents can
421    /// land `Candidate` ops on the same sig concurrently without
422    /// contention — they all chain off the current head and don't
423    /// move it. Used together with [`Self::Promote`] to model
424    /// bake-offs: several agents propose, one is promoted.
425    ///
426    /// The `Operation`'s `intent_id` is expected to be set so
427    /// downstream consumers can distinguish proposals by author.
428    /// (The schema doesn't enforce this; the gate does.)
429    Candidate {
430        sig_id: SigId,
431        stage_id: StageId,
432    },
433    /// Multi-agent coordination: promotes a previously-landed
434    /// [`Self::Candidate`] op as the new head for its sig (#294).
435    /// Carries the list of *other* candidates this Promote
436    /// supersedes so the op log explicitly records the bake-off
437    /// shape.
438    ///
439    /// Acts as a `ModifyBody` (or `AddFunction` when the sig has
440    /// no head) for branch-head purposes — `transition_for_kind`
441    /// returns the appropriate `StageTransition`.
442    Promote {
443        sig_id: SigId,
444        /// Op id of the [`Self::Candidate`] being promoted.
445        winner_candidate: OpId,
446        /// Stage id of the winner (duplicates the candidate's
447        /// `stage_id` for fast lookup; saves a log round-trip
448        /// for `lex op show`).
449        winner_stage_id: StageId,
450        /// Every other live `Candidate` for `sig_id` at the time
451        /// of promotion. Sorted by op_id for canonical-form
452        /// stability. After this `Promote` lands, none of these
453        /// op_ids appear in [`Store::list_candidates`].
454        supersedes: Vec<OpId>,
455        /// Current branch head stage for `sig_id`, or `None` if
456        /// the sig had no head (the Promote is creating it).
457        /// `None` is serialized as missing for canonical stability
458        /// across "first promote on a sig" vs "later promote".
459        #[serde(default, skip_serializing_if = "Option::is_none")]
460        from_stage_id: Option<StageId>,
461        #[serde(default, skip_serializing_if = "Option::is_none")]
462        from_budget: Option<u64>,
463        #[serde(default, skip_serializing_if = "Option::is_none")]
464        to_budget: Option<u64>,
465    },
466}
467
468impl OperationKind {
469    /// The `(SigId, Option<StageId>)` an op kind targets, as used by
470    /// `StageTransition::Merge::entries`. Used by the merge-commit
471    /// path (#134) to translate a `Resolution::Custom { op }` into
472    /// the head-map delta the merge op records:
473    ///
474    /// * Adds → `(sig, Some(stage_id))`
475    /// * Modifies → `(sig, Some(to_stage_id))`
476    /// * Removes → `(sig, None)`
477    /// * Renames → `(to_sig, Some(body_stage_id))`
478    /// * `AddImport` / `RemoveImport` / nested `Merge` → `None`
479    ///   (no single sig→stage delta)
480    pub fn merge_target(&self) -> Option<(SigId, Option<StageId>)> {
481        use OperationKind::*;
482        match self {
483            AddFunction { sig_id, stage_id, .. }
484            | AddType { sig_id, stage_id, .. }
485                => Some((sig_id.clone(), Some(stage_id.clone()))),
486            // #992: a sig-moving modification lands under the sig it moves to.
487            ModifyBody { sig_id, to_stage_id, to_sig_id, .. }
488            | ChangeEffectSig { sig_id, to_stage_id, to_sig_id, .. }
489            | ModifyType { sig_id, to_stage_id, to_sig_id, .. }
490                => Some((to_sig_id.as_ref().unwrap_or(sig_id).clone(), Some(to_stage_id.clone()))),
491            ReplaceMatchArm { sig_id, to_stage_id, .. }
492            | RenameLocal { sig_id, to_stage_id, .. }
493            | InlineLet { sig_id, to_stage_id, .. }
494                => Some((sig_id.clone(), Some(to_stage_id.clone()))),
495            Promote { sig_id, winner_stage_id, .. }
496                => Some((sig_id.clone(), Some(winner_stage_id.clone()))),
497            RemoveFunction { sig_id, .. }
498            | RemoveType { sig_id, .. }
499                => Some((sig_id.clone(), None)),
500            RenameSymbol { to, body_stage_id, .. }
501                => Some((to.clone(), Some(body_stage_id.clone()))),
502            AddImport { .. } | RemoveImport { .. } | Merge { .. } => None,
503            // Candidate ops don't advance the branch head; they
504            // don't fit the (sig, Option<stage_id>) head-delta
505            // shape that `merge_target` describes.
506            Candidate { .. } => None,
507        }
508    }
509
510    /// `(from_budget, to_budget)` for ops that carry a budget delta
511    /// (#247). `(None, None)` for ops where the budget isn't part
512    /// of the canonical payload — `RemoveFunction`, `RenameSymbol`,
513    /// imports, and merges. `AddFunction` reports `(None,
514    /// Some(cost))` for "this is the initial cost." Used by `lex op
515    /// show`, `lex op log --budget-drift`, and `lex audit --budget`.
516    pub fn budget_delta(&self) -> (Option<u64>, Option<u64>) {
517        use OperationKind::*;
518        match self {
519            AddFunction { budget_cost, .. } => (None, *budget_cost),
520            ModifyBody { from_budget, to_budget, .. }
521            | ChangeEffectSig { from_budget, to_budget, .. }
522            | ReplaceMatchArm { from_budget, to_budget, .. }
523            | RenameLocal { from_budget, to_budget, .. }
524            | InlineLet { from_budget, to_budget, .. }
525            | Promote { from_budget, to_budget, .. } => (*from_budget, *to_budget),
526            _ => (None, None),
527        }
528    }
529
530    /// The `SigId` an op touches if it carries a budget — used for
531    /// per-sig audit rollups in `lex audit --budget`. Returns `None`
532    /// for ops without a relevant budget (the same set as the
533    /// `_ => (None, None)` arm of [`Self::budget_delta`]).
534    pub fn budget_sig(&self) -> Option<&SigId> {
535        use OperationKind::*;
536        match self {
537            AddFunction { sig_id, .. }
538            | ModifyBody { sig_id, .. }
539            | ChangeEffectSig { sig_id, .. }
540            | ReplaceMatchArm { sig_id, .. }
541            | RenameLocal { sig_id, .. }
542            | InlineLet { sig_id, .. }
543            | Promote { sig_id, .. } => Some(sig_id),
544            _ => None,
545        }
546    }
547}
548
549/// Extract the declared `[budget(N)]` integer from an [`EffectSet`],
550/// if any (#247).
551///
552/// Effect labels in [`EffectSet`] are produced by
553/// [`crate::compute_diff::effect_label`]: a `[budget(50)]`
554/// declaration becomes the literal string `"budget(50)"`. This
555/// helper parses that literal back to the integer; bare `"budget"`
556/// (no arg) returns `None` because the magnitude is unknown. A
557/// stage with multiple budget declarations — which the type-
558/// checker should reject anyway — picks the smallest, conservative
559/// answer for `lex audit --budget`.
560pub fn budget_from_effects(effects: &EffectSet) -> Option<u64> {
561    let mut min_cost: Option<u64> = None;
562    for label in effects {
563        let Some(rest) = label.strip_prefix("budget(") else { continue };
564        let Some(inner) = rest.strip_suffix(')') else { continue };
565        let Ok(n) = inner.parse::<u64>() else { continue };
566        min_cost = Some(min_cost.map(|c| c.min(n)).unwrap_or(n));
567    }
568    min_cost
569}
570
571/// The operation as a whole — its kind and the causal predecessors
572/// it assumes. The `OpId` is computed from this plus a sorted view
573/// of `parents`.
574///
575/// Operations without parents are valid and represent "applies to
576/// the empty repository" or "applies to the synthetic genesis
577/// state." `lex store migrate v1→v2` will produce parentless ops
578/// for stages it can't trace back to a clear predecessor.
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580pub struct Operation {
581    #[serde(flatten)]
582    pub kind: OperationKind,
583    /// Operations whose `produces` this op assumes. Sorted before
584    /// hashing for canonical form. Empty for ops against the empty
585    /// repo.
586    #[serde(default, skip_serializing_if = "Vec::is_empty")]
587    pub parents: Vec<OpId>,
588    /// The intent that caused this op, if known. Optional because
589    /// operations produced outside an agent harness (e.g. a human
590    /// running `lex publish` directly) don't have one.
591    ///
592    /// Including the intent in the canonical hash means the same
593    /// logical change made under different intents produces
594    /// different `OpId`s — causally distinct events should hash
595    /// distinctly. Ops with `intent_id: None` keep their existing
596    /// hashes (the field is omitted from the canonical JSON via
597    /// `skip_serializing_if`), so this is backwards-compatible
598    /// for stores written before #131.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub intent_id: Option<crate::intent::IntentId>,
601}
602
603impl Operation {
604    /// Construct an operation against zero or more parents. Caller
605    /// supplies parents in any order; canonicalization sorts them
606    /// before hashing.
607    pub fn new(kind: OperationKind, parents: impl IntoIterator<Item = OpId>) -> Self {
608        let mut parents: Vec<OpId> = parents.into_iter().collect();
609        parents.sort();
610        parents.dedup();
611        Self { kind, parents, intent_id: None }
612    }
613
614    /// Tag this operation with the intent that produced it. The
615    /// builder shape keeps existing call sites untouched; agent
616    /// harnesses that record intent call this once before
617    /// applying the op.
618    pub fn with_intent(mut self, intent_id: impl Into<crate::intent::IntentId>) -> Self {
619        self.intent_id = Some(intent_id.into());
620        self
621    }
622
623    /// Compute this operation's content-addressed identity under the
624    /// current production canonical form ([`OperationFormat::CURRENT`]).
625    ///
626    /// Stable across runs and machines: same `(kind, payload,
627    /// sorted parents, intent_id)` produces the same `OpId`. The
628    /// invariant #129's automatic-dedup behavior relies on.
629    pub fn op_id(&self) -> OpId {
630        self.op_id_in(OperationFormat::CURRENT)
631    }
632
633    /// Compute the `OpId` under a specific canonical-form version.
634    ///
635    /// Used by [`crate::migrate`] to derive new `OpId`s when porting
636    /// a store across format versions. Production code should call
637    /// [`Self::op_id`].
638    pub fn op_id_in(&self, format: OperationFormat) -> OpId {
639        canonical::hash_bytes(&self.canonical_bytes_in(format))
640    }
641
642    /// The byte sequence that gets hashed to produce [`Self::op_id`]
643    /// under the current canonical form. Equivalent to
644    /// `self.canonical_bytes_in(OperationFormat::CURRENT)`.
645    ///
646    /// Exposed (not just consumed by `op_id`) so golden tests can pin
647    /// the exact pre-image. **Not** equal to `serde_json::to_vec(&op)`
648    /// in general — the on-disk JSON skips empty `parents` and
649    /// `None` `intent_id`, while the canonical form always emits a
650    /// (sorted, deduped) `parents` array. See `canonical.rs` for the
651    /// full V1 canonical-form spec.
652    pub fn canonical_bytes(&self) -> Vec<u8> {
653        self.canonical_bytes_in(OperationFormat::CURRENT)
654    }
655
656    /// The pre-image hashed under a specific canonical-form version.
657    ///
658    /// Today every `OperationFormat` variant routes to V1's encoder
659    /// (only V1 exists in production). When V2 lands, this match
660    /// gains an arm and the migration tool's encoder closure routes
661    /// here.
662    pub fn canonical_bytes_in(&self, format: OperationFormat) -> Vec<u8> {
663        match format {
664            OperationFormat::V1 => self.canonical_bytes_v1(),
665        }
666    }
667
668    fn canonical_bytes_v1(&self) -> Vec<u8> {
669        // Build a transient hashable view rather than hashing
670        // `self` directly so the parent ordering is canonical
671        // even if a caller hand-constructs an `Operation` with
672        // unsorted parents.
673        let canonical = CanonicalView {
674            kind: &self.kind,
675            parents: self.parents.iter().collect::<IndexSet<_>>().into_iter().collect::<BTreeSet<_>>(),
676            intent_id: self.intent_id.as_deref(),
677        };
678        serde_json::to_vec(&canonical).expect("canonical serialization")
679    }
680}
681
682/// Hashable shadow of [`Operation`] with parents in a `BTreeSet` so
683/// the serialization is order-independent regardless of how the
684/// caller constructed the live operation. Never persisted; lives
685/// only as a transient for hashing.
686#[derive(Serialize)]
687struct CanonicalView<'a> {
688    #[serde(flatten)]
689    kind: &'a OperationKind,
690    parents: BTreeSet<&'a OpId>,
691    /// `skip_serializing_if = "Option::is_none"` keeps existing
692    /// `OpId`s stable for ops without an intent — the field is
693    /// omitted from the canonical JSON entirely.
694    #[serde(skip_serializing_if = "Option::is_none")]
695    intent_id: Option<&'a str>,
696}
697
698/// An operation paired with its computed `OpId` and the resulting
699/// stage transition. This is what gets persisted under
700/// `<root>/ops/<OpId>.json`.
701///
702/// `format_version` records the canonical form the `op_id` was
703/// computed under. Pre-#244 stores didn't emit this field; reading
704/// such records deserializes to [`OperationFormat::V1`] (the
705/// implicit pre-versioning format), and writing V1 records continues
706/// to omit it (`skip_serializing_if = is_implicit`) so adding the
707/// field doesn't rotate any existing `OpId` or change any on-disk
708/// byte. Records written under a future format will explicitly
709/// carry their version tag.
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711pub struct OperationRecord {
712    pub op_id: OpId,
713    #[serde(default, skip_serializing_if = "OperationFormat::is_implicit")]
714    pub format_version: OperationFormat,
715    #[serde(flatten)]
716    pub op: Operation,
717    pub produces: StageTransition,
718}
719
720impl OperationRecord {
721    pub fn new(op: Operation, produces: StageTransition) -> Self {
722        let op_id = op.op_id();
723        Self { op_id, format_version: OperationFormat::CURRENT, op, produces }
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    fn add_factorial() -> OperationKind {
732        OperationKind::AddFunction {
733            sig_id: "fac::Int->Int".into(),
734            stage_id: "abc123".into(),
735            effects: BTreeSet::new(),
736            budget_cost: None,
737            in_file: None,
738        }
739    }
740
741    #[test]
742    fn identical_operations_have_identical_op_ids() {
743        let a = Operation::new(add_factorial(), []);
744        let b = Operation::new(add_factorial(), []);
745        assert_eq!(a.op_id(), b.op_id());
746    }
747
748    #[test]
749    fn different_operations_have_different_op_ids() {
750        let a = Operation::new(add_factorial(), []);
751        let b = Operation::new(
752            OperationKind::AddFunction {
753                sig_id: "double::Int->Int".into(),
754                stage_id: "abc123".into(),
755                effects: BTreeSet::new(),
756                budget_cost: None,
757                in_file: None,
758            },
759            [],
760        );
761        assert_ne!(a.op_id(), b.op_id());
762    }
763
764    #[test]
765    fn parent_set_changes_op_id() {
766        let no_parent = Operation::new(add_factorial(), []);
767        let with_parent = Operation::new(add_factorial(), ["op-parent-1".into()]);
768        assert_ne!(no_parent.op_id(), with_parent.op_id());
769    }
770
771    #[test]
772    fn parent_order_does_not_affect_op_id() {
773        let a = Operation::new(add_factorial(), ["b".into(), "a".into(), "c".into()]);
774        let b = Operation::new(add_factorial(), ["c".into(), "a".into(), "b".into()]);
775        assert_eq!(a.op_id(), b.op_id());
776        // and the stored form is sorted.
777        assert_eq!(a.parents, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
778    }
779
780    #[test]
781    fn duplicate_parents_are_deduped() {
782        let with_dups = Operation::new(
783            add_factorial(),
784            ["a".into(), "a".into(), "b".into()],
785        );
786        let no_dups = Operation::new(
787            add_factorial(),
788            ["a".into(), "b".into()],
789        );
790        assert_eq!(with_dups.op_id(), no_dups.op_id());
791        assert_eq!(with_dups.parents, vec!["a".to_string(), "b".to_string()]);
792    }
793
794    #[test]
795    fn rename_with_same_body_hashes_equal_across_runs() {
796        // Two independent runs producing the same rename against the
797        // same parent should produce the same OpId — this is the
798        // automatic-dedup property #129 relies on for distributed
799        // agents.
800        let kind = OperationKind::RenameSymbol {
801            from: "parse::Str->Int".into(),
802            to: "parse_int::Str->Int".into(),
803            body_stage_id: "abc123".into(),
804        };
805        let a = Operation::new(kind.clone(), ["op-parent".into()]);
806        let b = Operation::new(kind, ["op-parent".into()]);
807        assert_eq!(a.op_id(), b.op_id());
808    }
809
810    #[test]
811    fn rename_does_not_collide_with_delete_plus_add() {
812        // The whole point of `RenameSymbol` is that it's a different
813        // OpId from the (semantically-equivalent) `RemoveFunction +
814        // AddFunction` pair. Causal history sees one event, not two.
815        let rename = Operation::new(
816            OperationKind::RenameSymbol {
817                from: "parse::Str->Int".into(),
818                to: "parse_int::Str->Int".into(),
819                body_stage_id: "abc123".into(),
820            },
821            ["op-parent".into()],
822        );
823        let remove = Operation::new(
824            OperationKind::RemoveFunction {
825                sig_id: "parse::Str->Int".into(),
826                last_stage_id: "abc123".into(),
827            },
828            ["op-parent".into()],
829        );
830        let add = Operation::new(
831            OperationKind::AddFunction {
832                sig_id: "parse_int::Str->Int".into(),
833                stage_id: "abc123".into(),
834                effects: BTreeSet::new(),
835                budget_cost: None,
836                in_file: None,
837            },
838            ["op-parent".into()],
839        );
840        assert_ne!(rename.op_id(), remove.op_id());
841        assert_ne!(rename.op_id(), add.op_id());
842    }
843
844    #[test]
845    fn effect_set_order_does_not_affect_op_id() {
846        // Effects are a BTreeSet so iteration is sorted. Build two
847        // ops via different insertion orders and confirm the
848        // canonical form is identical.
849        let a_effects: EffectSet = ["io".into(), "fs_write".into()].into_iter().collect();
850        let b_effects: EffectSet = ["fs_write".into(), "io".into()].into_iter().collect();
851        let a = Operation::new(
852            OperationKind::AddFunction {
853                sig_id: "x".into(), stage_id: "s".into(), effects: a_effects,
854                budget_cost: None,
855                in_file: None,
856            },
857            [],
858        );
859        let b = Operation::new(
860            OperationKind::AddFunction {
861                sig_id: "x".into(), stage_id: "s".into(), effects: b_effects,
862                budget_cost: None,
863                in_file: None,
864            },
865            [],
866        );
867        assert_eq!(a.op_id(), b.op_id());
868    }
869
870    #[test]
871    fn op_id_is_64_char_lowercase_hex() {
872        let id = Operation::new(add_factorial(), []).op_id();
873        assert_eq!(id.len(), 64);
874        assert!(id.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
875    }
876
877    #[test]
878    fn round_trip_through_serde_json() {
879        let op = Operation::new(
880            OperationKind::ChangeEffectSig {
881                sig_id: "f".into(),
882                from_stage_id: "old".into(),
883                to_stage_id: "new".into(),
884                from_effects: BTreeSet::new(),
885                to_effects: ["io".into()].into_iter().collect(),
886                from_budget: None,
887                to_budget: None,
888                to_sig_id: None,
889            },
890            ["op-parent".into()],
891        );
892        let json = serde_json::to_string(&op).expect("serialize");
893        let back: Operation = serde_json::from_str(&json).expect("deserialize");
894        assert_eq!(op, back);
895        assert_eq!(op.op_id(), back.op_id());
896    }
897
898    #[test]
899    fn operation_record_carries_op_id() {
900        let op = Operation::new(add_factorial(), []);
901        let expected = op.op_id();
902        let rec = OperationRecord::new(
903            op,
904            StageTransition::Create {
905                sig_id: "fac::Int->Int".into(),
906                stage_id: "abc123".into(),
907            },
908        );
909        assert_eq!(rec.op_id, expected);
910    }
911
912    #[test]
913    fn intent_id_is_part_of_op_id_canonical_hash() {
914        // The dedup property: same `(kind, parents, intent_id)`
915        // produces the same OpId. Different intent_ids on
916        // otherwise-identical ops produce different OpIds, so
917        // causally distinct events (different prompts) hash
918        // distinctly.
919        let no_intent = Operation::new(add_factorial(), []);
920        let with_intent_a = Operation::new(add_factorial(), [])
921            .with_intent("intent-a");
922        let with_intent_b = Operation::new(add_factorial(), [])
923            .with_intent("intent-b");
924        let with_intent_a_again = Operation::new(add_factorial(), [])
925            .with_intent("intent-a");
926
927        // No-intent op is distinct from any intent-tagged variant.
928        assert_ne!(no_intent.op_id(), with_intent_a.op_id());
929        // Different intents → different OpIds.
930        assert_ne!(with_intent_a.op_id(), with_intent_b.op_id());
931        // Same intent → same OpId (the load-bearing dedup invariant).
932        assert_eq!(with_intent_a.op_id(), with_intent_a_again.op_id());
933    }
934
935    #[test]
936    fn op_without_intent_keeps_pre_intent_op_id() {
937        // Backwards-compat invariant: an op constructed without an
938        // intent must hash to the same value as it would have
939        // before #131 added the field. The golden test below pins
940        // the exact hash; this one asserts that adding then
941        // resetting to None doesn't drift.
942        let mut op = Operation::new(add_factorial(), []);
943        let baseline = op.op_id();
944        op.intent_id = Some("transient".into());
945        let with_intent = op.op_id();
946        assert_ne!(baseline, with_intent);
947        op.intent_id = None;
948        let back = op.op_id();
949        assert_eq!(baseline, back);
950    }
951
952    /// Golden hash. If this changes, the canonical form has shifted
953    /// and *every* op_id in every existing store has changed too —
954    /// that's a major-version event for the data model and should
955    /// be a deliberate decision, not an accident from reordering
956    /// fields. Update with care.
957    #[test]
958    fn canonical_form_is_stable_for_a_known_input() {
959        let op = Operation::new(
960            OperationKind::AddFunction {
961                sig_id: "fac::Int->Int".into(),
962                stage_id: "abc123".into(),
963                effects: BTreeSet::new(),
964                budget_cost: None,
965                in_file: None,
966            },
967            [],
968        );
969        assert_eq!(
970            op.op_id(),
971            "f112990d31ef2a63f3e5ca5680637ed36a54bc7e8230510ae0c0e93fcb39d104"
972        );
973    }
974
975    #[test]
976    fn merge_kind_round_trips() {
977        let op = Operation::new(
978            OperationKind::Merge { resolved: 3 },
979            ["op-a".into(), "op-b".into()],
980        );
981        let json = serde_json::to_string(&op).expect("ser");
982        let back: Operation = serde_json::from_str(&json).expect("de");
983        assert_eq!(op, back);
984        assert_eq!(op.op_id(), back.op_id());
985    }
986
987    #[test]
988    fn merge_stage_transition_round_trips() {
989        let mut entries = BTreeMap::new();
990        entries.insert("sig-a".to_string(), Some("stage-a".to_string()));
991        entries.insert("sig-b".to_string(), None); // removed by merge
992        let t = StageTransition::Merge { entries };
993        let json = serde_json::to_string(&t).expect("ser");
994        let back: StageTransition = serde_json::from_str(&json).expect("de");
995        assert_eq!(t, back);
996    }
997
998    #[test]
999    fn merge_resolved_count_changes_op_id() {
1000        // Two merges with the same parents but different resolved counts
1001        // must hash differently — keeps structurally distinct merges from
1002        // colliding on op_id.
1003        let parents: Vec<OpId> = vec!["op-a".into(), "op-b".into()];
1004        let one = Operation::new(OperationKind::Merge { resolved: 1 }, parents.clone());
1005        let two = Operation::new(OperationKind::Merge { resolved: 2 }, parents);
1006        assert_ne!(one.op_id(), two.op_id());
1007    }
1008
1009    #[test]
1010    fn existing_add_function_op_id_is_unchanged_after_merge_added() {
1011        // Constructing the new Merge variant in the same enum must not
1012        // perturb the canonical bytes of existing variants. The golden
1013        // hash test below checks the literal value; this one verifies
1014        // the property holds even after a Merge op has been built.
1015        let _merge = Operation::new(
1016            OperationKind::Merge { resolved: 0 },
1017            ["op-x".into(), "op-y".into()],
1018        );
1019        let op = Operation::new(add_factorial(), []);
1020        assert_eq!(
1021            op.op_id(),
1022            "f112990d31ef2a63f3e5ca5680637ed36a54bc7e8230510ae0c0e93fcb39d104"
1023        );
1024    }
1025}