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}