soma-som-core 0.1.0

Universal soma(som) structural primitives — Quad / Tree / Ring / Genesis / Fingerprint / TemporalLedger / CrossingRecord
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
// SPDX-License-Identifier: LGPL-3.0-only
#![allow(missing_docs)]

//! Crossing record: the boundary's attestation that a transition occurred.
//!
//! ## Spec traceability
//! - Contracts §2.1 (Definition 2): crossing record fields
//! - Contracts §4: boundary specification
//! - Invariant 9: no self-certification — the producer cannot certify its own crossing
//!
//! compatibility.
//!
//! ## Design note
//!
//! The crossing record is NOT part of the Quad. It is the boundary's contribution
//! to the protocol (Contracts §2.1): "The Quad carries *what* was produced. The
//! crossing record carries *that it crossed*."
//!
//! The record is produced by the boundary (soma-boundary crate), not by the
//! producer unit. This crate only defines the type; construction and signing
//! are the boundary's responsibility.

use crate::types::{CrossingType, UnitId};
use serde::{Deserialize, Serialize};

/// Custom serde support for `[u8; 64]` (Ed25519 signatures).
///
/// serde's derive only supports arrays up to 32 elements.
/// This module serializes as a byte slice / deserializes from a `Vec<u8>`.
mod signature_bytes {
    use serde::{self, Deserialize, Deserializer, Serializer};

    pub fn serialize<S>(bytes: &[u8; 64], serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_bytes(bytes)
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error>
    where
        D: Deserializer<'de>,
    {
        let v: Vec<u8> = Vec::deserialize(deserializer)?;
        v.try_into().map_err(|v: Vec<u8>| {
            serde::de::Error::custom(format!("expected 64 bytes for signature, got {}", v.len()))
        })
    }
}

/// The crossing record: verification trace produced at a boundary crossing.
///
/// ## Contracts Definition 2
///
/// A crossing record V(Γ) contains:
/// 1. u_src: source component
/// 2. u_dst: destination component
/// 3. t_c: cycle index
/// 4. n_seq: sequence number within the cycle (1..=12)
/// 5. h_prev: hash of the previous crossing record in this cycle
///
/// ## Cryptographic fields
///
/// - `chain_hash`: BLAKE3 hash of this record's fields ∥ h_prev
/// - `signature`: Ed25519 signature by the boundary keypair
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrossingRecord {
    /// Source unit that produced the transition.
    pub source: UnitId,

    /// Destination unit (or layer) that will consume the transition.
    pub destination: UnitId,

    /// Cycle index. 0 for genesis, incremented per cycle.
    pub cycle_index: u64,

    /// Sequence number of this crossing within the current cycle.
    /// Range: 1..=12 (6 vertical + 6 horizontal per cycle).
    /// Range: 1..=6 for ring-only cycles; 1..=12 for full cycles including presentation crossings.
    pub sequence_number: u8,

    /// The type of this crossing: Vertical (ring) or Horizontal (presentation).
    ///
    /// Contracts §4.2: Vertical crossings carry ring state inter-unit;
    /// horizontal crossings carry presentation state intra-unit.
    /// Both produce crossing records in the same intra-cycle chain.
    pub crossing_type: CrossingType,

    /// Timestamp in nanoseconds (monotonic clock).
    pub timestamp_ns: u64,

    /// Hash of the previous crossing record in this cycle.
    /// For the first crossing of a standard cycle: SOM_{t-1}.hash
    /// For the first crossing of genesis: H(s_0)
    pub prev_hash: [u8; 32],

    /// BLAKE3 hash of this record: H(fields ∥ prev_hash).
    /// Links this crossing into the intra-cycle chain (Contracts §2.2).
    pub chain_hash: [u8; 32],

    /// Ed25519 signature by the boundary keypair.
    /// The boundary — not the producer — signs the crossing record.
    /// This is the structural expression of Invariant 9 (no self-certification).
    #[serde(with = "signature_bytes")]
    pub signature: [u8; 64],
}

impl CrossingRecord {
    /// Compute the chain hash for a crossing record.
    ///
    /// H(source ∥ destination ∥ cycle_index ∥ sequence_number ∥ crossing_type ∥ timestamp_ns ∥ prev_hash)
    ///
    /// This is deterministic and does not include the signature
    /// (the signature covers the chain_hash, not vice versa).
    pub fn compute_chain_hash(
        source: UnitId,
        destination: UnitId,
        cycle_index: u64,
        sequence_number: u8,
        crossing_type: CrossingType,
        timestamp_ns: u64,
        prev_hash: &[u8; 32],
    ) -> [u8; 32] {
        let mut hasher = blake3::Hasher::new();
        hasher.update(&[source as u8]);
        hasher.update(&[destination as u8]);
        hasher.update(&cycle_index.to_le_bytes());
        hasher.update(&[sequence_number]);
        hasher.update(&[crossing_type as u8]);
        hasher.update(&timestamp_ns.to_le_bytes());
        hasher.update(prev_hash);
        *hasher.finalize().as_bytes()
    }

