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