rightkit_agent_engine_protocol/ids.rs
1//! Canonical stable identifiers for sessions, forks, events, and transcript
2//! messages.
3//!
4//! Newly minted identities use RFC 9562 UUIDv7 — the 48 most-significant bits
5//! are the Unix-epoch timestamp in milliseconds (monotonic per process, sort-
6//! equivalent to creation order), the next 4 bits are the version, the next
7//! 2 bits are the variant, and the remaining 62 bits are random.
8//!
9//! Identity is implemented in pure Rust (no `uuid` crate, no `rand` crate) so
10//! F1 stays within the protocol crate's locked dependency set. The PRNG is a
11//! `splitmix64` seeded from system time + process id + an atomic counter, so
12//! on any single platform a sequence of mints produced at distinct timestamps
13//! is unique with overwhelming probability and still satisfies the RFC's
14//! "unpredictable" requirement (the 62 random bits are sufficient for the
15//! version-7 collision domain and there is no security claim at stake here;
16//! the F1 deliverable explicitly says "stable identity, not security").
17//!
18//! Producers (the engine loop) still pick constant message ids (`final`,
19//! `forced-stop`, `graceful-summary`, ...) and `seq` independently of the
20//! database row sequence. The id helpers below derive every message identity
21//! from the persisted row sequence at the storage boundary, so every message
22//! gets a `<kind>-<row_seq>` identity and React keys stay unique and stable
23//! (per the rendering-lists contract).
24
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28/// Process-local sequence mixed into every UUIDv7 mint so a sequence of
29/// mints produced in the same millisecond — the boundary case RFC 9562 §6.1
30/// warns about — is still unique.
31static COUNTER: AtomicU64 = AtomicU64::new(0);
32
33/// State for the splitmix64 PRNG. Each call advances the state and produces
34/// 64 random bits.
35struct SplitMix64(u64);
36
37impl SplitMix64 {
38 fn next(&mut self) -> u64 {
39 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
40 let mut z = self.0;
41 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
42 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
43 z ^ (z >> 31)
44 }
45}
46
47/// Build the per-process initial seed from the current wall-clock ms, the
48/// process id (best-effort), and an atomic counter. Each call advances the
49/// counter so back-to-back seeds are still distinct.
50fn seed_mix() -> u64 {
51 let nanos = SystemTime::now()
52 .duration_since(UNIX_EPOCH)
53 .map(|d| d.as_nanos() as u64)
54 .unwrap_or(0);
55 let pid = std::process::id() as u64;
56 let ctr = COUNTER.fetch_add(1, Ordering::Relaxed);
57 nanos ^ pid.rotate_left(17) ^ ctr.rotate_left(31)
58}
59
60/// Mint a UUIDv7 string. Output format is the canonical hyphenated
61/// 36-character lowercase form, equivalent to a UUID-printable BNF: 8-4-4-4-12
62/// hex with `version=7` and `variant=8/9/a/b`.
63pub fn uuid_v7() -> String {
64 let ms: u64 = SystemTime::now()
65 .duration_since(UNIX_EPOCH)
66 .map(|d| d.as_millis() as u64)
67 .unwrap_or(0);
68
69 let mut sm = SplitMix64(seed_mix());
70 let r1 = sm.next();
71 let r2 = sm.next();
72
73 let mut bytes = [0u8; 16];
74 bytes[0..6].copy_from_slice(&ms.to_be_bytes()[2..8]);
75 bytes[6] = ((ms >> 8) & 0x0f) as u8 | 0x70;
76 bytes[7] = (ms & 0xff) as u8;
77 bytes[8] = ((r1 & 0x3f) as u8) | 0x80;
78 bytes[9] = ((r1 >> 8) & 0xff) as u8;
79 bytes[10] = ((r1 >> 16) & 0xff) as u8;
80 bytes[11] = ((r1 >> 24) & 0xff) as u8;
81 bytes[12] = ((r1 >> 32) & 0xff) as u8;
82 bytes[13] = ((r1 >> 40) & 0xff) as u8;
83 bytes[14] = (r2 & 0xff) as u8;
84 bytes[15] = ((r2 >> 8) & 0xff) as u8;
85
86 format!(
87 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
88 bytes[0], bytes[1], bytes[2], bytes[3],
89 bytes[4], bytes[5],
90 bytes[6], bytes[7],
91 bytes[8], bytes[9],
92 bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
93 )
94}
95
96/// Predicate — cheaply identifies any RFC 9562 v7 UUID string. Used by the
97/// storage boundary to decide whether an incoming identity is the new
98/// canonical shape (mint-vs-rewrite).
99pub fn is_canonical_uuid_v7(id: &str) -> bool {
100 if id.len() != 36 {
101 return false;
102 }
103 let bytes = id.as_bytes();
104 bytes[8] == b'-'
105 && bytes[13] == b'-'
106 && bytes[18] == b'-'
107 && bytes[23] == b'-'
108 && bytes[14] == b'7'
109 && matches!(bytes[19], b'8' | b'9' | b'a' | b'b')
110}
111
112/// Build the canonical id for a transcript message at `row_seq`. Independent
113/// of the payload-supplied id so two rows whose producers both chose the same
114/// constant id are still uniquely keyed by their persisted sequence.
115pub fn message_id(row_seq: i64) -> String {
116 format!("message-{row_seq}")
117}
118
119/// Canonical id for a `ToolCallStarted` origin row. This is the row-level
120/// identity an awaited engine dispatch consults; the protocol event itself
121/// still carries a `seq`-derived display id, but the durable record uses this
122/// shape so a tool result can deterministically find its origin at replay
123/// time.
124pub fn tool_call_origin_id(call_id: &str, row_seq: i64) -> String {
125 format!("tool_call_origin:{call_id}:{row_seq}")
126}
127
128/// Canonical id for a `TranscriptMessage` carried inside a tool result.
129pub fn tool_result_id(call_id: &str, row_seq: i64) -> String {
130 format!("tool:{call_id}:{row_seq}")
131}
132
133/// Canonical id for an assistant message at `row_seq`.
134pub fn assistant_message_id(row_seq: i64) -> String {
135 format!("assistant:{row_seq}")
136}