Skip to main content

bellbook/record/
record.rs

1//! Core Record and Proposal structs from SPEC.md.
2
3use crate::base::canonical::canonical_json;
4use crate::base::hash::{sha256, Hash256};
5use crate::base::time::Time;
6use crate::record::author::Author;
7use crate::record::evidence::Evidence;
8use crate::record::kind::Kind;
9use crate::record::refs::{RecordId, Ref};
10use serde::{Deserialize, Serialize};
11
12/// Trust-domain identifier; refs never cross spaces, and the verifier
13/// rejects records whose space differs from `VerifierRules::space`.
14pub type SpaceId = Hash256;
15/// Conversation/work grouping within a space; context selection filters by
16/// thread, and Actions/Results must share their request's thread.
17pub type ThreadId = Hash256;
18/// Scope hash carried by requests, actions, capabilities, and approvals;
19/// part of the capability/approval lookup keys.
20pub type ScopeId = Hash256;
21
22/// Protocol and spec-version domain bound into every record signature.
23/// Keeping this in the signed bytes prevents a valid signature from another
24/// protocol or Bellbook spec epoch from being replayed as a v0.2 record.
25pub const RECORD_SIGNATURE_DOMAIN: &str = "bellbook.record-signature.v0.2";
26
27/// The one durable primitive: a typed, content-addressed entry in the
28/// append-only log. Immutable once committed - the id covers everything but
29/// itself. The completed detached signature is part of the id, so record
30/// identity and head attestations bind the exact signed envelope.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct Record {
34    /// Content address: SHA-256 of the canonical id form (only `id`
35    /// omitted; a completed signature is included), recomputed and checked
36    /// by the verifier.
37    pub id: RecordId,
38    /// Trust domain; must equal the verifier's configured space.
39    pub space: SpaceId,
40    /// Conversation/work grouping this record belongs to.
41    pub thread: ThreadId,
42    /// Logical commit counter; strictly `prev + 1`, starting at 1.
43    pub time: Time,
44    /// Who produced the record; a completed signature is included in the id.
45    pub author: Author,
46    /// Record type; must agree with what the frozen map assigns to `schema`.
47    pub kind: Kind,
48    /// SHA-256 of the frozen schema name governing `data`'s payload shape.
49    pub schema: Hash256,
50    /// JSON payload bytes for the schema, decoded via [`decode`].
51    pub data: Vec<u8>,
52    /// Typed edges to prior records; sorted by (type ordinal, target bytes)
53    /// and deduped before hashing.
54    pub refs: Vec<Ref>,
55    /// Trust class; must equal the value derived from the schema and the
56    /// refs' evidence, or the verifier rejects the record.
57    pub evidence: Evidence,
58}
59
60impl Record {
61    /// Compute the canonical id form and derive the record id.
62    ///
63    /// The id form excludes only `id`; a completed detached signature is
64    /// included. This makes record ids and head attestations bind the exact
65    /// signed envelope.
66    pub fn with_computed_id(mut self) -> Result<Self, serde_json::Error> {
67        self.id = self.compute_id()?;
68        Ok(self)
69    }
70
71    /// Compute the id without setting it.
72    pub fn compute_id(&self) -> Result<RecordId, serde_json::Error> {
73        Ok(sha256(&self.canonical_id_form()?))
74    }
75
76    /// Produce the canonical bytes used for the record id.
77    /// Only `id` is excluded; `author.signature` is included.
78    pub fn canonical_id_form(&self) -> Result<Vec<u8>, serde_json::Error> {
79        let id_form = CanonicalIdForm {
80            space: &self.space,
81            thread: &self.thread,
82            time: self.time,
83            author: CanonicalIdAuthor {
84                id: &self.author.id,
85                type_: &self.author.type_,
86                signature: self.author.signature.as_ref(),
87            },
88            kind: &self.kind,
89            schema: &self.schema,
90            data: &self.data,
91            refs: &self.refs,
92            evidence: &self.evidence,
93        };
94        canonical_json(&id_form)
95    }
96
97    /// Produce the canonical bytes covered by an Ed25519 signature.
98    ///
99    /// Both `id` and `author.signature` are excluded, avoiding a circular
100    /// dependency while signing every semantic field of the record. The
101    /// canonical form is wrapped with [`RECORD_SIGNATURE_DOMAIN`] so the
102    /// signature cannot be replayed across protocols or spec epochs.
103    pub fn signing_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
104        let signing_form = DomainSeparatedSigningForm {
105            domain: RECORD_SIGNATURE_DOMAIN,
106            record: CanonicalSigningForm {
107                space: &self.space,
108                thread: &self.thread,
109                time: self.time,
110                author: CanonicalAuthor {
111                    id: &self.author.id,
112                    type_: &self.author.type_,
113                },
114                kind: &self.kind,
115                schema: &self.schema,
116                data: &self.data,
117                refs: &self.refs,
118                evidence: &self.evidence,
119            },
120        };
121        canonical_json(&signing_form)
122    }
123}
124
125/// Top-level signature envelope. This is deliberately part of the canonical
126/// bytes rather than an implicit API parameter, so independent implementations
127/// have one exact, inspectable signing input.
128#[derive(Serialize)]
129struct DomainSeparatedSigningForm<'a> {
130    domain: &'static str,
131    record: CanonicalSigningForm<'a>,
132}
133
134/// Internal struct for computing the record id. Excludes only `id`.
135#[derive(Serialize)]
136struct CanonicalIdForm<'a> {
137    space: &'a SpaceId,
138    thread: &'a ThreadId,
139    time: Time,
140    author: CanonicalIdAuthor<'a>,
141    kind: &'a Kind,
142    schema: &'a Hash256,
143    data: &'a Vec<u8>,
144    refs: &'a Vec<Ref>,
145    evidence: &'a Evidence,
146}
147
148#[derive(Serialize)]
149struct CanonicalIdAuthor<'a> {
150    id: &'a str,
151    #[serde(rename = "type")]
152    type_: &'a crate::record::kind::AuthorType,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    signature: Option<&'a crate::record::author::Signature>,
155}
156
157/// Internal struct for signature input. Excludes `id` and the signature.
158#[derive(Serialize)]
159struct CanonicalSigningForm<'a> {
160    space: &'a SpaceId,
161    thread: &'a ThreadId,
162    time: Time,
163    author: CanonicalAuthor<'a>,
164    kind: &'a Kind,
165    schema: &'a Hash256,
166    data: &'a Vec<u8>,
167    refs: &'a Vec<Ref>,
168    evidence: &'a Evidence,
169}
170
171#[derive(Serialize)]
172struct CanonicalAuthor<'a> {
173    id: &'a str,
174    #[serde(rename = "type")]
175    type_: &'a crate::record::kind::AuthorType,
176}
177
178/// A proposal is what the Proposer emits before commit.
179/// No `id`, no `time`, no `evidence` - these are assigned at commit time.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct Proposal {
183    /// Trust domain the resulting record will live in.
184    pub space: SpaceId,
185    /// Thread the resulting record will belong to.
186    pub thread: ThreadId,
187    /// Claimed author of the proposed record.
188    pub author: Author,
189    /// Proposed record type; verified against `schema` at commit.
190    pub kind: Kind,
191    /// SHA-256 of the frozen schema name for `data`.
192    pub schema: Hash256,
193    /// JSON payload bytes for the schema.
194    pub data: Vec<u8>,
195    /// Refs as supplied by the proposer; sorted and deduped during commit,
196    /// before the id is computed.
197    pub refs: Vec<Ref>,
198}
199
200/// Decode payload bytes into a typed struct.
201pub fn decode<T: serde::de::DeserializeOwned>(data: &[u8]) -> Result<T, serde_json::Error> {
202    serde_json::from_slice(data)
203}
204
205/// Encode payload struct into canonical JSON bytes.
206pub fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
207    canonical_json(value)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::record::kind::*;
214
215    fn test_record() -> Record {
216        Record {
217            id: [0u8; 32],
218            space: [1u8; 32],
219            thread: [2u8; 32],
220            time: 1,
221            author: Author {
222                id: "test".into(),
223                type_: AuthorType::User,
224                signature: None,
225            },
226            kind: Kind::Request,
227            schema: [3u8; 32],
228            data: b"{}".to_vec(),
229            refs: vec![],
230            evidence: Evidence::Reported,
231        }
232    }
233
234    #[test]
235    fn test_with_computed_id() {
236        let record = test_record().with_computed_id().unwrap();
237        // id should not be all zeros anymore
238        assert_ne!(record.id, [0u8; 32]);
239        // Computing again should give the same id
240        let id2 = record.compute_id().unwrap();
241        assert_eq!(record.id, id2);
242    }
243
244    #[test]
245    fn test_id_form_excludes_id_but_binds_signature() {
246        let mut r1 = test_record();
247        r1.id = [99u8; 32];
248        let mut r2 = r1.clone();
249        r2.id = [88u8; 32];
250        assert_eq!(
251            r1.canonical_id_form().unwrap(),
252            r2.canonical_id_form().unwrap()
253        );
254
255        r2.author.signature = Some(crate::record::author::Signature {
256            key_id: "11".repeat(32),
257            sig: vec![1; 64],
258        });
259        assert_ne!(
260            r1.canonical_id_form().unwrap(),
261            r2.canonical_id_form().unwrap()
262        );
263        assert_ne!(r1.compute_id().unwrap(), r2.compute_id().unwrap());
264    }
265
266    #[test]
267    fn test_signing_bytes_exclude_id_and_signature() {
268        let mut r1 = test_record();
269        r1.id = [99u8; 32];
270        r1.author.signature = Some(crate::record::author::Signature {
271            key_id: "11".repeat(32),
272            sig: vec![1; 64],
273        });
274
275        let mut r2 = test_record();
276        r2.id = [88u8; 32];
277        r2.author.signature = None;
278
279        assert_eq!(r1.signing_bytes().unwrap(), r2.signing_bytes().unwrap());
280
281        let value: serde_json::Value =
282            serde_json::from_slice(&r1.signing_bytes().unwrap()).unwrap();
283        assert_eq!(value["domain"], RECORD_SIGNATURE_DOMAIN);
284        assert!(value.get("record").is_some());
285    }
286
287    #[test]
288    fn test_id_determinism() {
289        let r1 = test_record().with_computed_id().unwrap();
290        let r2 = test_record().with_computed_id().unwrap();
291        assert_eq!(r1.id, r2.id);
292    }
293}