Skip to main content

kernel/event/
payload.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Event payloads, drafts, records, and refs.
7
8use serde::{Deserialize, Serialize};
9
10use crate::address::Address;
11use crate::consts_external::EVENT_LOG_V;
12use crate::error::{AxCode, AxError};
13use crate::locator::B3Hash;
14
15use super::identity::{RunId, Seq, TimeMs};
16use super::kind::EventKind;
17
18/// The deepest nesting a payload may carry, its own object counted as
19/// the first level: serde_json's recursion limit of 128 refuses the
20/// 128th nested container, so a line holds 127, and the [`EventRecord`]
21/// envelope spends one of them.
22const PAYLOAD_DEPTH_MAX: usize = 126;
23
24/// Ledger payload: a JSON object with every float refused, at construction
25/// and again on read (determinism rule 6). Keys serialize sorted
26/// (serde_json's default BTreeMap), which is part of the canonical bytes.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28#[serde(transparent)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30pub struct Payload(serde_json::Map<String, serde_json::Value>);
31
32impl Payload {
33    /// Sole constructor.
34    pub fn new(map: serde_json::Map<String, serde_json::Value>) -> Result<Self, AxError> {
35        reject_floats_and_depth(&map)?;
36        Ok(Payload(map))
37    }
38
39    pub fn empty() -> Self {
40        Payload(serde_json::Map::new())
41    }
42
43    /// The way in: one of the record structs in [`crate::event::record`],
44    /// encoded as the object a ledger line carries. Every writer goes
45    /// through here, so the keys of a kind are spelled in exactly one
46    /// place: the struct.
47    ///
48    /// # Errors
49    /// Refuses a record that does not encode to a JSON object, and any
50    /// float inside one, for the reasons [`Payload::new`] gives.
51    pub fn of(record: &impl Serialize) -> Result<Self, AxError> {
52        let value = serde_json::to_value(record).map_err(|err| {
53            AxError::failure(
54                AxCode::InvalidArgs,
55                "encode a ledger payload",
56                err.to_string(),
57            )
58            .with_recovery("give the record string keys and integer numbers only")
59        })?;
60        let serde_json::Value::Object(map) = value else {
61            return Err(AxError::failure(
62                AxCode::InvalidArgs,
63                "encode a ledger payload",
64                "the record did not encode to a JSON object",
65            )
66            .with_recovery("a ledger payload is an object; record this fact as a struct"));
67        };
68        Payload::new(map)
69    }
70
71    /// The way out: the record struct this payload was written from.
72    ///
73    /// A field a record written by an older build may omit carries
74    /// `#[serde(default)]` on the struct, so the tolerance is declared
75    /// once per key rather than at each reader. Unknown keys pass, so a
76    /// newer writer never breaks an older reader.
77    ///
78    /// Deserializes from the held map by reference, so a read allocates
79    /// only what `T` owns, never a second copy of the payload.
80    ///
81    /// # Errors
82    /// A payload this build cannot read as `T`.
83    pub fn read<T: serde::de::DeserializeOwned>(&self) -> Result<T, AxError> {
84        T::deserialize(&self.0).map_err(|err| {
85            AxError::failure(
86                AxCode::WireMismatch,
87                "read a ledger payload",
88                err.to_string(),
89            )
90            .with_recovery("replay with the build that wrote this record")
91        })
92    }
93
94    pub fn as_map(&self) -> &serde_json::Map<String, serde_json::Value> {
95        &self.0
96    }
97}
98
99impl<'de> Deserialize<'de> for Payload {
100    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
101        let map = serde_json::Map::deserialize(deserializer)?;
102        Payload::new(map).map_err(serde::de::Error::custom)
103    }
104}
105
106/// Refuses a float anywhere under `map`, and containers nested past
107/// [`PAYLOAD_DEPTH_MAX`], in one iterative walk over one stack: a hostile
108/// payload cannot spend the writer's stack, and a well-formed one costs a
109/// single allocation however wide or deep it is. A container is judged
110/// before anything under it, so a payload too deep for the reader is
111/// refused for its depth even when a float waits at the bottom.
112fn reject_floats_and_depth(
113    map: &serde_json::Map<String, serde_json::Value>,
114) -> Result<(), AxError> {
115    // The payload's own object is the first level, so what it holds
116    // starts one container down.
117    let mut pending: Vec<(&serde_json::Value, usize)> =
118        map.values().map(|value| (value, 1)).collect();
119    while let Some((value, above)) = pending.pop() {
120        match value {
121            serde_json::Value::Number(n) if !n.is_i64() && !n.is_u64() => {
122                return Err(
123                    AxError::failure(AxCode::InvalidArgs, "build payload", n.to_string())
124                        .with_recovery("ledger payloads never carry floats; scale to integers"),
125                );
126            }
127            serde_json::Value::Array(items) => {
128                let level = opened(above)?;
129                pending.extend(items.iter().map(|item| (item, level)));
130            }
131            serde_json::Value::Object(inner) => {
132                let level = opened(above)?;
133                pending.extend(inner.values().map(|item| (item, level)));
134            }
135            serde_json::Value::Null
136            | serde_json::Value::Bool(_)
137            | serde_json::Value::Number(_)
138            | serde_json::Value::String(_) => {}
139        }
140    }
141    Ok(())
142}
143
144/// The level a container sits at when `above` containers hold it, or the
145/// refusal when that passes [`PAYLOAD_DEPTH_MAX`].
146fn opened(above: usize) -> Result<usize, AxError> {
147    above
148        .checked_add(1)
149        .filter(|level| *level <= PAYLOAD_DEPTH_MAX)
150        .ok_or_else(|| {
151            AxError::failure(
152                AxCode::InvalidArgs,
153                "build payload",
154                format!("nesting deeper than {PAYLOAD_DEPTH_MAX} levels"),
155            )
156            .with_recovery(
157                "store the body in CAS and carry its locator in the payload; the ledger reader cannot parse this depth back",
158            )
159        })
160}
161
162/// What a recording party supplies; the Ledger implementation owns the
163/// rest (seq, prev, v).
164#[derive(Debug, Clone, PartialEq)]
165pub struct EventDraft {
166    pub run: RunId,
167    pub t: TimeMs,
168    pub who: String,
169    pub addr: Option<Address>,
170    pub kind: EventKind,
171    pub data: Payload,
172    /// "Ignorable": a future reader may skip this line without changing
173    /// any rebuild result. Writers set it only when that holds.
174    pub ig: bool,
175}
176
177fn ig_is_false(ig: &bool) -> bool {
178    !*ig
179}
180
181/// One serialized history line. Fields private: the only paths to a
182/// record are [`EventRecord::from_draft`] (append side) and
183/// [`EventRecord::parse_line`] (read side).
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
187pub struct EventRecord {
188    v: u32,
189    run: RunId,
190    seq: Seq,
191    prev: B3Hash,
192    t: TimeMs,
193    who: String,
194    #[serde(skip_serializing_if = "Option::is_none", default)]
195    addr: Option<Address>,
196    kind: EventKind,
197    data: Payload,
198    #[serde(skip_serializing_if = "ig_is_false", default)]
199    ig: bool,
200}
201
202impl EventRecord {
203    /// Append-side assembly: the caller (a Ledger implementation) owns seq
204    /// and prev; `v` is pinned to [`EVENT_LOG_V`].
205    pub fn from_draft(draft: EventDraft, seq: Seq, prev: B3Hash) -> Self {
206        EventRecord {
207            v: EVENT_LOG_V,
208            run: draft.run,
209            seq,
210            prev,
211            t: draft.t,
212            who: draft.who,
213            addr: draft.addr,
214            kind: draft.kind,
215            data: draft.data,
216            ig: draft.ig,
217        }
218    }
219
220    /// The canonical bytes (no line terminator). The one byte producer
221    /// library-wide; adapters append these bytes verbatim and hash exactly
222    /// them for the next line's prev.
223    pub fn canonical_line(&self) -> Result<Vec<u8>, AxError> {
224        serde_json::to_vec(self).map_err(|e| {
225            AxError::failure(
226                AxCode::InvalidArgs,
227                "serialize event",
228                format!("{:?} at seq {}", self.kind, self.seq.value()),
229            )
230            .with_recovery(format!(
231                "JSON refused this payload ({e}); give the event's data only string keys and finite numbers"
232            ))
233        })
234    }
235
236    /// Read-side entrance: full field revalidation, fail-closed. Unknown
237    /// kinds fail here; the `ig:true` skip belongs to replay, which probes
238    /// the envelope before committing to a typed parse.
239    pub fn parse_line(raw: &[u8]) -> Result<Self, AxError> {
240        serde_json::from_slice(raw).map_err(|e| {
241            AxError::failure(AxCode::InvalidArgs, "parse event line", e.to_string())
242                .with_recovery("the line is not a canonical EventRecord")
243        })
244    }
245
246    /// Minting a reference requires holding a whole record (15.3-1): the
247    /// append path holds what it just assembled, replay holds what it just
248    /// verified. There is no third path.
249    pub fn to_ref(&self) -> EventRef {
250        EventRef {
251            seq: self.seq,
252            kind: self.kind,
253        }
254    }
255
256    pub fn v(&self) -> u32 {
257        self.v
258    }
259
260    pub fn run(&self) -> RunId {
261        self.run
262    }
263
264    pub fn seq(&self) -> Seq {
265        self.seq
266    }
267
268    pub fn prev(&self) -> B3Hash {
269        self.prev
270    }
271
272    pub fn t(&self) -> TimeMs {
273        self.t
274    }
275
276    pub fn who(&self) -> &str {
277        &self.who
278    }
279
280    pub fn addr(&self) -> Option<&Address> {
281        self.addr.as_ref()
282    }
283
284    pub fn kind(&self) -> EventKind {
285        self.kind
286    }
287
288    pub fn data(&self) -> &Payload {
289        &self.data
290    }
291
292    pub fn ig(&self) -> bool {
293        self.ig
294    }
295}
296
297/// Unforgeable pointer at a ledger line: private fields, no public
298/// constructor, no serde (deserialization would be a third mint).
299#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
300pub struct EventRef {
301    seq: Seq,
302    kind: EventKind,
303}
304
305impl EventRef {
306    pub fn seq(&self) -> Seq {
307        self.seq
308    }
309
310    pub fn kind(&self) -> EventKind {
311        self.kind
312    }
313}
314
315#[cfg(test)]
316#[allow(
317    clippy::unwrap_used,
318    clippy::expect_used,
319    clippy::panic,
320    clippy::indexing_slicing,
321    reason = "test code"
322)]
323mod tests;