Skip to main content

gwk_domain/
ids.rs

1//! Identifier and scalar newtypes.
2//!
3//! Two families:
4//!
5//! - **Opaque string identifiers** — the contract does not prescribe an id scheme
6//!   (UUID, ULID, …); ids are opaque tokens minted by the kernel and compared
7//!   byte-for-byte. On the wire they are plain JSON strings.
8//! - **64-bit counters as decimal strings** — any value that can exceed
9//!   JavaScript's safe-integer range (2^53 − 1) crosses the wire as a canonical
10//!   decimal string, never a JSON number. Canonical means: ASCII digits only, no
11//!   sign, no leading zero (except `"0"` itself). Non-canonical input is rejected
12//!   at deserialization so that decode → encode is byte-identical.
13
14/// Declares an opaque string identifier newtype.
15macro_rules! string_id {
16    ($(#[$doc:meta])* $name:ident) => {
17        $(#[$doc])*
18        #[derive(
19            Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash,
20            serde::Serialize, serde::Deserialize, specta::Type,
21        )]
22        #[serde(transparent)]
23        pub struct $name(pub String);
24
25        impl $name {
26            pub fn new(value: impl Into<String>) -> Self {
27                Self(value.into())
28            }
29
30            pub fn as_str(&self) -> &str {
31                &self.0
32            }
33        }
34
35        impl std::fmt::Display for $name {
36            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37                f.write_str(&self.0)
38            }
39        }
40    };
41}
42
43/// Declares a u64 counter newtype carried on the wire as a canonical decimal string.
44macro_rules! u64_decimal_string {
45    ($(#[$doc:meta])* $name:ident) => {
46        $(#[$doc])*
47        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
48        pub struct $name(pub u64);
49
50        impl $name {
51            pub const fn new(value: u64) -> Self {
52                Self(value)
53            }
54
55            pub const fn value(self) -> u64 {
56                self.0
57            }
58        }
59
60        impl std::fmt::Display for $name {
61            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62                write!(f, "{}", self.0)
63            }
64        }
65
66        impl serde::Serialize for $name {
67            fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
68                s.collect_str(&self.0)
69            }
70        }
71
72        impl<'de> serde::Deserialize<'de> for $name {
73            fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
74                let raw = <std::borrow::Cow<'de, str>>::deserialize(d)?;
75                let canonical = !raw.is_empty()
76                    && raw.len() <= 20
77                    && raw.bytes().all(|b| b.is_ascii_digit())
78                    && (raw.len() == 1 || !raw.starts_with('0'));
79                if !canonical {
80                    return Err(serde::de::Error::custom(concat!(
81                        stringify!($name),
82                        " must be a canonical decimal u64 string"
83                    )));
84                }
85                raw.parse::<u64>().map(Self).map_err(serde::de::Error::custom)
86            }
87        }
88
89        // The WIRE form is a string, so the exported TS type is `string`.
90        impl specta::Type for $name {
91            fn definition(types: &mut specta::Types) -> specta::datatype::DataType {
92                <String as specta::Type>::definition(types)
93            }
94        }
95    };
96}
97
98string_id!(
99    /// An appended event's identity.
100    EventId
101);
102string_id!(
103    /// A command envelope's identity.
104    CommandId
105);
106string_id!(
107    /// The project (tenant / workspace) an aggregate belongs to.
108    ProjectId
109);
110string_id!(
111    /// An aggregate instance's identity, unique within its `aggregate_type`.
112    AggregateId
113);
114string_id!(TaskId);
115string_id!(AttemptId);
116string_id!(MessageId);
117string_id!(LeaseId);
118string_id!(GateId);
119string_id!(ReceiptId);
120string_id!(WorktreeId);
121string_id!(DispatchNodeId);
122string_id!(AttentionItemId);
123string_id!(AuthorityGrantId);
124string_id!(EvidenceId);
125string_id!(
126    /// One ingested record. Kernel-derived rather than caller-named: the
127    /// command carries no id, so the identity is minted from the
128    /// `(project_id, idempotency_key)` pair the envelope already had to
129    /// supply — which is what makes a retried ingest the same record.
130    IngestedRecordId
131);
132string_id!(EngineSessionId);
133string_id!(
134    /// Correlates every event/command in one logical flow.
135    CorrelationId
136);
137string_id!(
138    /// Caller-chosen key that makes a command or transition retry-stable.
139    IdempotencyKey
140);
141string_id!(
142    /// An execution engine, as an OPEN identifier (new engines are additive,
143    /// never a breaking enum change).
144    EngineId
145);
146string_id!(
147    /// Correlates one wire request with its response, event batch, or stream
148    /// close. Client-minted and echoed back unchanged.
149    RequestId
150);
151string_id!(
152    /// One in-flight blob upload. Kernel-minted: a caller never names a
153    /// storage location, and an uncommitted upload expires after an hour.
154    BlobUploadId
155);
156
157u64_decimal_string!(
158    /// A position in the global event log. Assigned by the kernel append actor
159    /// in COMMIT order — unique and strictly increasing, but NOT gapless.
160    Seq
161);
162u64_decimal_string!(
163    /// A fencing token: strictly increasing per lease scope; a holder presenting
164    /// a stale token is rejected by the storage layer.
165    FenceToken
166);
167u64_decimal_string!(
168    /// A size in bytes.
169    ByteCount
170);
171u64_decimal_string!(
172    /// The durable writer epoch: incremented under row lock at every kernel
173    /// boot and compared by every mutating transaction, so a resurrected old
174    /// writer cannot commit. NOT a log position — [`Seq`] is.
175    WriterEpoch
176);
177u64_decimal_string!(
178    /// A count of events. Distinct from [`Seq`]: the fresh-epoch proof asserts
179    /// the log holds exactly ONE event, which says nothing about the sequence
180    /// the database assigned it.
181    EventCount
182);
183u64_decimal_string!(
184    /// A cost amount in micro-USD (1_000_000 = $1).
185    CostMicros
186);
187
188/// An RFC 3339 timestamp carried opaquely (the contract pins the format, the
189/// kernel validates it; a plain JSON string on the wire).
190#[derive(
191    Debug,
192    Clone,
193    PartialEq,
194    Eq,
195    PartialOrd,
196    Ord,
197    Hash,
198    serde::Serialize,
199    serde::Deserialize,
200    specta::Type,
201)]
202#[serde(transparent)]
203pub struct Timestamp(pub String);
204
205impl Timestamp {
206    pub fn new(value: impl Into<String>) -> Self {
207        Self(value.into())
208    }
209
210    pub fn as_str(&self) -> &str {
211        &self.0
212    }
213}
214
215impl std::fmt::Display for Timestamp {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        f.write_str(&self.0)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn seq_round_trips_as_decimal_string() {
227        let seq = Seq::new(u64::MAX);
228        let json = serde_json::to_string(&seq).expect("serialize");
229        assert_eq!(json, "\"18446744073709551615\"");
230        let back: Seq = serde_json::from_str(&json).expect("deserialize");
231        assert_eq!(back, seq);
232    }
233
234    #[test]
235    fn seq_rejects_non_canonical_input() {
236        for bad in [
237            "\"\"",
238            "\"01\"",
239            "\"1e3\"",
240            "\"-1\"",
241            "\" 1\"",
242            "\"18446744073709551616\"",
243            "7",
244        ] {
245            assert!(
246                serde_json::from_str::<Seq>(bad).is_err(),
247                "accepted non-canonical {bad}"
248            );
249        }
250        let zero: Seq = serde_json::from_str("\"0\"").expect("bare zero is canonical");
251        assert_eq!(zero, Seq::new(0));
252    }
253}