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