    /// Verify that this record's chain_hash is consistent with its fields.
    pub fn verify_chain_hash(&self) -> bool {
        let expected = Self::compute_chain_hash(
            self.source,
            self.destination,
            self.cycle_index,
            self.sequence_number,
            self.crossing_type,
            self.timestamp_ns,
            &self.prev_hash,
        );
        self.chain_hash == expected
    }

    /// Returns the bytes that should be signed by the boundary keypair.
    /// This is the chain_hash — the boundary signs the hash, not the full record.
    ///
    /// The boundary signs canonical CBOR of the signing view (per OPUS §8).
    /// `b"crossing:" ∥ blake3(canonical_cbor(CrossingRecordSigningView::from(self)))`,
    /// which binds the 8 non-signature fields cryptographically (strengthens I-5
    /// vs. signing only the chain_hash, which left source/destination/etc.
    /// unbound by the signature even though chain_hash internally derives from
    /// them).
    ///
    /// Retained for back-compat with non-boundary signing paths and pre-amendment
    /// fixtures; the boundary signer no longer calls this directly.
    pub fn signable_bytes(&self) -> &[u8; 32] {
        &self.chain_hash
    }
}

/// Sign-time / verify-time view of `CrossingRecord` over the 8 non-signature
/// fields, in the same field-definition order as `CrossingRecord` minus
/// `signature`.
///
/// ## Purpose
///
/// The boundary signer (`soma_ring/boundary/src/signing.rs`) and the chain
/// digest (`soma_mirror/src/crossing_chain.rs::payload_digest_for`) both
/// compute over `blake3(canonical_cbor(view))` of THIS struct so that:
///
/// 1. The boundary's signature cryptographically binds all 8 non-signature
///    fields (strengthening I-5 — pre-amendment, signing `chain_hash` left
///    `source`/`destination`/`cycle_index`/`sequence_number`/`crossing_type`/
///    `timestamp_ns`/`prev_hash`/`chain_hash` unbound by the signature shape;
///    chain_hash internally derives from a subset but the SIGNATURE shape
///    didn't bind the source-tier fields independently).
/// 2. The chain digest in `mirror.redb::CROSSING_CHAIN` matches the input the
///    boundary actually signed — so the chain-audit verifier can reconstruct
///    The signing input embeds `chain.payload_digest` to provide
///    forward-binding from chain head into the crossing signature.
///
/// ## Encoding determinism
///
/// CBOR encoding via `ciborium` in canonical mode (RFC 8949 §4.2.1: sorted
/// map keys, definite-length, smallest-int). Two views constructed from the
/// same record encode byte-equal — pinned by the
/// `signing_view_canonical_cbor_byte_equal_for_two_clones` test below.
///
/// ## Borrow shape
///
/// Borrowed (`<'a>` against the source record) for zero-copy at sign-time.
/// `prev_hash` and `chain_hash` are `&'a [u8; 32]` references into the source
/// record's storage; serde encodes them as byte arrays. Other fields (UnitId,
/// CrossingType, integers) are `Copy` so the visit costs nothing.
///
/// NOT persisted — fixture for sign-time + verify-time digest derivation only.
/// The persisted shape is `CrossingRecord` (with signature) and
/// `mirror_mirror::types::CrossingChainRecord` (with payload_digest).
#[derive(Debug, Serialize)]
pub struct CrossingRecordSigningView<'a> {
    pub source: UnitId,
    pub destination: UnitId,
    pub cycle_index: u64,
    pub sequence_number: u8,
    pub crossing_type: CrossingType,
    pub timestamp_ns: u64,
    pub prev_hash: &'a [u8; 32],
    pub chain_hash: &'a [u8; 32],
}

