Skip to main content

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}