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
162/// Position of a step within an execution.
163///
164/// Assigned at the moment a step is *called* (the Nth call in program order is `StepId(N)`), never
165/// at completion, so the value is stable across replays regardless of concurrent completion order
166/// (INV-2). Wraps a [`u32`]: an execution is capped well below `u32::MAX` steps by the retention
167/// policy.
168///
169/// # Examples
170///
171/// ```
172/// use zeph_durable::StepId;
173///
174/// let step = StepId::new(7);
175/// assert_eq!(step.value(), 7);
176/// ```
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
178pub struct StepId(u32);
179
180impl StepId {
181    /// Wrap a raw step position.
182    ///
183    /// The value normally comes from the execution's atomic step counter; this constructor exists
184    /// for the journal backend and tests that reconstruct a persisted step.
185    #[must_use]
186    pub fn new(value: u32) -> Self {
187        Self(value)
188    }
189
190    /// Return the raw step position.
191    #[must_use]
192    pub fn value(self) -> u32 {
193        self.0
194    }
195}
196
197impl fmt::Display for StepId {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        fmt::Display::fmt(&self.0, f)
200    }
201}
202
203/// Global append order of a journal entry — the durability anchor.
204///
205/// Assigned by the database (an autoincrement / `BIGSERIAL` column), so it is monotonically
206/// increasing across all entries of all executions in a journal. Wraps an [`i64`] to match the
207/// column type.
208///
209/// # Examples
210///
211/// ```
212/// use zeph_durable::JournalSeq;
213///
214/// let first = JournalSeq::new(1);
215/// let second = JournalSeq::new(2);
216/// assert!(second > first);
217/// ```
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
219pub struct JournalSeq(i64);
220
221impl JournalSeq {
222    /// Wrap a database-assigned sequence number.
223    #[must_use]
224    pub fn new(value: i64) -> Self {
225        Self(value)
226    }
227
228    /// Return the raw sequence number.
229    #[must_use]
230    pub fn value(self) -> i64 {
231        self.0
232    }
233}
234
235/// Domain-separated deduplication key for a non-idempotent effect.
236///
237/// Derived with BLAKE3 in `derive_key` mode from `(execution_id, step_id, op_fingerprint)`. The
238/// derivation is injective (length-delimited input) so an attacker-controlled `op_fingerprint`
239/// cannot be crafted to collide with a different `(execution_id, step_id)` pair. The key is a
240/// *deduplication discriminator only* — never the sole trust basis for skipping a guarded effect.
241///
242/// # Examples
243///
244/// ```
245/// use zeph_durable::{ExecutionId, IdempotencyKey, StepId};
246///
247/// let exec = ExecutionId::new();
248/// let a = IdempotencyKey::derive(exec, StepId::new(0), b"transfer:acct-7");
249/// let b = IdempotencyKey::derive(exec, StepId::new(0), b"transfer:acct-7");
250/// let c = IdempotencyKey::derive(exec, StepId::new(1), b"transfer:acct-7");
251/// assert_eq!(a, b, "same inputs derive the same key");
252/// assert_ne!(a, c, "a different step derives a different key");
253/// ```
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
255pub struct IdempotencyKey([u8; 32]);
256
257impl IdempotencyKey {
258    /// Derive an idempotency key from the execution identity, step position, and an opaque
259    /// operation fingerprint.
260    ///
261    /// The fingerprint MUST be derived from non-secret descriptors only (e.g. a tool name and its
262    /// non-secret arguments); resolved secret material MUST NOT be passed here (INV-6).
263    ///
264    /// The input is length-delimited — `len(execution_id) || execution_id || len(step_id) ||
265    /// step_id || op_fingerprint` — so the field boundaries are unambiguous and the derivation is
266    /// injective. The fixed BLAKE3 `derive_key` context string keeps these keys disjoint from any
267    /// other BLAKE3 use in the workspace.
268    #[must_use]
269    pub fn derive(execution_id: ExecutionId, step_id: StepId, op_fingerprint: &[u8]) -> Self {
270        let exec_bytes = execution_id.as_bytes();
271        let step_bytes = step_id.value().to_le_bytes();
272        debug_assert_eq!(exec_bytes.len(), 16, "UUID is always 16 bytes");
273        debug_assert_eq!(step_bytes.len(), 4, "u32 is always 4 bytes");
274
275        // Length-prefix each fixed-width field (injective framing); the variable-length
276        // op_fingerprint is appended last, where its boundary is unambiguous.
277        let mut input = Vec::with_capacity(4 + 16 + 4 + 4 + op_fingerprint.len());
278        input.extend_from_slice(&16u32.to_le_bytes());
279        input.extend_from_slice(exec_bytes);
280        input.extend_from_slice(&4u32.to_le_bytes());
281        input.extend_from_slice(&step_bytes);
282        input.extend_from_slice(op_fingerprint);
283
284        Self(blake3::derive_key(IDEMPOTENCY_CONTEXT, &input))
285    }
286
287    /// Return the 32 raw key bytes.
288    #[must_use]
289    pub fn as_bytes(&self) -> &[u8; 32] {
290        &self.0
291    }
292
293    /// Reconstruct a key from its 32 stored bytes.
294    ///
295    /// Used by a journal backend to rebuild a key read back from storage; the bytes MUST originate
296    /// from a prior [`IdempotencyKey::as_bytes`] of a key produced by [`IdempotencyKey::derive`].
297    pub(crate) fn from_bytes(bytes: [u8; 32]) -> Self {
298        Self(bytes)
299    }
300}
301
302/// Reference to an external-completion handle (HITL, A2A async, subagent result).
303///
304/// A `PromiseId` is **not** a bearer capability: resolving a promise additionally requires a
305/// separate high-entropy resolver token (INV-9). The id is a `UUIDv7` so it is unguessable for
306/// practical purposes and time-ordered for indexing.
307///
308/// # Examples
309///
310/// ```
311/// use zeph_durable::PromiseId;
312///
313/// let id = PromiseId::new();
314/// assert_ne!(id, PromiseId::new());
315/// ```
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
317pub struct PromiseId(Uuid);
318
319impl PromiseId {
320    /// Mint a fresh promise identity.
321    #[must_use]
322    pub fn new() -> Self {
323        Self(Uuid::now_v7())
324    }
325
326    /// Derive the deterministic promise id for a `(execution_id, step_id)` position.
327    ///
328    /// Used by `promise()` so a resumed execution re-derives the same id at the same program point
329    /// and re-attaches to the pending `durable_promises` row instead of minting an orphan. The id is
330    /// guessable from the execution journal, but that is harmless: a `PromiseId` is *not* a bearer
331    /// capability (INV-9) — resolution requires the separate high-entropy resolver token.
332    ///
333    /// # Examples
334    ///
335    /// ```
336    /// use zeph_durable::{ExecutionId, PromiseId, StepId};
337    ///
338    /// let exec = ExecutionId::new();
339    /// let a = PromiseId::derive(exec, StepId::new(3));
340    /// let b = PromiseId::derive(exec, StepId::new(3));
341    /// assert_eq!(a, b, "the same position derives the same promise id");
342    /// assert_ne!(a, PromiseId::derive(exec, StepId::new(4)));
343    /// ```
344    #[must_use]
345    pub fn derive(execution_id: ExecutionId, step_id: StepId) -> Self {
346        Self(derive_position_uuid(
347            PROMISE_DERIVE_CONTEXT,
348            execution_id,
349            step_id,
350        ))
351    }
352
353    /// Return the underlying UUID.
354    #[must_use]
355    pub fn as_uuid(self) -> Uuid {
356        self.0
357    }
358}
359
360impl Default for PromiseId {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366/// Handle to a durable timer that wakes at a persisted instant, surviving process restarts.
367///
368/// # Examples
369///
370/// ```
371/// use zeph_durable::TimerId;
372///
373/// let id = TimerId::new();
374/// assert_ne!(id, TimerId::new());
375/// ```
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
377pub struct TimerId(Uuid);
378
379impl TimerId {
380    /// Mint a fresh timer identity.
381    #[must_use]
382    pub fn new() -> Self {
383        Self(Uuid::now_v7())
384    }
385
386    /// Derive the deterministic timer id for a `(execution_id, step_id)` position.
387    ///
388    /// As with [`PromiseId::derive`], a resumed `sleep_until` at the same program point re-derives
389    /// the same id and re-attaches to the journaled `durable_timers` row, so a timer that fired
390    /// during downtime is recognized on replay rather than re-armed afresh (FR-DE-06).
391    ///
392    /// # Examples
393    ///
394    /// ```
395    /// use zeph_durable::{ExecutionId, StepId, TimerId};
396    ///
397    /// let exec = ExecutionId::new();
398    /// assert_eq!(
399    ///     TimerId::derive(exec, StepId::new(1)),
400    ///     TimerId::derive(exec, StepId::new(1)),
401    /// );
402    /// ```
403    #[must_use]
404    pub fn derive(execution_id: ExecutionId, step_id: StepId) -> Self {
405        Self(derive_position_uuid(
406            TIMER_DERIVE_CONTEXT,
407            execution_id,
408            step_id,
409        ))
410    }
411
412    /// Reconstruct a timer identity from a [`Uuid`] read back from storage.
413    pub(crate) fn from_uuid(uuid: Uuid) -> Self {
414        Self(uuid)
415    }
416
417    /// Return the underlying UUID.
418    #[must_use]
419    pub fn as_uuid(self) -> Uuid {
420        self.0
421    }
422}
423
424impl Default for TimerId {
425    fn default() -> Self {
426        Self::new()
427    }
428}
429
430/// Closed classification of what a durable execution represents.
431///
432/// A closed enum (rather than a free-form string) prevents typos and lets the retention policy
433/// reason about execution categories. The `Custom` variant carries a compile-time string literal
434/// for execution kinds defined outside the standard set.
435///
436/// # Examples
437///
438/// ```
439/// use zeph_durable::ExecutionKind;
440///
441/// assert_eq!(ExecutionKind::AgentTurn.as_str(), "agent_turn");
442/// assert_eq!(ExecutionKind::Custom("nightly_sweep").as_str(), "nightly_sweep");
443/// ```
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
445pub enum ExecutionKind {
446    /// A single agent reasoning turn (the P1 adapter target).
447    AgentTurn,
448    /// An orchestration DAG run (the P2 adapter target).
449    DagRun,
450    /// A scheduler job fire (the P3 adapter target).
451    ScheduledJob,
452    /// A subagent session (the P4 adapter target).
453    SubagentSession,
454    /// A caller-defined execution kind identified by a compile-time literal.
455    Custom(&'static str),
456}
457
458impl ExecutionKind {
459    /// Return the canonical lower-snake-case string used in the `kind` journal column.
460    ///
461    /// For [`ExecutionKind::Custom`] the inner literal is returned verbatim.
462    #[must_use]
463    pub fn as_str(&self) -> &'static str {
464        match self {
465            Self::AgentTurn => "agent_turn",
466            Self::DagRun => "dag_run",
467            Self::ScheduledJob => "scheduled_job",
468            Self::SubagentSession => "subagent_session",
469            Self::Custom(name) => name,
470        }
471    }
472
473    /// Reconstruct a standard execution kind from its canonical column string.
474    ///
475    /// Returns `None` for an unrecognized tag. [`ExecutionKind::Custom`] cannot round-trip from
476    /// storage — its inner `&'static str` has no representation recoverable from a dynamic database
477    /// string — so a custom kind read back from the journal is reported as unrecognized rather than
478    /// silently coerced.
479    pub(crate) fn from_tag(tag: &str) -> Option<Self> {
480        match tag {
481            "agent_turn" => Some(Self::AgentTurn),
482            "dag_run" => Some(Self::DagRun),
483            "scheduled_job" => Some(Self::ScheduledJob),
484            "subagent_session" => Some(Self::SubagentSession),
485            _ => None,
486        }
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn execution_id_new_is_unique() {
496        let a = ExecutionId::new();
497        let b = ExecutionId::new();
498        assert_ne!(a, b);
499    }
500
501    #[test]
502    fn promise_and_timer_ids_are_unique() {
503        assert_ne!(PromiseId::new(), PromiseId::new());
504        assert_ne!(TimerId::new(), TimerId::new());
505    }
506
507    #[test]
508    fn execution_id_serde_round_trip() {
509        let id = ExecutionId::new();
510        let json = serde_json::to_string(&id).unwrap();
511        let back: ExecutionId = serde_json::from_str(&json).unwrap();
512        assert_eq!(id, back);
513        // Verify the JSON shape is a bare UUID string (not {"0":"..."} or a wrapped object).
514        assert!(
515            json.starts_with('"') && json.ends_with('"'),
516            "ExecutionId must serialize as a bare UUID string, got: {json}"
517        );
518    }
519
520    #[test]
521    fn step_id_serde_round_trip_and_accessor() {
522        let step = StepId::new(42);
523        assert_eq!(step.value(), 42);
524        let json = serde_json::to_string(&step).unwrap();
525        let back: StepId = serde_json::from_str(&json).unwrap();
526        assert_eq!(step, back);
527    }
528
529    #[test]
530    fn journal_seq_serde_round_trip_and_ordering() {
531        let seq = JournalSeq::new(99);
532        assert_eq!(seq.value(), 99);
533        assert!(JournalSeq::new(2) > JournalSeq::new(1));
534        let json = serde_json::to_string(&seq).unwrap();
535        let back: JournalSeq = serde_json::from_str(&json).unwrap();
536        assert_eq!(seq, back);
537    }
538
539    #[test]
540    fn derived_promise_and_timer_ids_are_position_stable_and_disjoint() {
541        let exec = ExecutionId::new();
542        let other = ExecutionId::new();
543        // Deterministic for a fixed position…
544        assert_eq!(
545            PromiseId::derive(exec, StepId::new(2)),
546            PromiseId::derive(exec, StepId::new(2))
547        );
548        assert_eq!(
549            TimerId::derive(exec, StepId::new(2)),
550            TimerId::derive(exec, StepId::new(2))
551        );
552        // …yet distinct across step, execution, and the promise/timer domain separation.
553        assert_ne!(
554            PromiseId::derive(exec, StepId::new(2)),
555            PromiseId::derive(exec, StepId::new(3))
556        );
557        assert_ne!(
558            PromiseId::derive(exec, StepId::new(2)),
559            PromiseId::derive(other, StepId::new(2))
560        );
561        let promise = PromiseId::derive(exec, StepId::new(2)).as_uuid();
562        let timer = TimerId::derive(exec, StepId::new(2)).as_uuid();
563        assert_ne!(
564            promise, timer,
565            "promise and timer ids never collide at the same position"
566        );
567        assert_eq!(promise.get_version_num(), 8, "derived ids are UUIDv8");
568    }
569
570    #[test]
571    fn promise_and_timer_serde_round_trip() {
572        let promise = PromiseId::new();
573        let timer = TimerId::new();
574        let pj = serde_json::to_string(&promise).unwrap();
575        let tj = serde_json::to_string(&timer).unwrap();
576        assert_eq!(promise, serde_json::from_str::<PromiseId>(&pj).unwrap());
577        assert_eq!(timer, serde_json::from_str::<TimerId>(&tj).unwrap());
578    }
579
580    #[test]
581    fn idempotency_key_serde_round_trip() {
582        let key = IdempotencyKey::derive(ExecutionId::new(), StepId::new(3), b"op");
583        let json = serde_json::to_string(&key).unwrap();
584        let back: IdempotencyKey = serde_json::from_str(&json).unwrap();
585        assert_eq!(key, back);
586    }
587
588    #[test]
589    fn idempotency_key_is_deterministic() {
590        let exec = ExecutionId::new();
591        let a = IdempotencyKey::derive(exec, StepId::new(5), b"tool:read");
592        let b = IdempotencyKey::derive(exec, StepId::new(5), b"tool:read");
593        assert_eq!(a, b);
594    }
595
596    #[test]
597    fn idempotency_key_varies_with_each_input() {
598        let exec = ExecutionId::new();
599        let other = ExecutionId::new();
600        let base = IdempotencyKey::derive(exec, StepId::new(0), b"op");
601        assert_ne!(base, IdempotencyKey::derive(other, StepId::new(0), b"op"));
602        assert_ne!(base, IdempotencyKey::derive(exec, StepId::new(1), b"op"));
603        assert_ne!(base, IdempotencyKey::derive(exec, StepId::new(0), b"op2"));
604    }
605
606    #[test]
607    fn idempotency_key_framing_is_injective() {
608        // The length-delimited framing keeps the step_id/op_fingerprint boundary unambiguous:
609        // moving the step bytes into the fingerprint must change the derived key. A naive
610        // concatenation that merged the two fields could collide here.
611        let exec = ExecutionId::new();
612        let with_step = IdempotencyKey::derive(exec, StepId::new(2), b"");
613        let with_fingerprint = IdempotencyKey::derive(exec, StepId::new(0), &2u32.to_le_bytes());
614        assert_ne!(with_step, with_fingerprint);
615    }
616
617    #[test]
618    fn execution_kind_as_str_is_stable() {
619        assert_eq!(ExecutionKind::AgentTurn.as_str(), "agent_turn");
620        assert_eq!(ExecutionKind::DagRun.as_str(), "dag_run");
621        assert_eq!(ExecutionKind::ScheduledJob.as_str(), "scheduled_job");
622        assert_eq!(ExecutionKind::SubagentSession.as_str(), "subagent_session");
623        assert_eq!(ExecutionKind::Custom("x").as_str(), "x");
624    }
625}