impl<'a> From<&'a CrossingRecord> for CrossingRecordSigningView<'a> {
    fn from(record: &'a CrossingRecord) -> Self {
        Self {
            source: record.source,
            destination: record.destination,
            cycle_index: record.cycle_index,
            sequence_number: record.sequence_number,
            crossing_type: record.crossing_type,
            timestamp_ns: record.timestamp_ns,
            prev_hash: &record.prev_hash,
            chain_hash: &record.chain_hash,
        }
    }
}

// inline: exercises module-private items via super::*
#[cfg(test)]
mod tests {
    use super::*;

    fn make_record(seq: u8, prev: [u8; 32]) -> CrossingRecord {
        let chain_hash = CrossingRecord::compute_chain_hash(
            UnitId::FU,
            UnitId::MU,
            1,
            seq,
            CrossingType::Vertical,
            1_000_000_000,
            &prev,
        );
        CrossingRecord {
            source: UnitId::FU,
            destination: UnitId::MU,
            cycle_index: 1,
            sequence_number: seq,
            crossing_type: CrossingType::Vertical,
            timestamp_ns: 1_000_000_000,
            prev_hash: prev,
            chain_hash,
            signature: [0u8; 64], // placeholder — boundary fills this
        }
    }

    #[test]
    fn chain_hash_is_deterministic() {
        let r1 = make_record(1, [0u8; 32]);
        let r2 = make_record(1, [0u8; 32]);
        assert_eq!(r1.chain_hash, r2.chain_hash);
    }

    #[test]
    fn chain_hash_changes_with_sequence() {
        let r1 = make_record(1, [0u8; 32]);
        let r2 = make_record(2, [0u8; 32]);
        assert_ne!(r1.chain_hash, r2.chain_hash);
    }

    #[test]
    fn chain_hash_changes_with_prev_hash() {
        let r1 = make_record(1, [0u8; 32]);
        let r2 = make_record(1, [1u8; 32]);
        assert_ne!(r1.chain_hash, r2.chain_hash);
    }

    #[test]
    fn chain_hash_verification_passes_for_correct_record() {
        let r = make_record(1, [0u8; 32]);
        assert!(r.verify_chain_hash());
    }

    #[test]
    fn chain_hash_verification_fails_on_tamper() {
        let mut r = make_record(1, [0u8; 32]);
        r.timestamp_ns = 999; // tamper with a field
        assert!(!r.verify_chain_hash());
    }

    #[test]
    fn crossing_records_chain_correctly() {
        // Build a 3-link chain: record 1 → record 2 → record 3
        let r1 = make_record(1, [0u8; 32]);
        let r2 = make_record(2, r1.chain_hash);
        let r3 = make_record(3, r2.chain_hash);

        assert!(r1.verify_chain_hash());
        assert!(r2.verify_chain_hash());
        assert!(r3.verify_chain_hash());

        // r3 transitively depends on r1
        assert_eq!(r2.prev_hash, r1.chain_hash);
        assert_eq!(r3.prev_hash, r2.chain_hash);
    }

    #[test]
    fn source_and_destination_affect_hash() {
        let h1 = CrossingRecord::compute_chain_hash(
            UnitId::FU,
            UnitId::MU,
            1,
            1,
            CrossingType::Vertical,
            1_000,
            &[0u8; 32],
        );
        let h2 = CrossingRecord::compute_chain_hash(
            UnitId::MU,
            UnitId::CU,
            1,
            1,
            CrossingType::Vertical,
            1_000,
            &[0u8; 32],
        );
        assert_ne!(h1, h2);
    }

