turnframe_core/ids.rs
1//! Identity and version newtypes (spec §7).
2//!
3//! Every identifier that crosses a boundary is a dedicated type so that a
4//! `CaseId` can never be passed where an `InteractionId` is expected and so that
5//! public signatures never carry raw strings or integers for identities.
6//!
7//! Two families exist:
8//!
9//! * **UUID-backed** identifiers ([`ConversationId`], [`TurnId`], [`InteractionId`],
10//! [`CommandId`], [`EventId`], [`ReceiptId`], [`BatchId`], [`OutboxId`]) are
11//! generated server-side with [`Uuid::now_v7`] so they sort by creation time.
12//! * **String-backed** identifiers ([`CaseId`], [`WorkflowKey`], [`WorkflowVersion`],
13//! [`TargetToken`], [`OptionId`], [`OperationKey`], [`ReadToolKey`], [`AccountId`],
14//! [`UserId`], [`SchemaVersion`], [`AttachmentId`], [`OriginToken`], [`QuestionId`],
15//! [`BlockId`], [`ReadRequestId`], [`ProviderKey`], [`ModelKey`], [`AttemptId`])
16//! are opaque labels owned by the application or the server.
17//!
18//! Neither family implements [`Default`]: an identifier is either derived from
19//! its inputs, generated on purpose, or [`nil`](TurnId::nil) in a fixture.
20//!
21//! All of them serialize transparently (a UUID string or a plain string) and
22//! derive [`schemars::JsonSchema`] so they can appear in model-facing schemas.
23
24use std::fmt;
25
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28use uuid::Uuid;
29
30macro_rules! uuid_id {
31 ($(#[$meta:meta])* $name:ident) => {
32 #[derive(
33 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
34 )]
35 #[serde(transparent)]
36 $(#[$meta])*
37 pub struct $name(pub Uuid);
38
39 impl $name {
40 /// Generates a new time-ordered (UUID v7) identifier.
41 ///
42 /// Identifiers that must be reproducible across a replay are
43 /// derived instead: see `derive` on the types that have one.
44 // Deliberately no `Default`: minting a fresh identity is an act,
45 // not a default value, and `#[derive(Default)]` on a struct that
46 // contains one would fabricate identities silently.
47 #[allow(clippy::new_without_default)]
48 #[must_use]
49 pub fn new() -> Self {
50 Self(Uuid::now_v7())
51 }
52
53 /// The all-zero identifier, useful as a sentinel in tests and fixtures.
54 #[must_use]
55 pub const fn nil() -> Self {
56 Self(Uuid::nil())
57 }
58
59 /// Returns the underlying UUID.
60 #[must_use]
61 pub const fn as_uuid(&self) -> &Uuid {
62 &self.0
63 }
64
65 /// Returns `true` when this is the nil identifier.
66 #[must_use]
67 pub fn is_nil(&self) -> bool {
68 self.0.is_nil()
69 }
70 }
71
72 impl From<Uuid> for $name {
73 fn from(value: Uuid) -> Self {
74 Self(value)
75 }
76 }
77
78 impl From<$name> for Uuid {
79 fn from(value: $name) -> Self {
80 value.0
81 }
82 }
83
84 impl fmt::Display for $name {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 fmt::Display::fmt(&self.0.hyphenated(), f)
87 }
88 }
89
90 impl std::str::FromStr for $name {
91 type Err = uuid::Error;
92
93 fn from_str(s: &str) -> Result<Self, Self::Err> {
94 Uuid::parse_str(s).map(Self)
95 }
96 }
97 };
98}
99
100macro_rules! string_id {
101 ($(#[$meta:meta])* $name:ident) => {
102 #[derive(
103 Debug,
104 Clone,
105 PartialEq,
106 Eq,
107 Hash,
108 PartialOrd,
109 Ord,
110 ::serde::Serialize,
111 ::serde::Deserialize,
112 ::schemars::JsonSchema,
113 )]
114 #[serde(transparent)]
115 $(#[$meta])*
116 pub struct $name(pub String);
117
118 impl $name {
119 /// Wraps an existing label.
120 #[must_use]
121 pub fn new(value: impl Into<String>) -> Self {
122 Self(value.into())
123 }
124
125 /// Borrows the label as a string slice.
126 #[must_use]
127 pub fn as_str(&self) -> &str {
128 &self.0
129 }
130
131 /// Consumes the identifier and returns the owned label.
132 #[must_use]
133 pub fn into_string(self) -> String {
134 self.0
135 }
136
137 /// Returns `true` when the label is empty.
138 #[must_use]
139 pub fn is_empty(&self) -> bool {
140 self.0.is_empty()
141 }
142 }
143
144 impl From<&str> for $name {
145 fn from(value: &str) -> Self {
146 Self(value.to_owned())
147 }
148 }
149
150 impl From<String> for $name {
151 fn from(value: String) -> Self {
152 Self(value)
153 }
154 }
155
156 impl From<$name> for String {
157 fn from(value: $name) -> Self {
158 value.0
159 }
160 }
161
162 impl AsRef<str> for $name {
163 fn as_ref(&self) -> &str {
164 &self.0
165 }
166 }
167
168 impl std::borrow::Borrow<str> for $name {
169 fn borrow(&self) -> &str {
170 &self.0
171 }
172 }
173
174 impl ::std::fmt::Display for $name {
175 fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
176 f.write_str(&self.0)
177 }
178 }
179 };
180}
181
182pub(crate) use string_id;
183
184uuid_id! {
185 /// Identifies one conversation (a chat thread) within an account.
186 ConversationId
187}
188
189uuid_id! {
190 /// Identifies one user turn. Every command, replay record and response block
191 /// produced while handling the turn carries it.
192 TurnId
193}
194
195uuid_id! {
196 /// Identifies one persisted interaction (card, confirmation, selection...).
197 InteractionId
198}
199
200uuid_id! {
201 /// Identifies one typed command envelope. Distinct from the idempotency key:
202 /// two envelopes may share an idempotency key when a turn is replayed.
203 CommandId
204}
205
206uuid_id! {
207 /// Identifies one committed domain event in the claim ledger.
208 EventId
209}
210
211uuid_id! {
212 /// Identifies one operational receipt derived from committed events.
213 ReceiptId
214}
215
216uuid_id! {
217 /// Identifies one command batch (a group of envelopes committed under one
218 /// [`AtomicityScope`](crate::command::AtomicityScope)).
219 BatchId
220}
221
222uuid_id! {
223 /// Identifies one outbox row for an external side effect.
224 OutboxId
225}
226
227string_id! {
228 /// Application-owned identifier of a workflow case (a trip, a
229 /// traveler record...). Opaque to the library; never shown to the model.
230 CaseId
231}
232
233string_id! {
234 /// Stable key of a workflow definition, e.g. `"trip"`.
235 WorkflowKey
236}
237
238string_id! {
239 /// Version label of a workflow definition. Must change whenever projection
240 /// semantics change (spec §8.4).
241 WorkflowVersion
242}
243
244string_id! {
245 /// Opaque token shown to the model instead of a raw case identifier. The
246 /// server owns the token → [`CaseRef`](crate::case::CaseRef) mapping.
247 #[schemars(
248 description = "Opaque handle for one record, taken from the list of candidates offered with this turn. It is meaningless outside the turn and is never a database identifier."
249 )]
250 TargetToken
251}
252
253string_id! {
254 /// Identifier of a stored option on an interaction. The client echoes it
255 /// back; the server derives its meaning from the stored option.
256 OptionId
257}
258
259string_id! {
260 /// Key of a semantic operation offered to the interpreter, e.g.
261 /// `"trip.set_travel_date"`. Only keys listed in the catalog of this turn
262 /// may be used.
263 OperationKey
264}
265
266string_id! {
267 /// Key of a read-only tool offered to the bounded read loop.
268 ReadToolKey
269}
270
271string_id! {
272 /// Tenant identifier. Every lookup in the library is scoped by it.
273 AccountId
274}
275
276string_id! {
277 /// Identifier of the authenticated user acting within an account.
278 UserId
279}
280
281string_id! {
282 /// Version label of a model-facing schema (e.g. the interpreter output).
283 SchemaVersion
284}
285
286string_id! {
287 /// Identifier of an attachment supplied with a turn.
288 AttachmentId
289}
290
291string_id! {
292 /// Server-issued token that a UI surface attaches to a turn to name the
293 /// exact record it was opened from (spec §12.4).
294 #[schemars(
295 description = "Handle for the record the user has open, supplied by the interface with this turn."
296 )]
297 OriginToken
298}
299
300string_id! {
301 /// Stable identifier of a question within a turn (spec §19.1).
302 QuestionId
303}
304
305string_id! {
306 /// Stable identifier of a response block within an assistant turn.
307 BlockId
308}
309
310string_id! {
311 /// Identifier the model assigns to a read request so results can be matched
312 /// back to it.
313 ReadRequestId
314}
315
316string_id! {
317 /// Stable key of a configured model provider, e.g. `"openai"`. Never a
318 /// credential and never a URL.
319 ProviderKey
320}
321
322string_id! {
323 /// Stable key of a configured model, e.g. `"gpt-5.4"`. The configured
324 /// identifier, not a marketing name.
325 ModelKey
326}
327
328string_id! {
329 /// Names the authority under which an event payload was erased, e.g. an
330 /// erasure ticket, a retention policy key or an operator identifier
331 /// ([`EventRedaction`](crate::event::EventRedaction)).
332 ///
333 /// It is the *only* thing an erasure records beyond its timestamp, so it
334 /// must be a stable label the operator can trace back to the request that
335 /// justified it, and never the data that was removed, the subject's name or
336 /// any other free text: it survives in the ledger precisely because the
337 /// payload did not.
338 RedactionAuthority
339}
340
341string_id! {
342 /// Identifier of one attempt at an external effect, scoped to the outbox or
343 /// the dispatcher, used to reconcile an unknown outcome (I15).
344 ///
345 /// # Why this is an identifier and a model call attempt is a number
346 ///
347 /// [`ProviderAttemptRecord::attempt`](crate::replay::ProviderAttemptRecord::attempt)
348 /// counts model calls with a plain `u32`, and that asymmetry is deliberate.
349 ///
350 /// A model call has no effect outside the process: if it times out, the
351 /// only thing anyone ever needs to say about it is *which try it was*,
352 /// within a stage whose identity the replay record already fixes (the turn,
353 /// the purpose, the provider and the model). Nothing outside the record
354 /// refers to it, so it needs a position, not a name.
355 ///
356 /// An external effect attempt is the opposite. It may have happened even
357 /// though the answer never arrived (spec §16.5), which is what
358 /// `OutcomeUnknown` means, and settling it later requires naming the exact
359 /// attempt to a remote system that has its own idea of what it received.
360 /// That name is passed to the dispatcher as an idempotency-scoped token,
361 /// stored on the outbox row, quoted in a reconciliation query and compared
362 /// against a remote reference. An ordinal would be ambiguous the moment two
363 /// workers, two rows or two turns counted separately; an identifier is not.
364 AttemptId
365}
366
367/// Monotonic revision of a mutable case (spec §7, I13).
368///
369/// Every command targets an expected revision; a mismatch is a
370/// [`RevisionConflict`](crate::error::RevisionConflict).
371#[derive(
372 Debug,
373 Clone,
374 Copy,
375 PartialEq,
376 Eq,
377 Hash,
378 PartialOrd,
379 Ord,
380 Default,
381 Serialize,
382 Deserialize,
383 JsonSchema,
384)]
385#[serde(transparent)]
386pub struct CaseRevision(pub u64);
387
388impl CaseRevision {
389 /// The revision of a case that does not exist yet.
390 pub const ZERO: Self = Self(0);
391
392 /// Returns the revision that follows this one.
393 #[must_use]
394 pub const fn next(self) -> Self {
395 Self(self.0.saturating_add(1))
396 }
397
398 /// Returns the raw counter.
399 #[must_use]
400 pub const fn value(self) -> u64 {
401 self.0
402 }
403}
404
405impl From<u64> for CaseRevision {
406 fn from(value: u64) -> Self {
407 Self(value)
408 }
409}
410
411impl fmt::Display for CaseRevision {
412 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
413 write!(f, "{}", self.0)
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn uuid_ids_round_trip_and_are_time_ordered() {
423 let a = TurnId::new();
424 let b = TurnId::new();
425 assert!(a <= b, "v7 identifiers sort by creation time");
426 let json = serde_json::to_string(&a).unwrap();
427 assert!(json.starts_with('"'));
428 let back: TurnId = serde_json::from_str(&json).unwrap();
429 assert_eq!(a, back);
430 assert_eq!(a.to_string().parse::<TurnId>().unwrap(), a);
431 assert!(TurnId::nil().is_nil());
432 }
433
434 #[test]
435 fn string_ids_are_transparent() {
436 let key = WorkflowKey::from("trip");
437 assert_eq!(serde_json::to_string(&key).unwrap(), "\"trip\"");
438 assert_eq!(key.as_str(), "trip");
439 assert_eq!(key.to_string(), "trip");
440 let back: WorkflowKey = serde_json::from_str("\"trip\"").unwrap();
441 assert_eq!(back, key);
442 }
443
444 #[test]
445 fn revision_next_and_zero() {
446 assert_eq!(CaseRevision::ZERO.next(), CaseRevision(1));
447 assert_eq!(CaseRevision(u64::MAX).next(), CaseRevision(u64::MAX));
448 assert!(CaseRevision(3) < CaseRevision(4));
449 }
450}