Skip to main content

objects/object/
state_attachment.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use super::{Attribution, ContentHash, StateId, StateSignature};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
9#[serde(transparent)]
10pub struct StateAttachmentId(ContentHash);
11
12impl StateAttachmentId {
13    pub fn from_hash(hash: ContentHash) -> Self {
14        Self(hash)
15    }
16
17    pub fn as_hash(&self) -> &ContentHash {
18        &self.0
19    }
20}
21
22impl std::fmt::Display for StateAttachmentId {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        write!(f, "ha-{}", self.0.short())
25    }
26}
27
28#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
29pub enum StateAttachmentBody {
30    Context(ContentHash),
31    RiskSignals(ContentHash),
32    ReviewSignatures(ContentHash),
33    Discussions(ContentHash),
34    StructuredConflicts(ContentHash),
35    /// Content hash of the state's `SemanticIndexRoot` blob (heddle#1067).
36    SemanticIndex(ContentHash),
37    Signature(StateSignature),
38}
39
40/// The kind of a [`StateAttachmentBody`], with the payload projected away.
41///
42/// Kind is a pure function of the record: [`StateAttachmentBody::kind`] maps a
43/// body to its kind with no I/O and no ambiguity. This is the primitive that
44/// currency (last-attachment-of-a-kind) and supersession (same-kind guard) are
45/// expressed in terms of, and that the wire layer threads through
46/// `wire::ObjectId` (heddle#1080, Fable §B(1)).
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub enum StateAttachmentKind {
49    Context,
50    RiskSignals,
51    ReviewSignatures,
52    Discussions,
53    StructuredConflicts,
54    SemanticIndex,
55    Signature,
56}
57
58impl StateAttachmentBody {
59    /// The [`StateAttachmentKind`] of this body — a pure projection that
60    /// discards the payload. Exhaustive by construction: adding a body variant
61    /// forces a matching kind arm here.
62    pub fn kind(&self) -> StateAttachmentKind {
63        match self {
64            StateAttachmentBody::Context(_) => StateAttachmentKind::Context,
65            StateAttachmentBody::RiskSignals(_) => StateAttachmentKind::RiskSignals,
66            StateAttachmentBody::ReviewSignatures(_) => StateAttachmentKind::ReviewSignatures,
67            StateAttachmentBody::Discussions(_) => StateAttachmentKind::Discussions,
68            StateAttachmentBody::StructuredConflicts(_) => StateAttachmentKind::StructuredConflicts,
69            StateAttachmentBody::SemanticIndex(_) => StateAttachmentKind::SemanticIndex,
70            StateAttachmentBody::Signature(_) => StateAttachmentKind::Signature,
71        }
72    }
73}
74
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76pub struct StateAttachment {
77    pub state_id: StateId,
78    pub body: StateAttachmentBody,
79    pub attribution: Attribution,
80    pub created_at: DateTime<Utc>,
81    pub supersedes: Option<StateAttachmentId>,
82}
83
84impl StateAttachment {
85    pub fn id(&self) -> StateAttachmentId {
86        let bytes = rmp_serde::to_vec_named(self).expect("state attachment encoding is infallible");
87        StateAttachmentId::from_hash(ContentHash::compute_typed("state-attachment", &bytes))
88    }
89}
90
91#[cfg(test)]
92mod wire_compat_tests {
93    //! Cross-version decode guard for the `SemanticIndex` attachment variant
94    //! (heddle#1067 / heddle#1078).
95    //!
96    //! `StateAttachmentBody` is an externally-tagged rmp-named enum, so a
97    //! `SemanticIndex(hash)` value serializes as a one-key map keyed on the
98    //! variant name. A consumer built before the variant existed (heddle 0.10.2,
99    //! e.g. a `weft` that has not yet bumped its `heddle-objects` pin) has no
100    //! matching arm and therefore CANNOT decode such an attachment. This test
101    //! pins that hazard so the failure mode is a documented regression guard and
102    //! not a field surprise: weft must bump to `=0.10.3` before a heddle release
103    //! ships #1067.
104
105    use chrono::{DateTime, Utc};
106    use serde::Deserialize;
107
108    use super::*;
109    use crate::object::{Attribution, Principal};
110
111    /// A faithful mirror of the **0.10.2** `StateAttachmentBody`, i.e. the arm
112    /// set BEFORE `SemanticIndex` was introduced. This is exactly the shape an
113    /// older consumer's decoder would carry. The payloads exist only to shape
114    /// the decoder — they are never read.
115    #[derive(Debug, Deserialize)]
116    #[allow(dead_code)]
117    enum LegacyBody {
118        Context(ContentHash),
119        RiskSignals(ContentHash),
120        ReviewSignatures(ContentHash),
121        Discussions(ContentHash),
122        StructuredConflicts(ContentHash),
123        Signature(StateSignature),
124    }
125
126    /// A 0.10.2 consumer's `StateAttachment`, structurally identical to the
127    /// current one but with the legacy (SemanticIndex-less) body enum.
128    #[derive(Debug, Deserialize)]
129    #[allow(dead_code)]
130    struct LegacyAttachment {
131        state_id: StateId,
132        body: LegacyBody,
133        attribution: Attribution,
134        created_at: DateTime<Utc>,
135        supersedes: Option<StateAttachmentId>,
136    }
137
138    fn sample(body: StateAttachmentBody) -> StateAttachment {
139        StateAttachment {
140            state_id: StateId::from_bytes([7; 32]),
141            body,
142            attribution: Attribution::human(Principal::new("Test", "test@example.com")),
143            created_at: Utc::now(),
144            supersedes: None,
145        }
146    }
147
148    #[test]
149    fn semantic_index_attachment_is_undecodable_by_0_10_2_consumer() {
150        let attachment = sample(StateAttachmentBody::SemanticIndex(ContentHash::compute(b"root")));
151        let bytes = rmp_serde::to_vec_named(&attachment).expect("encode");
152
153        // A current (0.10.3+) consumer round-trips it cleanly.
154        let current: StateAttachment = rmp_serde::from_slice(&bytes).expect("current decoder");
155        assert_eq!(current, attachment);
156
157        // A 0.10.2 consumer, whose decoder has no `SemanticIndex` arm, MUST
158        // reject it — this is precisely why weft has to bump before a heddle
159        // release ships the SemanticIndex attachment.
160        let legacy: Result<LegacyAttachment, _> = rmp_serde::from_slice(&bytes);
161        assert!(
162            legacy.is_err(),
163            "a 0.10.2 decoder must fail on a SemanticIndex-tagged attachment"
164        );
165    }
166
167    #[test]
168    fn known_variant_still_decodes_on_0_10_2_consumer() {
169        // Control: an attachment kind the 0.10.2 consumer DOES know still
170        // decodes with the legacy enum, so the failure above is specifically the
171        // new variant and not a framing/format mismatch.
172        let attachment = sample(StateAttachmentBody::Context(ContentHash::compute(b"ctx")));
173        let bytes = rmp_serde::to_vec_named(&attachment).expect("encode");
174        let legacy: Result<LegacyAttachment, _> = rmp_serde::from_slice(&bytes);
175        assert!(
176            legacy.is_ok(),
177            "a 0.10.2 decoder must still handle the Context variant it knows"
178        );
179    }
180}