use std::fmt;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const IDEMPOTENCY_CONTEXT: &str = "zeph-durable v1 idempotency-key 2026";
const PROMISE_DERIVE_CONTEXT: &str = "zeph-durable v1 promise-id 2026";
const TIMER_DERIVE_CONTEXT: &str = "zeph-durable v1 timer-id 2026";
fn derive_position_uuid(context: &str, execution_id: ExecutionId, step_id: StepId) -> Uuid {
let mut input = [0u8; 20];
input[..16].copy_from_slice(execution_id.as_bytes());
input[16..].copy_from_slice(&step_id.value().to_le_bytes());
let hash = blake3::derive_key(context, &input);
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&hash[..16]);
Uuid::new_v8(bytes)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ExecutionId(Uuid);
impl ExecutionId {
#[must_use]
pub fn new() -> Self {
Self(Uuid::now_v7())
}
#[must_use]
pub fn as_uuid(self) -> Uuid {
self.0
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; 16] {
self.0.as_bytes()
}
pub(crate) fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn parse_str(s: &str) -> Result<Self, uuid::Error> {
Ok(Self::from_uuid(Uuid::parse_str(s)?))
}
#[must_use]
pub fn derive(domain: &[u8], payload: &[u8]) -> Self {
const DERIVE_CONTEXT: &str = "zeph-durable v1 execution-id derive 2026";
let mut input = Vec::with_capacity(8 + domain.len() + payload.len());
input.extend_from_slice(&(domain.len() as u64).to_le_bytes());
input.extend_from_slice(domain);
input.extend_from_slice(payload);
let hash = blake3::derive_key(DERIVE_CONTEXT, &input);
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&hash[..16]);
Self(Uuid::new_v8(bytes))
}
}
impl Default for ExecutionId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ExecutionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct StepId(u32);
impl StepId {
#[must_use]
pub fn new(value: u32) -> Self {
Self(value)
}
#[must_use]
pub fn value(self) -> u32 {
self.0
}
}
impl fmt::Display for StepId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct JournalSeq(i64);
impl JournalSeq {
#[must_use]
pub fn new(value: i64) -> Self {
Self(value)
}
#[must_use]
pub fn value(self) -> i64 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct IdempotencyKey([u8; 32]);
impl IdempotencyKey {
#[must_use]
pub fn derive(execution_id: ExecutionId, step_id: StepId, op_fingerprint: &[u8]) -> Self {
let exec_bytes = execution_id.as_bytes();
let step_bytes = step_id.value().to_le_bytes();
debug_assert_eq!(exec_bytes.len(), 16, "UUID is always 16 bytes");
debug_assert_eq!(step_bytes.len(), 4, "u32 is always 4 bytes");
let mut input = Vec::with_capacity(4 + 16 + 4 + 4 + op_fingerprint.len());
input.extend_from_slice(&16u32.to_le_bytes());
input.extend_from_slice(exec_bytes);
input.extend_from_slice(&4u32.to_le_bytes());
input.extend_from_slice(&step_bytes);
input.extend_from_slice(op_fingerprint);
Self(blake3::derive_key(IDEMPOTENCY_CONTEXT, &input))
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub(crate) fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PromiseId(Uuid);
impl PromiseId {
#[must_use]
pub fn new() -> Self {
Self(Uuid::now_v7())
}
#[must_use]
pub fn derive(execution_id: ExecutionId, step_id: StepId) -> Self {
Self(derive_position_uuid(
PROMISE_DERIVE_CONTEXT,
execution_id,
step_id,
))
}
#[must_use]
pub fn as_uuid(self) -> Uuid {
self.0
}
}
impl Default for PromiseId {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TimerId(Uuid);
impl TimerId {
#[must_use]
pub fn new() -> Self {
Self(Uuid::now_v7())
}
#[must_use]
pub fn derive(execution_id: ExecutionId, step_id: StepId) -> Self {
Self(derive_position_uuid(
TIMER_DERIVE_CONTEXT,
execution_id,
step_id,
))
}
pub(crate) fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
#[must_use]
pub fn as_uuid(self) -> Uuid {
self.0
}
}
impl Default for TimerId {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExecutionKind {
AgentTurn,
DagRun,
ScheduledJob,
SubagentSession,
Custom(&'static str),
}
impl ExecutionKind {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::AgentTurn => "agent_turn",
Self::DagRun => "dag_run",
Self::ScheduledJob => "scheduled_job",
Self::SubagentSession => "subagent_session",
Self::Custom(name) => name,
}
}
pub(crate) fn from_tag(tag: &str) -> Option<Self> {
match tag {
"agent_turn" => Some(Self::AgentTurn),
"dag_run" => Some(Self::DagRun),
"scheduled_job" => Some(Self::ScheduledJob),
"subagent_session" => Some(Self::SubagentSession),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn execution_id_new_is_unique() {
let a = ExecutionId::new();
let b = ExecutionId::new();
assert_ne!(a, b);
}
#[test]
fn promise_and_timer_ids_are_unique() {
assert_ne!(PromiseId::new(), PromiseId::new());
assert_ne!(TimerId::new(), TimerId::new());
}
#[test]
fn execution_id_display_matches_uuid() {
let id = ExecutionId::new();
assert_eq!(id.to_string(), id.as_uuid().to_string());
}
#[test]
fn execution_id_serde_round_trip() {
let id = ExecutionId::new();
let json = serde_json::to_string(&id).unwrap();
let back: ExecutionId = serde_json::from_str(&json).unwrap();
assert_eq!(id, back);
assert!(
json.starts_with('"') && json.ends_with('"'),
"ExecutionId must serialize as a bare UUID string, got: {json}"
);
}
#[test]
fn step_id_serde_round_trip_and_accessor() {
let step = StepId::new(42);
assert_eq!(step.value(), 42);
let json = serde_json::to_string(&step).unwrap();
let back: StepId = serde_json::from_str(&json).unwrap();
assert_eq!(step, back);
}
#[test]
fn journal_seq_serde_round_trip_and_ordering() {
let seq = JournalSeq::new(99);
assert_eq!(seq.value(), 99);
assert!(JournalSeq::new(2) > JournalSeq::new(1));
let json = serde_json::to_string(&seq).unwrap();
let back: JournalSeq = serde_json::from_str(&json).unwrap();
assert_eq!(seq, back);
}
#[test]
fn derived_promise_and_timer_ids_are_position_stable_and_disjoint() {
let exec = ExecutionId::new();
let other = ExecutionId::new();
assert_eq!(
PromiseId::derive(exec, StepId::new(2)),
PromiseId::derive(exec, StepId::new(2))
);
assert_eq!(
TimerId::derive(exec, StepId::new(2)),
TimerId::derive(exec, StepId::new(2))
);
assert_ne!(
PromiseId::derive(exec, StepId::new(2)),
PromiseId::derive(exec, StepId::new(3))
);
assert_ne!(
PromiseId::derive(exec, StepId::new(2)),
PromiseId::derive(other, StepId::new(2))
);
let promise = PromiseId::derive(exec, StepId::new(2)).as_uuid();
let timer = TimerId::derive(exec, StepId::new(2)).as_uuid();
assert_ne!(
promise, timer,
"promise and timer ids never collide at the same position"
);
assert_eq!(promise.get_version_num(), 8, "derived ids are UUIDv8");
}
#[test]
fn promise_and_timer_serde_round_trip() {
let promise = PromiseId::new();
let timer = TimerId::new();
let pj = serde_json::to_string(&promise).unwrap();
let tj = serde_json::to_string(&timer).unwrap();
assert_eq!(promise, serde_json::from_str::<PromiseId>(&pj).unwrap());
assert_eq!(timer, serde_json::from_str::<TimerId>(&tj).unwrap());
}
#[test]
fn idempotency_key_serde_round_trip() {
let key = IdempotencyKey::derive(ExecutionId::new(), StepId::new(3), b"op");
let json = serde_json::to_string(&key).unwrap();
let back: IdempotencyKey = serde_json::from_str(&json).unwrap();
assert_eq!(key, back);
}
#[test]
fn idempotency_key_is_deterministic() {
let exec = ExecutionId::new();
let a = IdempotencyKey::derive(exec, StepId::new(5), b"tool:read");
let b = IdempotencyKey::derive(exec, StepId::new(5), b"tool:read");
assert_eq!(a, b);
}
#[test]
fn idempotency_key_varies_with_each_input() {
let exec = ExecutionId::new();
let other = ExecutionId::new();
let base = IdempotencyKey::derive(exec, StepId::new(0), b"op");
assert_ne!(base, IdempotencyKey::derive(other, StepId::new(0), b"op"));
assert_ne!(base, IdempotencyKey::derive(exec, StepId::new(1), b"op"));
assert_ne!(base, IdempotencyKey::derive(exec, StepId::new(0), b"op2"));
}
#[test]
fn idempotency_key_framing_is_injective() {
let exec = ExecutionId::new();
let with_step = IdempotencyKey::derive(exec, StepId::new(2), b"");
let with_fingerprint = IdempotencyKey::derive(exec, StepId::new(0), &2u32.to_le_bytes());
assert_ne!(with_step, with_fingerprint);
}
#[test]
fn execution_kind_as_str_is_stable() {
assert_eq!(ExecutionKind::AgentTurn.as_str(), "agent_turn");
assert_eq!(ExecutionKind::DagRun.as_str(), "dag_run");
assert_eq!(ExecutionKind::ScheduledJob.as_str(), "scheduled_job");
assert_eq!(ExecutionKind::SubagentSession.as_str(), "subagent_session");
assert_eq!(ExecutionKind::Custom("x").as_str(), "x");
}
}