net/adapter/net/redex/write_token.rs
1//! `WriteToken` — typed handle to a specific write on a specific
2//! origin's chain. Returned by event-ingest paths; consumed by
3//! read-your-writes wait primitives.
4//!
5//! The substrate uses a 64-bit `origin_hash` throughout (see
6//! `identity::entity::EntityKeypair::origin_hash`). An earlier draft
7//! of the Dataforts plan speculated a 32-byte origin; the substrate
8//! shape wins because every causal-chain primitive already keys on
9//! `u64`.
10//!
11//! ## Threat model
12//!
13//! `WriteToken` is **plain in-process data**, not a signed
14//! capability. The fields are `pub` so the FFI / binding layer can
15//! marshal them without going through `serde`; in-process callers
16//! can construct any token they want. RYW guarantees are upheld by
17//! the *adapter*: `TasksAdapter::wait_for_token` and
18//! `MemoriesAdapter::wait_for_token` reject tokens whose
19//! `origin_hash` doesn't match the adapter's own bound origin
20//! ([`super::cortex::WaitForTokenError::WrongOrigin`]).
21//!
22//! This means the trust boundary is the adapter handle, not the
23//! token. A caller holding a `TasksAdapter` bound to origin X
24//! cannot use it to learn anything about origin Y by forging a
25//! token — `wait_for_token` will reject and `note_wrong_origin`
26//! will trip the metric. Tokens crossing the wire (e.g. encoded
27//! into another origin's payload) must be treated as untrusted
28//! input; the receiving side validates via the adapter binding
29//! before honoring them.
30
31use std::fmt;
32use std::str::FromStr;
33
34/// Address of a write — origin (which chain) + seq (which event on
35/// that chain). Round-trips through every binding as a typed value.
36///
37/// Treat tokens as **opaque, in-process data**. They are not
38/// signed. The fields are `pub` so FFI / binding layers can
39/// marshal them; application code should not synthesise tokens.
40/// See module-level docs for the trust model — origin-bound
41/// adapters reject mismatched tokens at `wait_for_token`.
42#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
43pub struct WriteToken {
44 /// 64-bit hash of the entity whose chain this write landed on
45 /// (`EntityKeypair::origin_hash`).
46 pub origin_hash: u64,
47 /// Per-chain monotonic sequence assigned by `RedexFile::append`.
48 pub seq: u64,
49}
50
51impl WriteToken {
52 /// Construct a token from its components. **Not for application
53 /// use** — the ingest path returns tokens with the right
54 /// `(origin_hash, seq)` already attached. Exposed only so
55 /// FFI / binding layers can marshal tokens across the
56 /// language boundary. Hand-rolled tokens that don't match an
57 /// actually-issued ingest produce waits that will hang until
58 /// the deadline expires or `WrongOrigin` rejects.
59 #[doc(hidden)]
60 pub const fn new(origin_hash: u64, seq: u64) -> Self {
61 Self { origin_hash, seq }
62 }
63}
64
65/// `<origin_hex>:<seq>` — chosen for grep-ability against the
66/// `causal:<hex>:<seq>` reserved-prefix tag shape. Stable wire
67/// form for log/CLI surfaces; bindings serialise the struct
68/// directly instead.
69impl fmt::Display for WriteToken {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 write!(f, "{:016x}:{}", self.origin_hash, self.seq)
72 }
73}
74
75/// Errors returned by [`WriteToken::from_str`].
76#[derive(Debug, PartialEq, Eq)]
77pub enum WriteTokenParseError {
78 /// Input did not contain the `:` that separates origin from seq.
79 MissingSeparator,
80 /// Origin portion was not 16 lowercase hex characters.
81 BadOrigin,
82 /// Seq portion did not parse as a decimal `u64`.
83 BadSeq,
84}
85
86impl fmt::Display for WriteTokenParseError {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 match self {
89 Self::MissingSeparator => f.write_str("expected `<origin_hex>:<seq>`"),
90 Self::BadOrigin => f.write_str("origin must be 16 lowercase hex chars"),
91 Self::BadSeq => f.write_str("seq must be a decimal u64"),
92 }
93 }
94}
95
96impl std::error::Error for WriteTokenParseError {}
97
98impl FromStr for WriteToken {
99 type Err = WriteTokenParseError;
100
101 fn from_str(s: &str) -> Result<Self, Self::Err> {
102 let (origin_str, seq_str) = s
103 .split_once(':')
104 .ok_or(WriteTokenParseError::MissingSeparator)?;
105 if origin_str.len() != 16 {
106 return Err(WriteTokenParseError::BadOrigin);
107 }
108 let origin_hash =
109 u64::from_str_radix(origin_str, 16).map_err(|_| WriteTokenParseError::BadOrigin)?;
110 let seq: u64 = seq_str.parse().map_err(|_| WriteTokenParseError::BadSeq)?;
111 Ok(Self::new(origin_hash, seq))
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn display_pads_origin_to_16_hex() {
121 let token = WriteToken::new(0xDEAD_BEEF, 42);
122 assert_eq!(token.to_string(), "00000000deadbeef:42");
123 }
124
125 #[test]
126 fn display_round_trips_via_from_str() {
127 let token = WriteToken::new(0x0123_4567_89AB_CDEF, 12345);
128 let parsed: WriteToken = token.to_string().parse().unwrap();
129 assert_eq!(parsed, token);
130 }
131
132 #[test]
133 fn from_str_rejects_missing_separator() {
134 assert_eq!(
135 "deadbeef".parse::<WriteToken>(),
136 Err(WriteTokenParseError::MissingSeparator)
137 );
138 }
139
140 #[test]
141 fn from_str_rejects_short_origin() {
142 assert_eq!(
143 "deadbeef:1".parse::<WriteToken>(),
144 Err(WriteTokenParseError::BadOrigin)
145 );
146 }
147
148 #[test]
149 fn from_str_rejects_non_hex_origin() {
150 assert_eq!(
151 "zzzzzzzzzzzzzzzz:1".parse::<WriteToken>(),
152 Err(WriteTokenParseError::BadOrigin)
153 );
154 }
155
156 #[test]
157 fn from_str_rejects_bad_seq() {
158 assert_eq!(
159 "0000000000000001:-1".parse::<WriteToken>(),
160 Err(WriteTokenParseError::BadSeq)
161 );
162 }
163
164 #[test]
165 fn equality_is_componentwise() {
166 let a = WriteToken::new(1, 2);
167 let b = WriteToken::new(1, 2);
168 let c = WriteToken::new(1, 3);
169 let d = WriteToken::new(2, 2);
170 assert_eq!(a, b);
171 assert_ne!(a, c);
172 assert_ne!(a, d);
173 }
174
175 #[test]
176 fn token_is_copy() {
177 fn assert_copy<T: Copy>() {}
178 assert_copy::<WriteToken>();
179 }
180}