Skip to main content

heddle_object_model/object/
state_review.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Review signatures: who reviewed a state, in what role, with what scope.
3//!
4//! Distinct from [`StateSignature`](crate::object::StateSignature). That signature
5//! authenticates *authorship* — "this principal captured this state". A
6//! [`ReviewSignature`] authenticates *review* — "this actor read / previewed /
7//! co-reviewed this state". A state may carry many review signatures across its
8//! lifetime; the kind enum is extensible to admit future kinds without
9//! re-encoding old data.
10//!
11//! The cryptographic primitives live in `crates/crypto/`. This module owns the
12//! payload format the signature is computed over, so verifiers in any language
13//! can reproduce it.
14
15use serde::{Deserialize, Serialize};
16
17use crate::object::{hash::StateId, state_attribution::Principal};
18
19/// Stable byte prefix the signing payload begins with. This pre-launch domain
20/// deliberately has no compatibility path for the superseded `hd-` draft tag.
21pub const SIGNING_PAYLOAD_VERSION_TAG: &[u8] = b"hc-rev-sig-v1\x00";
22
23#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ReviewSignaturesBlob {
25    pub format_version: u8,
26    pub signatures: Vec<ReviewSignature>,
27}
28
29versioned_msgpack_blob! {
30    blob: ReviewSignaturesBlob,
31    item: ReviewSignature,
32    field: signatures,
33    error: ReviewSignatureError,
34    codec_err: Encoding,
35    version: 1,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ReviewSignature {
40    pub actor: Principal,
41    pub kind: ReviewKind,
42    pub scope: ReviewScope,
43    /// Reserved for future review kinds (e.g. send-it / fast-track) that need
44    /// a written rationale. Read/preview/co-review leave this `None`.
45    #[serde(default)]
46    pub justification: Option<String>,
47    /// Unix epoch seconds.
48    pub signed_at: i64,
49    pub algorithm: String,
50    pub public_key: String,
51    /// Hex-encoded signature bytes — same encoding convention as
52    /// [`StateSignature::signature`](crate::object::StateSignature).
53    pub signature: String,
54}
55
56impl ReviewSignature {
57    pub fn validate(&self) -> Result<(), ReviewSignatureError> {
58        if self.algorithm.is_empty() {
59            return Err(ReviewSignatureError::EmptyAlgorithm);
60        }
61        if self.public_key.is_empty() {
62            return Err(ReviewSignatureError::EmptyPublicKey);
63        }
64        if self.signature.is_empty() {
65            return Err(ReviewSignatureError::EmptySignature);
66        }
67        self.scope.validate()?;
68        Ok(())
69    }
70}
71
72/// Reviewer roles. New variants append at the tail; the wire format stays
73/// backwards compatible because `serde` emits the snake-case discriminant.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum ReviewKind {
77    /// Reviewer claims to have read the change.
78    Read,
79    /// Agent ran a preview pass — listed signals, optionally annotated.
80    AgentPreview,
81    /// Agent acted as co-reviewer; comments and signatures recorded.
82    AgentCoReview,
83}
84
85impl ReviewKind {
86    pub fn as_str(&self) -> &'static str {
87        match self {
88            Self::Read => "read",
89            Self::AgentPreview => "agent_preview",
90            Self::AgentCoReview => "agent_co_review",
91        }
92    }
93}
94
95/// Reviewer signed off on the whole change, or on a specific list of symbols.
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub enum ReviewScope {
98    WholeChange,
99    Symbols(Vec<SymbolAnchor>),
100}
101
102impl ReviewScope {
103    pub fn validate(&self) -> Result<(), ReviewSignatureError> {
104        match self {
105            Self::WholeChange => Ok(()),
106            Self::Symbols(symbols) => {
107                if symbols.is_empty() {
108                    return Err(ReviewSignatureError::EmptySymbolScope);
109                }
110                for s in symbols {
111                    s.validate()?;
112                }
113                Ok(())
114            }
115        }
116    }
117}
118
119/// Durable symbol-level anchor: a file path plus a symbol name. No line range
120/// — line numbers move under reformatting; symbols do not.
121#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
122pub struct SymbolAnchor {
123    pub file: String,
124    pub symbol: String,
125}
126
127impl SymbolAnchor {
128    pub fn new(file: impl Into<String>, symbol: impl Into<String>) -> Self {
129        Self {
130            file: file.into(),
131            symbol: symbol.into(),
132        }
133    }
134
135    pub fn validate(&self) -> Result<(), ReviewSignatureError> {
136        if self.file.is_empty() {
137            return Err(ReviewSignatureError::EmptyAnchorFile);
138        }
139        if self.symbol.is_empty() {
140            return Err(ReviewSignatureError::EmptyAnchorSymbol);
141        }
142        Ok(())
143    }
144}
145
146/// Build the deterministic byte payload that a [`ReviewSignature`] is computed
147/// over. Re-implementing this in another language (TypeScript, Python) must
148/// produce byte-identical output for verification to round-trip.
149///
150/// Layout: version tag, then NUL-terminated string fields, then fixed-width
151/// integers. Uses NUL byte as a field separator, which is safe because
152/// `change_id` is hex and other fields are utf-8 strings without embedded NULs.
153pub fn signing_payload(
154    state_id: StateId,
155    kind: ReviewKind,
156    scope: &ReviewScope,
157    signed_at: i64,
158    justification: Option<&str>,
159) -> Vec<u8> {
160    let mut buf = Vec::with_capacity(SIGNING_PAYLOAD_VERSION_TAG.len() + 256);
161    buf.extend_from_slice(SIGNING_PAYLOAD_VERSION_TAG);
162    buf.extend_from_slice(state_id.to_string_full().as_bytes());
163    buf.push(0);
164    buf.extend_from_slice(kind.as_str().as_bytes());
165    buf.push(0);
166    match scope {
167        ReviewScope::WholeChange => {
168            buf.extend_from_slice(b"whole_change");
169            buf.push(0);
170        }
171        ReviewScope::Symbols(symbols) => {
172            buf.extend_from_slice(b"symbols");
173            buf.push(0);
174            buf.extend_from_slice(&(symbols.len() as u32).to_le_bytes());
175            for s in symbols {
176                buf.extend_from_slice(s.file.as_bytes());
177                buf.push(0);
178                buf.extend_from_slice(s.symbol.as_bytes());
179                buf.push(0);
180            }
181        }
182    }
183    buf.extend_from_slice(&signed_at.to_le_bytes());
184    if let Some(j) = justification {
185        buf.push(1);
186        buf.extend_from_slice(j.as_bytes());
187        buf.push(0);
188    } else {
189        buf.push(0);
190    }
191    buf
192}
193
194#[derive(Debug, thiserror::Error)]
195pub enum ReviewSignatureError {
196    #[error("unsupported review signatures blob version {0}")]
197    UnsupportedVersion(u8),
198    #[error("review signature must declare a non-empty algorithm")]
199    EmptyAlgorithm,
200    #[error("review signature must include a public key")]
201    EmptyPublicKey,
202    #[error("review signature must include a signature value")]
203    EmptySignature,
204    #[error("symbol-scope review must include at least one symbol")]
205    EmptySymbolScope,
206    #[error("symbol anchor must reference a non-empty file")]
207    EmptyAnchorFile,
208    #[error("symbol anchor must reference a non-empty symbol")]
209    EmptyAnchorSymbol,
210    #[error("review signatures blob encoding error: {0}")]
211    Encoding(String),
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn sample_principal() -> Principal {
219        Principal::new("Alice", "alice@example.com")
220    }
221
222    fn sample_signature() -> ReviewSignature {
223        ReviewSignature {
224            actor: sample_principal(),
225            kind: ReviewKind::Read,
226            scope: ReviewScope::WholeChange,
227            justification: None,
228            signed_at: 1_700_000_000,
229            algorithm: "ed25519".into(),
230            public_key: "deadbeef".into(),
231            signature: "abad1dea".into(),
232        }
233    }
234
235    #[test]
236    fn read_signature_validates() {
237        sample_signature().validate().unwrap();
238    }
239
240    #[test]
241    fn empty_symbol_scope_rejected() {
242        let mut sig = sample_signature();
243        sig.scope = ReviewScope::Symbols(vec![]);
244        assert!(matches!(
245            sig.validate(),
246            Err(ReviewSignatureError::EmptySymbolScope)
247        ));
248    }
249
250    #[test]
251    fn unsigned_blob_validates() {
252        let blob = ReviewSignaturesBlob::new(vec![]);
253        blob.validate().unwrap();
254    }
255
256    #[test]
257    fn blob_roundtrip() {
258        let blob = ReviewSignaturesBlob::new(vec![sample_signature()]);
259        let bytes = blob.encode().unwrap();
260        let decoded = ReviewSignaturesBlob::decode(&bytes).unwrap();
261        assert_eq!(blob, decoded);
262    }
263
264    #[test]
265    fn signing_payload_distinguishes_scope() {
266        let id = StateId::from_bytes([1; 32]);
267        let whole = signing_payload(id, ReviewKind::Read, &ReviewScope::WholeChange, 0, None);
268        let one_symbol = signing_payload(
269            id,
270            ReviewKind::Read,
271            &ReviewScope::Symbols(vec![SymbolAnchor::new("a.rs", "foo")]),
272            0,
273            None,
274        );
275        assert_ne!(whole, one_symbol);
276    }
277
278    #[test]
279    fn signing_payload_starts_with_version_tag() {
280        let id = StateId::from_bytes([1; 32]);
281        let payload = signing_payload(id, ReviewKind::Read, &ReviewScope::WholeChange, 0, None);
282        assert_eq!(SIGNING_PAYLOAD_VERSION_TAG, b"hc-rev-sig-v1\x00");
283        assert!(payload.starts_with(SIGNING_PAYLOAD_VERSION_TAG));
284    }
285
286    #[test]
287    fn signing_payload_distinguishes_kind() {
288        let id = StateId::from_bytes([1; 32]);
289        let read = signing_payload(id, ReviewKind::Read, &ReviewScope::WholeChange, 0, None);
290        let preview = signing_payload(
291            id,
292            ReviewKind::AgentPreview,
293            &ReviewScope::WholeChange,
294            0,
295            None,
296        );
297        assert_ne!(read, preview);
298    }
299}