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}