    #[test]
    fn crossing_type_affects_hash() {
        let h1 = CrossingRecord::compute_chain_hash(
            UnitId::FU,
            UnitId::MU,
            1,
            1,
            CrossingType::Vertical,
            1_000,
            &[0u8; 32],
        );
        let h2 = CrossingRecord::compute_chain_hash(
            UnitId::FU,
            UnitId::MU,
            1,
            1,
            CrossingType::Horizontal,
            1_000,
            &[0u8; 32],
        );
        assert_ne!(
            h1, h2,
            "Vertical and horizontal crossings must produce different hashes"
        );
    }

    // ── CrossingRecordSigningView ─────────────────────────────────

    /// AC-A1: two views constructed from clones of the same record encode
    /// byte-equal under canonical CBOR. Pins the determinism contract that
    /// boundary signer + the ring's digest function both depend on.
    #[test]
    fn signing_view_canonical_cbor_byte_equal_for_two_clones()
    -> Result<(), Box<dyn std::error::Error>> {
        let r1 = make_record(3, [7u8; 32]);
        let r2 = r1.clone();
        let v1 = CrossingRecordSigningView::from(&r1);
        let v2 = CrossingRecordSigningView::from(&r2);

        let mut buf1 = Vec::new();
        ciborium::ser::into_writer(&v1, &mut buf1)?;
        let mut buf2 = Vec::new();
        ciborium::ser::into_writer(&v2, &mut buf2)?;
        assert_eq!(buf1, buf2, "canonical-CBOR encoding must be deterministic");
        assert!(!buf1.is_empty(), "encoding must not be empty");
        Ok(())
    }

    /// AC-A2: changing any of the 8 view fields changes the CBOR encoding
    /// (strengthening I-5 — the post-amendment signing input binds source,
    /// destination, cycle_index, sequence_number, crossing_type, timestamp_ns,
    /// prev_hash, and chain_hash; pre-amendment signing input was
    /// `chain_hash` only, leaving the source-tier fields unbound by the
    /// signature shape).
    #[test]
    fn signing_view_encoding_changes_with_each_bound_field()
    -> Result<(), Box<dyn std::error::Error>> {
        let base = make_record(1, [0u8; 32]);
        let mut buf_base = Vec::new();
        ciborium::ser::into_writer(&CrossingRecordSigningView::from(&base), &mut buf_base)?;

        // Mutate one field at a time and assert the encoding diverges.
        let mut mut_seq = base.clone();
        mut_seq.sequence_number = 7;
        let mut buf_seq = Vec::new();
        ciborium::ser::into_writer(&CrossingRecordSigningView::from(&mut_seq), &mut buf_seq)?;
        assert_ne!(buf_base, buf_seq, "sequence_number must affect view encoding");

        let mut mut_ts = base.clone();
        mut_ts.timestamp_ns = base.timestamp_ns + 1;
        let mut buf_ts = Vec::new();
        ciborium::ser::into_writer(&CrossingRecordSigningView::from(&mut_ts), &mut buf_ts)?;
        assert_ne!(buf_base, buf_ts, "timestamp_ns must affect view encoding");

        let mut mut_prev = base.clone();
        mut_prev.prev_hash = [9u8; 32];
        let mut buf_prev = Vec::new();
        ciborium::ser::into_writer(&CrossingRecordSigningView::from(&mut_prev), &mut buf_prev)?;
        assert_ne!(buf_base, buf_prev, "prev_hash must affect view encoding");

        Ok(())
    }

    /// AC-A3: changing only the signature field (the field excluded from the
    /// view) does NOT change the view encoding. Confirms the view scopes the
    /// signing input to the 8 non-signature fields exactly.
    #[test]
    fn signing_view_encoding_invariant_under_signature_mutation()
    -> Result<(), Box<dyn std::error::Error>> {
        let r1 = make_record(1, [0u8; 32]);
        let mut r2 = r1.clone();
        r2.signature = [0xFFu8; 64];

        let mut buf1 = Vec::new();
        ciborium::ser::into_writer(&CrossingRecordSigningView::from(&r1), &mut buf1)?;
        let mut buf2 = Vec::new();
        ciborium::ser::into_writer(&CrossingRecordSigningView::from(&r2), &mut buf2)?;

        assert_eq!(
            buf1, buf2,
            "view encoding must be invariant under signature mutation \
             (the field excluded from the view)"
        );
        Ok(())
    }
}