Skip to main content

zeph_durable/
ids.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Journal-boundary newtypes.
5//!
6//! Every identifier that crosses the journal boundary is a distinct newtype with private fields
7//! and a smart constructor. No raw `String` or `i64` is passed across the API, which makes it
8//! impossible to confuse, say, a [`JournalSeq`] with a [`StepId`]. Each newtype is serde-round-trip
9//! stable so it can be persisted and reloaded without loss.
10
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16/// Domain-separation context for [`IdempotencyKey`] derivation.
17///
18/// Passed to BLAKE3's `derive_key` mode so an idempotency key can never collide with a hash
19/// produced for any other purpose, even under identical key material.
20const IDEMPOTENCY_CONTEXT: &str = "zeph-durable v1 idempotency-key 2026";
21
22/// Domain-separation context for the deterministic [`PromiseId::derive`] correlation id.
23const PROMISE_DERIVE_CONTEXT: &str = "zeph-durable v1 promise-id 2026";
24
25/// Domain-separation context for the deterministic [`TimerId::derive`] correlation id.
26const TIMER_DERIVE_CONTEXT: &str = "zeph-durable v1 timer-id 2026";
27
28/// Derive a deterministic [`Uuid`] from a domain context and an execution/step position.
29///
30/// Promises and timers are correlated across a crash-resume by *position*, not by a runtime-minted
31/// random id: on replay the program re-runs and re-derives the same id for the same `(execution_id,
32/// step_id)`, so the existing `durable_promises` / `durable_timers` row is found rather than a new,
33/// orphaned one created. The BLAKE3 `derive_key` output seeds a `UUIDv8` (custom layout) so the id is
34/// a well-formed, collision-resistant UUID with a deterministic value.
35fn derive_position_uuid(context: &str, execution_id: ExecutionId, step_id: StepId) -> Uuid {
36    let mut input = [0u8; 20];
37    input[..16].copy_from_slice(execution_id.as_bytes());
38    input[16..].copy_from_slice(&step_id.value().to_le_bytes());
39    let hash = blake3::derive_key(context, &input);
40    let mut bytes = [0u8; 16];
41    bytes.copy_from_slice(&hash[..16]);
42    Uuid::new_v8(bytes)
43}
44
45/// Identifier of a single durable execution.
46///
47/// Runtime-minted as a `UUIDv7` (time-ordered) at execution start. It is **never** consumer-supplied
48/// for a fresh execution — a resumed execution reuses the persisted value, but a new one always
49/// calls [`ExecutionId::new`].
50///
51/// # Examples
52///
53/// ```
54/// use zeph_durable::ExecutionId;
55///
56/// let a = ExecutionId::new();
57/// let b = ExecutionId::new();
58/// assert_ne!(a, b, "each execution gets a distinct identity");
59/// ```
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
61pub struct ExecutionId(Uuid);
62
63impl ExecutionId {
64    /// Mint a fresh, time-ordered execution identity.
65    #[must_use]
66    pub fn new() -> Self {
67        Self(Uuid::now_v7())
68    }
69
70    /// Return the underlying UUID.
71    #[must_use]
72    pub fn as_uuid(self) -> Uuid {
73        self.0
74    }
75
76    /// Return the 16 raw bytes of the underlying UUID.
77    #[must_use]
78    pub fn as_bytes(&self) -> &[u8; 16] {
79        self.0.as_bytes()
80    }
81
82    /// Reconstruct an execution identity from a [`Uuid`] read back from storage.
83    ///
84    /// Used by a journal backend to rebuild the id of a persisted promise or timer row. A new
85    /// execution always uses [`ExecutionId::new`]; this constructor is for resume-time reconstruction
86    /// only.
87    pub(crate) fn from_uuid(uuid: Uuid) -> Self {
88        Self(uuid)
89    }
90
91    /// Parse a canonical UUID string into an execution identity.
92    ///
93    /// Used by operability surfaces (the `zeph durable` CLI, the TUI) that accept a user-supplied
94    /// execution id. A fresh execution always uses [`ExecutionId::new`]; this is for addressing an
95    /// existing one.
96    ///
97    /// # Errors
98    ///
99    /// Returns the underlying [`uuid::Error`] when `s` is not a valid UUID.
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use zeph_durable::ExecutionId;
105    ///
106    /// let id = ExecutionId::new();
107    /// let parsed = ExecutionId::parse_str(&id.as_uuid().to_string()).unwrap();
108    /// assert_eq!(parsed, id);
109    /// assert!(ExecutionId::parse_str("not-a-uuid").is_err());
110    /// ```
111    pub fn parse_str(s: &str) -> Result<Self, uuid::Error> {
112        Ok(Self::from_uuid(Uuid::parse_str(s)?))
113    }
114
115    /// Derive a deterministic execution identity from a domain tag and opaque payload bytes.
116    ///
117    /// Produces a stable [`ExecutionId`] for a `(domain, payload)` pair using BLAKE3 in
118    /// `derive_key` mode. Two calls with identical inputs produce the same id; differing inputs
119    /// produce cryptographically distinct ids. Use this for exactly-once adapters that need to
120    /// reattach to an existing journal execution on restart (e.g. the scheduler fire adapter, which
121    /// derives the id from `(job_name, slot_ms)` so a crashed and restarted scheduler finds the
122    /// same row).
123    ///
124    /// The `domain` string separates id spaces — choose a stable, globally-unique literal per
125    /// adapter (e.g. `"zeph.scheduler.fire.v1"`). The `payload` carries the distinguishing bytes
126    /// (e.g. little-endian slot timestamp).
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use zeph_durable::ExecutionId;
132    ///
133    /// let a = ExecutionId::derive(b"zeph.test.v1", b"job_name\x00\x01\x00\x00\x00\x00\x00\x00\x00");
134    /// let b = ExecutionId::derive(b"zeph.test.v1", b"job_name\x00\x01\x00\x00\x00\x00\x00\x00\x00");
135    /// let c = ExecutionId::derive(b"zeph.test.v1", b"other_job\x00\x02\x00\x00\x00\x00\x00\x00\x00");
136    /// assert_eq!(a, b, "same domain+payload derives the same id");
137    /// assert_ne!(a, c, "different payload derives a different id");
138    /// ```
139    #[must_use]
140    pub fn derive(domain: &[u8], payload: &[u8]) -> Self {
141        // BLAKE3 derive_key requires a context string, not arbitrary bytes. Build a stable
142        // context from the ASCII prefix and embed the domain bytes in the payload to keep the
143        // domain separation in the keyed-hash layer, not just the input.
144        const DERIVE_CONTEXT: &str = "zeph-durable v1 execution-id derive 2026";
145        let mut input = Vec::with_capacity(8 + domain.len() + payload.len());
146        input.extend_from_slice(&(domain.len() as u64).to_le_bytes());
147        input.extend_from_slice(domain);
148        input.extend_from_slice(payload);
149        let hash = blake3::derive_key(DERIVE_CONTEXT, &input);
150        let mut bytes = [0u8; 16];
151        bytes.copy_from_slice(&hash[..16]);
152        Self(Uuid::new_v8(bytes))
153    }
154}
155
156impl Default for ExecutionId {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162impl fmt::Display for ExecutionId {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        fmt::Display::fmt(&self.0, f)
165    }
166}
167
168/// Position of a step within an execution.
169///
170/// Assigned at the moment a step is *called* (the Nth call in program order is `StepId(N)`), never
171/// at completion, so the value is stable across replays regardless of concurrent completion order
172/// (INV-2). Wraps a [`u32`]: an execution is capped well below `u32::MAX` steps by the retention
173/// policy.
174///
175/// # Examples
176///
177/// ```
178/// use zeph_durable::StepId;
179///
180/// let step = StepId::new(7);
181/// assert_eq!(step.value(), 7);
182/// ```
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
184pub struct StepId(u32);
185
186impl StepId {
187    /// Wrap a raw step position.
188    ///
189    /// The value normally comes from the execution's atomic step counter; this constructor exists
190    /// for the journal backend and tests that reconstruct a persisted step.
191    #[must_use]
192    pub fn new(value: u32) -> Self {
193        Self(value)
194    }
195
196    /// Return the raw step position.
197    #[must_use]
198    pub fn value(self) -> u32 {
199        self.0
200    }
201}
202
203impl fmt::Display for StepId {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        fmt::Display::fmt(&self.0, f)
206    }
207}
208
209/// Global append order of a journal entry — the durability anchor.
210///
211/// Assigned by the database (an autoincrement / `BIGSERIAL` column), so it is monotonically
212/// increasing across all entries of all executions in a journal. Wraps an [`i64`] to match the
213/// column type.
214///
215/// # Examples
216///
217/// ```
218/// use zeph_durable::JournalSeq;
219///
220/// let first = JournalSeq::new(1);
221/// let second = JournalSeq::new(2);
222/// assert!(second > first);
223/// ```
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
225pub struct JournalSeq(i64);
226
227impl JournalSeq {
228    /// Wrap a database-assigned sequence number.
229    #[must_use]
230    pub fn new(value: i64) -> Self {
231        Self(value)
232    }
233
234    /// Return the raw sequence number.
235    #[must_use]
236    pub fn value(self) -> i64 {
237        self.0
238    }
239}
240
241/// Domain-separated deduplication key for a non-idempotent effect.
242///
243/// Derived with BLAKE3 in `derive_key` mode from `(execution_id, step_id, op_fingerprint)`. The
244/// derivation is injective (length-delimited input) so an attacker-controlled `op_fingerprint`
245/// cannot be crafted to collide with a different `(execution_id, step_id)` pair. The key is a
246/// *deduplication discriminator only* — never the sole trust basis for skipping a guarded effect.
247///
248/// # Examples
249///
250/// ```
251/// use zeph_durable::{ExecutionId, IdempotencyKey, StepId};
252///
253/// let exec = ExecutionId::new();
254/// let a = IdempotencyKey::derive(exec, StepId::new(0), b"transfer:acct-7");
255/// let b = IdempotencyKey::derive(exec, StepId::new(0), b"transfer:acct-7");
256/// let c = IdempotencyKey::derive(exec, StepId::new(1), b"transfer:acct-7");
257/// assert_eq!(a, b, "same inputs derive the same key");
258/// assert_ne!(a, c, "a different step derives a different key");
259/// ```
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
261pub struct IdempotencyKey([u8; 32]);
262
263impl IdempotencyKey {
264    /// Derive an idempotency key from the execution identity, step position, and an opaque
265    /// operation fingerprint.
266    ///
267    /// The fingerprint MUST be derived from non-secret descriptors only (e.g. a tool name and its
268    /// non-secret arguments); resolved secret material MUST NOT be passed here (INV-6).
269    ///
270    /// The input is length-delimited — `len(execution_id) || execution_id || len(step_id) ||
271    /// step_id || op_fingerprint` — so the field boundaries are unambiguous and the derivation is
272    /// injective. The fixed BLAKE3 `derive_key` context string keeps these keys disjoint from any
273    /// other BLAKE3 use in the workspace.
274    #[must_use]
275    pub fn derive(execution_id: ExecutionId, step_id: StepId, op_fingerprint: &[u8]) -> Self {
276        let exec_bytes = execution_id.as_bytes();
277        let step_bytes = step_id.value().to_le_bytes();
278        debug_assert_eq!(exec_bytes.len(), 16, "UUID is always 16 bytes");
279        debug_assert_eq!(step_bytes.len(), 4, "u32 is always 4 bytes");
280
281        // Length-prefix each fixed-width field (injective framing); the variable-length
282        // op_fingerprint is appended last, where its boundary is unambiguous.
283        let mut input = Vec::with_capacity(4 + 16 + 4 + 4 + op_fingerprint.len());
284        input.extend_from_slice(&16u32.to_le_bytes());
285        input.extend_from_slice(exec_bytes);
286        input.extend_from_slice(&4u32.to_le_bytes());
287        input.extend_from_slice(&step_bytes);
288        input.extend_from_slice(op_fingerprint);
289
290        Self(blake3::derive_key(IDEMPOTENCY_CONTEXT, &input))
291    }
292
293    /// Return the 32 raw key bytes.
294    #[must_use]
295    pub fn as_bytes(&self) -> &[u8; 32] {
296        &self.0
297    }
298
299    /// Reconstruct a key from its 32 stored bytes.
300    ///
301    /// Used by a journal backend to rebuild a key read back from storage; the bytes MUST originate
302    /// from a prior [`IdempotencyKey::as_bytes`] of a key produced by [`IdempotencyKey::derive`].
303    pub(crate) fn from_bytes(bytes: [u8; 32]) -> Self {
304        Self(bytes)
305    }
306}
307
308/// Reference to an external-completion handle (HITL, A2A async, subagent result).
309///
310/// A `PromiseId` is **not** a bearer capability: resolving a promise additionally requires a
311/// separate high-entropy resolver token (INV-9). The id is a `UUIDv7` so it is unguessable for
312/// practical purposes and time-ordered for indexing.
313///
314/// # Examples
315///
316/// ```
317/// use zeph_durable::PromiseId;
318///
319/// let id = PromiseId::new();
320/// assert_ne!(id, PromiseId::new());
321/// ```
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
323pub struct PromiseId(Uuid);
324
325impl PromiseId {
326    /// Mint a fresh promise identity.
327    #[must_use]
328    pub fn new() -> Self {
329        Self(Uuid::now_v7())
330    }
331
332    /// Derive the deterministic promise id for a `(execution_id, step_id)` position.
333    ///
334    /// Used by `promise()` so a resumed execution re-derives the same id at the same program point
335    /// and re-attaches to the pending `durable_promises` row instead of minting an orphan. The id is
336    /// guessable from the execution journal, but that is harmless: a `PromiseId` is *not* a bearer
337    /// capability (INV-9) — resolution requires the separate high-entropy resolver token.
338    ///
339    /// # Examples
340    ///
341    /// ```
342    /// use zeph_durable::{ExecutionId, PromiseId, StepId};
343    ///
344    /// let exec = ExecutionId::new();
345    /// let a = PromiseId::derive(exec, StepId::new(3));
346    /// let b = PromiseId::derive(exec, StepId::new(3));
347    /// assert_eq!(a, b, "the same position derives the same promise id");
348    /// assert_ne!(a, PromiseId::derive(exec, StepId::new(4)));
349    /// ```
350    #[must_use]
351    pub fn derive(execution_id: ExecutionId, step_id: StepId) -> Self {
352        Self(derive_position_uuid(
353            PROMISE_DERIVE_CONTEXT,
354            execution_id,
355            step_id,
356        ))
357    }
358
359    /// Return the underlying UUID.
360    #[must_use]
361    pub fn as_uuid(self) -> Uuid {
362        self.0
363    }
364}
365
366impl Default for PromiseId {
367    fn default() -> Self {
368        Self::new()
369    }
370}
371
372/// Handle to a durable timer that wakes at a persisted instant, surviving process restarts.
373///
374/// # Examples
375///
376/// ```
377/// use zeph_durable::TimerId;
378///
379/// let id = TimerId::new();
380/// assert_ne!(id, TimerId::new());
381/// ```
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
383pub struct TimerId(Uuid);
384
385impl TimerId {
386    /// Mint a fresh timer identity.
387    #[must_use]
388    pub fn new() -> Self {
389        Self(Uuid::now_v7())
390    }
391
392    /// Derive the deterministic timer id for a `(execution_id, step_id)` position.
393    ///
394    /// As with [`PromiseId::derive`], a resumed `sleep_until` at the same program point re-derives
395    /// the same id and re-attaches to the journaled `durable_timers` row, so a timer that fired
396    /// during downtime is recognized on replay rather than re-armed afresh (FR-DE-06).
397    ///
398    /// # Examples
399    ///
400    /// ```
401    /// use zeph_durable::{ExecutionId, StepId, TimerId};
402    ///
403    /// let exec = ExecutionId::new();
404    /// assert_eq!(
405    ///     TimerId::derive(exec, StepId::new(1)),
406    ///     TimerId::derive(exec, StepId::new(1)),
407    /// );
408    /// ```
409    #[must_use]
410    pub fn derive(execution_id: ExecutionId, step_id: StepId) -> Self {
411        Self(derive_position_uuid(
412            TIMER_DERIVE_CONTEXT,
413            execution_id,
414            step_id,
415        ))
416    }
417
418    /// Reconstruct a timer identity from a [`Uuid`] read back from storage.
419    pub(crate) fn from_uuid(uuid: Uuid) -> Self {
420        Self(uuid)
421    }
422
423    /// Return the underlying UUID.
424    #[must_use]
425    pub fn as_uuid(self) -> Uuid {
426        self.0
427    }
428}
429
430impl Default for TimerId {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436/// Closed classification of what a durable execution represents.
437///
438/// A closed enum (rather than a free-form string) prevents typos and lets the retention policy
439/// reason about execution categories. The `Custom` variant carries a compile-time string literal
440/// for execution kinds defined outside the standard set.
441///
442/// # Examples
443///
444/// ```
445/// use zeph_durable::ExecutionKind;
446///
447/// assert_eq!(ExecutionKind::AgentTurn.as_str(), "agent_turn");
448/// assert_eq!(ExecutionKind::Custom("nightly_sweep").as_str(), "nightly_sweep");
449/// ```
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
451pub enum ExecutionKind {
452    /// A single agent reasoning turn (the P1 adapter target).
453    AgentTurn,
454    /// An orchestration DAG run (the P2 adapter target).
455    DagRun,
456    /// A scheduler job fire (the P3 adapter target).
457    ScheduledJob,
458    /// A subagent session (the P4 adapter target).
459    SubagentSession,
460    /// A caller-defined execution kind identified by a compile-time literal.
461    Custom(&'static str),
462}
463
464impl ExecutionKind {
465    /// Return the canonical lower-snake-case string used in the `kind` journal column.
466    ///
467    /// For [`ExecutionKind::Custom`] the inner literal is returned verbatim.
468    #[must_use]
469    pub fn as_str(&self) -> &'static str {
470        match self {
471            Self::AgentTurn => "agent_turn",
472            Self::DagRun => "dag_run",
473            Self::ScheduledJob => "scheduled_job",
474            Self::SubagentSession => "subagent_session",
475            Self::Custom(name) => name,
476        }
477    }
478
479    /// Reconstruct a standard execution kind from its canonical column string.
480    ///
481    /// Returns `None` for an unrecognized tag. [`ExecutionKind::Custom`] cannot round-trip from
482    /// storage — its inner `&'static str` has no representation recoverable from a dynamic database
483    /// string — so a custom kind read back from the journal is reported as unrecognized rather than
484    /// silently coerced.
485    pub(crate) fn from_tag(tag: &str) -> Option<Self> {
486        match tag {
487            "agent_turn" => Some(Self::AgentTurn),
488            "dag_run" => Some(Self::DagRun),
489            "scheduled_job" => Some(Self::ScheduledJob),
490            "subagent_session" => Some(Self::SubagentSession),
491            _ => None,
492        }
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn execution_id_new_is_unique() {
502        let a = ExecutionId::new();
503        let b = ExecutionId::new();
504        assert_ne!(a, b);
505    }
506
507    #[test]
508    fn promise_and_timer_ids_are_unique() {
509        assert_ne!(PromiseId::new(), PromiseId::new());
510        assert_ne!(TimerId::new(), TimerId::new());
511    }
512
513    #[test]
514    fn execution_id_display_matches_uuid() {
515        let id = ExecutionId::new();
516        assert_eq!(id.to_string(), id.as_uuid().to_string());
517    }
518
519    #[test]
520    fn execution_id_serde_round_trip() {
521        let id = ExecutionId::new();
522        let json = serde_json::to_string(&id).unwrap();
523        let back: ExecutionId = serde_json::from_str(&json).unwrap();
524        assert_eq!(id, back);
525        // Verify the JSON shape is a bare UUID string (not {"0":"..."} or a wrapped object).
526        assert!(
527            json.starts_with('"') && json.ends_with('"'),
528            "ExecutionId must serialize as a bare UUID string, got: {json}"
529        );
530    }
531
532    #[test]
533    fn step_id_serde_round_trip_and_accessor() {
534        let step = StepId::new(42);
535        assert_eq!(step.value(), 42);
536        let json = serde_json::to_string(&step).unwrap();
537        let back: StepId = serde_json::from_str(&json).unwrap();
538        assert_eq!(step, back);
539    }
540
541    #[test]
542    fn journal_seq_serde_round_trip_and_ordering() {
543        let seq = JournalSeq::new(99);
544        assert_eq!(seq.value(), 99);
545        assert!(JournalSeq::new(2) > JournalSeq::new(1));
546        let json = serde_json::to_string(&seq).unwrap();
547        let back: JournalSeq = serde_json::from_str(&json).unwrap();
548        assert_eq!(seq, back);
549    }
550
551    #[test]
552    fn derived_promise_and_timer_ids_are_position_stable_and_disjoint() {
553        let exec = ExecutionId::new();
554        let other = ExecutionId::new();
555        // Deterministic for a fixed position…
556        assert_eq!(
557            PromiseId::derive(exec, StepId::new(2)),
558            PromiseId::derive(exec, StepId::new(2))
559        );
560        assert_eq!(
561            TimerId::derive(exec, StepId::new(2)),
562            TimerId::derive(exec, StepId::new(2))
563        );
564        // …yet distinct across step, execution, and the promise/timer domain separation.
565        assert_ne!(
566            PromiseId::derive(exec, StepId::new(2)),
567            PromiseId::derive(exec, StepId::new(3))
568        );
569        assert_ne!(
570            PromiseId::derive(exec, StepId::new(2)),
571            PromiseId::derive(other, StepId::new(2))
572        );
573        let promise = PromiseId::derive(exec, StepId::new(2)).as_uuid();
574        let timer = TimerId::derive(exec, StepId::new(2)).as_uuid();
575        assert_ne!(
576            promise, timer,
577            "promise and timer ids never collide at the same position"
578        );
579        assert_eq!(promise.get_version_num(), 8, "derived ids are UUIDv8");
580    }
581
582    #[test]
583    fn promise_and_timer_serde_round_trip() {
584        let promise = PromiseId::new();
585        let timer = TimerId::new();
586        let pj = serde_json::to_string(&promise).unwrap();
587        let tj = serde_json::to_string(&timer).unwrap();
588        assert_eq!(promise, serde_json::from_str::<PromiseId>(&pj).unwrap());
589        assert_eq!(timer, serde_json::from_str::<TimerId>(&tj).unwrap());
590    }
591
592    #[test]
593    fn idempotency_key_serde_round_trip() {
594        let key = IdempotencyKey::derive(ExecutionId::new(), StepId::new(3), b"op");
595        let json = serde_json::to_string(&key).unwrap();
596        let back: IdempotencyKey = serde_json::from_str(&json).unwrap();
597        assert_eq!(key, back);
598    }
599
600    #[test]
601    fn idempotency_key_is_deterministic() {
602        let exec = ExecutionId::new();
603        let a = IdempotencyKey::derive(exec, StepId::new(5), b"tool:read");
604        let b = IdempotencyKey::derive(exec, StepId::new(5), b"tool:read");
605        assert_eq!(a, b);
606    }
607
608    #[test]
609    fn idempotency_key_varies_with_each_input() {
610        let exec = ExecutionId::new();
611        let other = ExecutionId::new();
612        let base = IdempotencyKey::derive(exec, StepId::new(0), b"op");
613        assert_ne!(base, IdempotencyKey::derive(other, StepId::new(0), b"op"));
614        assert_ne!(base, IdempotencyKey::derive(exec, StepId::new(1), b"op"));
615        assert_ne!(base, IdempotencyKey::derive(exec, StepId::new(0), b"op2"));
616    }
617
618    #[test]
619    fn idempotency_key_framing_is_injective() {
620        // The length-delimited framing keeps the step_id/op_fingerprint boundary unambiguous:
621        // moving the step bytes into the fingerprint must change the derived key. A naive
622        // concatenation that merged the two fields could collide here.
623        let exec = ExecutionId::new();
624        let with_step = IdempotencyKey::derive(exec, StepId::new(2), b"");
625        let with_fingerprint = IdempotencyKey::derive(exec, StepId::new(0), &2u32.to_le_bytes());
626        assert_ne!(with_step, with_fingerprint);
627    }
628
629    #[test]
630    fn execution_kind_as_str_is_stable() {
631        assert_eq!(ExecutionKind::AgentTurn.as_str(), "agent_turn");
632        assert_eq!(ExecutionKind::DagRun.as_str(), "dag_run");
633        assert_eq!(ExecutionKind::ScheduledJob.as_str(), "scheduled_job");
634        assert_eq!(ExecutionKind::SubagentSession.as_str(), "subagent_session");
635        assert_eq!(ExecutionKind::Custom("x").as_str(), "x");
636    }
637}