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