Skip to main content

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