Skip to main content

vertep_runtime/
address.rs

1//! Actor addresses and the stable IRIs of runtime records.
2//!
3//! Every actor has exactly one address, an absolute IRI, which is also the RDF subject of its
4//! state and the name of its current-state graph. Journal entries, snapshots, timers and dead
5//! letters get IRIs derived from the address and a journal position or send id, so that
6//! replaying or re-projecting a journal yields the same IRIs.
7
8use core::fmt;
9
10use oxrdf::NamedNode;
11use sha2::{Digest, Sha256};
12
13/// Why a string is not an actor address.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct AddressError {
16    /// The rejected text.
17    pub text: String,
18    /// The reason.
19    pub reason: String,
20}
21
22impl fmt::Display for AddressError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        write!(f, "not an actor address: {:?} ({})", self.text, self.reason)
25    }
26}
27
28impl std::error::Error for AddressError {}
29
30/// An actor address: an absolute IRI.
31// @tg: implements:: [[openspec:actor-model#IRI Actor Addresses]]
32#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub struct Address(NamedNode);
34
35impl fmt::Debug for Address {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        write!(f, "<{}>", self.0.as_str())
38    }
39}
40
41impl fmt::Display for Address {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.write_str(self.0.as_str())
44    }
45}
46
47impl Address {
48    /// Parses an address. Relative references and strings that are not IRIs are rejected.
49    ///
50    /// # Errors
51    ///
52    /// [`AddressError`] for anything but an absolute IRI.
53    pub fn parse(text: &str) -> Result<Self, AddressError> {
54        NamedNode::new(text).map(Address).map_err(|e| AddressError {
55            text: text.to_owned(),
56            reason: e.to_string(),
57        })
58    }
59
60    /// The address of the virtual actor of chart type `chart_type` with key `key`:
61    /// `<chart_type>/<percent-encoded key>`. The same pair always gives the same address.
62    ///
63    /// # Errors
64    ///
65    /// [`AddressError`] when `chart_type` is not an absolute IRI.
66    // @tg: implements:: [[openspec:actor-model#Virtual Actor Identity]]
67    pub fn virtual_actor(chart_type: &str, key: &str) -> Result<Self, AddressError> {
68        Address::parse(chart_type)?;
69        let mut s = String::with_capacity(chart_type.len() + key.len() + 1);
70        s.push_str(chart_type);
71        s.push('/');
72        percent_encode_into(key, &mut s);
73        Address::parse(&s)
74    }
75
76    /// The address of the child invoked by `parent` with invoke id `invokeid`:
77    /// `<parent>/invoke/<percent-encoded id>`.
78    pub fn invoked(parent: &Address, invokeid: &str) -> Address {
79        Address(parent.derived("invoke", invokeid))
80    }
81
82    /// The IRI text.
83    pub fn as_str(&self) -> &str {
84        self.0.as_str()
85    }
86
87    /// The IRI as an RDF named node.
88    pub fn node(&self) -> &NamedNode {
89        &self.0
90    }
91
92    /// The IRI scheme, such as `urn` or `https`.
93    pub fn scheme(&self) -> &str {
94        self.as_str().split(':').next().unwrap_or_default()
95    }
96
97    /// `<self>/<kind>/<percent-encoded part>`.
98    fn derived(&self, kind: &str, part: &str) -> NamedNode {
99        let mut s = String::with_capacity(self.as_str().len() + kind.len() + part.len() + 2);
100        s.push_str(self.as_str());
101        s.push('/');
102        s.push_str(kind);
103        s.push('/');
104        percent_encode_into(part, &mut s);
105        NamedNode::new_unchecked(s)
106    }
107
108    /// The IRI of the journal entry at `position`, also the name of its graph.
109    // @tg: implements:: [[openspec:runtime-ontology#Stable Runtime IRIs]]
110    pub fn entry_iri(&self, position: u64) -> NamedNode {
111        self.derived("entry", &position.to_string())
112    }
113
114    /// The IRI of the snapshot at `position`, also the name of its graph.
115    pub fn snapshot_iri(&self, position: u64) -> NamedNode {
116        self.derived("snapshot", &position.to_string())
117    }
118
119    /// The IRI of the dead letter for message `message_id`.
120    pub fn dead_letter_iri(&self, message_id: &str) -> NamedNode {
121        self.derived("dead-letter", &short_digest(message_id))
122    }
123
124    /// The graph holding this actor's dead letters.
125    pub fn dead_letter_graph(&self) -> NamedNode {
126        NamedNode::new_unchecked(format!("{}/dead-letters", self.as_str()))
127    }
128
129    /// The graph holding this actor's store-backed mailbox.
130    pub fn mailbox_graph(&self) -> NamedNode {
131        NamedNode::new_unchecked(format!("{}/mailbox", self.as_str()))
132    }
133
134    /// The IRI of the mailbox row with sequence number `seq` holding the serialized envelope
135    /// `envelope`: `<self>/mailbox/<seq>-<digest>`. Mailboxes on different nodes sharing one
136    /// store number their rows independently, so the sequence number alone collides; with the
137    /// envelope's digest two rows share an IRI only when they hold the same message at the same
138    /// sequence number, and then they are the same triples.
139    pub fn mailbox_row_iri(&self, seq: u64, envelope: &str) -> NamedNode {
140        self.derived("mailbox", &format!("{seq}-{}", short_digest(envelope)))
141    }
142
143    /// The graph holding this actor's outbox marks.
144    pub fn outbox_graph(&self) -> NamedNode {
145        NamedNode::new_unchecked(format!("{}/outbox", self.as_str()))
146    }
147
148    /// The correlation id of the `k`-th request issued by the macrostep journaled at
149    /// `position`: `<self>/request/<position>.<k>`.
150    // @tg: implements:: [[openspec:messaging#Deterministic Correlation Ids]]
151    pub fn correlation_id(&self, position: u64, k: usize) -> String {
152        format!("{}/request/{position}.{k}", self.as_str())
153    }
154}
155
156/// The IRI of the input event of entry `entry`.
157pub fn input_iri(entry: &NamedNode) -> NamedNode {
158    NamedNode::new_unchecked(format!("{}/input", entry.as_str()))
159}
160
161/// The IRI of the `k`-th send of entry `entry`; also the message id the send is delivered with.
162pub fn send_iri(entry: &NamedNode, k: usize) -> NamedNode {
163    NamedNode::new_unchecked(format!("{}/send/{k}", entry.as_str()))
164}
165
166/// The IRI of the timer with send id `send_id` scheduled by entry `entry`; also the message id
167/// its event is delivered with.
168pub fn timer_iri(entry: &NamedNode, send_id: &str) -> NamedNode {
169    let mut s = format!("{}/timer/", entry.as_str());
170    percent_encode_into(send_id, &mut s);
171    NamedNode::new_unchecked(s)
172}
173
174/// Percent-encodes everything but RFC 3986 unreserved characters.
175pub fn percent_encode_into(text: &str, out: &mut String) {
176    for b in text.bytes() {
177        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
178            out.push(char::from(b));
179        } else {
180            out.push('%');
181            out.push(char::from(HEX[usize::from(b >> 4)]));
182            out.push(char::from(HEX[usize::from(b & 0xF)]));
183        }
184    }
185}
186
187/// Decodes `%XX` escapes; `None` when the result is not UTF-8 or an escape is malformed.
188pub fn percent_decode(text: &str) -> Option<String> {
189    let bytes = text.as_bytes();
190    let mut out = Vec::with_capacity(bytes.len());
191    let mut i = 0;
192    while i < bytes.len() {
193        if bytes[i] == b'%' {
194            let hex = text.get(i + 1..i + 3)?;
195            out.push(u8::from_str_radix(hex, 16).ok()?);
196            i += 3;
197        } else {
198            out.push(bytes[i]);
199            i += 1;
200        }
201    }
202    String::from_utf8(out).ok()
203}
204
205const HEX: &[u8; 16] = b"0123456789ABCDEF";
206
207/// The first 16 bytes of the SHA-256 of `text`, as lowercase hex.
208pub fn short_digest(text: &str) -> String {
209    hex(&Sha256::digest(text.as_bytes())[..16])
210}
211
212/// Lowercase hex of `bytes`.
213pub fn hex(bytes: &[u8]) -> String {
214    const L: &[u8; 16] = b"0123456789abcdef";
215    let mut s = String::with_capacity(bytes.len() * 2);
216    for b in bytes {
217        s.push(char::from(L[usize::from(b >> 4)]));
218        s.push(char::from(L[usize::from(b & 0xF)]));
219    }
220    s
221}
222
223/// The random seed of the macrostep journaled as `entry`: the first 8 bytes of the SHA-256 of
224/// its IRI, so replay reproduces every random value without storing it.
225pub fn seed_of(entry: &NamedNode) -> u64 {
226    let d = Sha256::digest(entry.as_str().as_bytes());
227    let mut b = [0u8; 8];
228    b.copy_from_slice(&d[..8]);
229    u64::from_be_bytes(b)
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn percent_round_trip() {
238        let mut s = String::new();
239        percent_encode_into("a b/ü#", &mut s);
240        assert_eq!(s, "a%20b%2F%C3%BC%23");
241        assert_eq!(percent_decode(&s).unwrap(), "a b/ü#");
242    }
243
244    #[test]
245    fn derived_iris_are_stable_and_distinct() {
246        let a = Address::parse("urn:vertep:test:a").unwrap();
247        assert_eq!(a.entry_iri(3).as_str(), "urn:vertep:test:a/entry/3");
248        assert_ne!(a.entry_iri(3), a.snapshot_iri(3));
249        let e = a.entry_iri(3);
250        assert_eq!(
251            timer_iri(&e, "r 1").as_str(),
252            "urn:vertep:test:a/entry/3/timer/r%201"
253        );
254        assert_eq!(seed_of(&e), seed_of(&a.entry_iri(3)));
255        assert_ne!(seed_of(&e), seed_of(&a.entry_iri(4)));
256        assert_eq!(a.scheme(), "urn");
257    }
258}