Skip to main content

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}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn uuid_v7_has_canonical_version_and_variant() {
144        let id = uuid_v7();
145        assert_eq!(id.len(), 36);
146        assert!(is_canonical_uuid_v7(&id), "{id} not v7");
147    }
148
149    #[test]
150    fn uuid_v7_is_lexicographically_sortable_by_minting_time() {
151        let a = uuid_v7();
152        std::thread::sleep(std::time::Duration::from_millis(2));
153        let b = uuid_v7();
154        assert!(a < b, "expected {a} < {b}");
155    }
156
157    #[test]
158    fn sequential_mints_differ_in_their_random_tail() {
159        let a = uuid_v7();
160        let b = uuid_v7();
161        assert_ne!(a, b);
162    }
163
164    #[test]
165    fn message_id_derives_from_row_seq() {
166        assert_eq!(message_id(7), "message-7");
167        assert_eq!(tool_call_origin_id("tc1", 11), "tool_call_origin:tc1:11");
168        assert_eq!(tool_result_id("tc1", 11), "tool:tc1:11");
169        assert_eq!(assistant_message_id(11), "assistant:11");
170    }
171
172    #[test]
173    fn is_canonical_uuid_v7_only_accepts_v7() {
174        assert!(is_canonical_uuid_v7("01920000-0000-7000-8000-000000000000"));
175        assert!(!is_canonical_uuid_v7(
176            "01920000-0000-4000-8000-000000000000"
177        ));
178        assert!(!is_canonical_uuid_v7("not-a-uuid"));
179        assert!(!is_canonical_uuid_v7(""));
180    }
181}