Skip to main content

heddle_object_model/object/
redaction.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Redaction — a declaration that a blob in a state is sensitive and must
3//! materialize as a stub instead of its content.
4//!
5//! Redaction is *additive*: a new object that supersedes a read of the
6//! original. The blob's bytes stay on disk until `heddle purge` explicitly
7//! removes them; the redaction itself is the readers' contract that those
8//! bytes are no longer accessible through the materialize path.
9//!
10//! Distinct from review signatures and state signatures:
11//! - [`StateSignature`](crate::object::StateSignature) authenticates a state's authorship.
12//! - [`ReviewSignature`](crate::object::ReviewSignature) authenticates that a state was reviewed.
13//! - [`Redaction`] is itself a signable operation — it claims that a specific
14//!   blob in a specific state should no longer materialize. The signature
15//!   binds operator → declaration so audits can trace who hid what when.
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20use crate::object::{ContentHash, Principal, StateId, StateSignature};
21
22/// Stable byte prefix the signing payload begins with. Bumping this invalidates
23/// signatures written with an older prefix unless verification also gains
24/// explicit version dispatch. Version 2 intentionally makes a clean break from
25/// the unused version 1 format.
26pub const REDACTION_SIGNING_PAYLOAD_VERSION_TAG: &[u8] = b"hd-redact-v2\x00";
27
28/// A redaction declaration on a single blob in a single state.
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Redaction {
31    /// The blob whose bytes should no longer materialize.
32    pub redacted_blob: ContentHash,
33    /// The state in which the path resides. A redaction is *scoped* to the
34    /// (blob, state, path) triple; `--all-states` produces one redaction
35    /// per matching state.
36    pub state: StateId,
37    /// Path within the state's tree where the blob lives.
38    pub path: String,
39    /// Operator-supplied reason ("leaked credential", "PII", ...).
40    pub reason: String,
41    /// Who declared the redaction.
42    pub redactor: Principal,
43    /// When the redaction was declared. RFC3339 string at the wire format
44    /// boundary; `DateTime<Utc>` internally.
45    pub redacted_at: DateTime<Utc>,
46    /// Optional cryptographic signature over the canonical signing payload
47    /// (see [`canonical_signing_payload`]). `None` for unsigned redactions
48    /// (still recorded in the oplog, still surfaced in materialize, but
49    /// reviewers will see them flagged unsigned).
50    #[serde(default)]
51    pub signature: Option<StateSignature>,
52    /// When `heddle purge` removed the underlying blob bytes. `None` while
53    /// the redaction is declared-but-bytes-still-on-disk.
54    #[serde(default)]
55    pub purged_at: Option<DateTime<Utc>>,
56    /// The redaction this one supersedes, if any — for chains where the
57    /// reason or scope was updated. Identified by the prior redaction's
58    /// content hash.
59    #[serde(default)]
60    pub supersedes: Option<ContentHash>,
61}
62
63impl Redaction {
64    /// Build the canonical bytes a signer covers. Anything outside this
65    /// payload (the `signature` itself) is intentionally excluded because a
66    /// signature cannot sign itself. Lifecycle state, including `purged_at`,
67    /// is covered so a relay cannot forge a destructive purge transition.
68    pub fn canonical_signing_payload(&self) -> Vec<u8> {
69        let mut buf = Vec::with_capacity(256);
70        buf.extend_from_slice(REDACTION_SIGNING_PAYLOAD_VERSION_TAG);
71        buf.extend_from_slice(self.redacted_blob.as_bytes());
72        buf.extend_from_slice(self.state.as_bytes());
73        buf.extend_from_slice(self.path.as_bytes());
74        buf.push(0);
75        buf.extend_from_slice(self.reason.as_bytes());
76        buf.push(0);
77        buf.extend_from_slice(self.redactor.name.as_bytes());
78        buf.push(0);
79        buf.extend_from_slice(self.redactor.email.as_bytes());
80        buf.push(0);
81        buf.extend_from_slice(self.redacted_at.to_rfc3339().as_bytes());
82        buf.push(0);
83        match self.purged_at {
84            Some(purged_at) => {
85                buf.push(1);
86                buf.extend_from_slice(purged_at.to_rfc3339().as_bytes());
87            }
88            None => buf.push(0),
89        }
90        buf.push(0);
91        if let Some(supersedes) = &self.supersedes {
92            buf.extend_from_slice(supersedes.as_bytes());
93        }
94        buf
95    }
96
97    /// Mark the redaction as purged. Returns `true` if the state changed
98    /// (`false` if already purged — callers can use this for idempotency).
99    pub fn mark_purged(&mut self, at: DateTime<Utc>) -> bool {
100        if self.purged_at.is_some() {
101            false
102        } else {
103            self.purged_at = Some(at);
104            true
105        }
106    }
107
108    /// Whether the blob bytes are gone from local storage.
109    pub fn is_purged(&self) -> bool {
110        self.purged_at.is_some()
111    }
112
113    /// Format the stub a reader sees instead of the redacted blob content.
114    /// Plain text, ASCII-only, safe to embed in materialized worktrees and
115    /// downstream Git exports.
116    pub fn stub_text(&self, redaction_id: &ContentHash) -> String {
117        let mut out = String::with_capacity(256);
118        out.push_str("# This file was redacted by Heddle.\n");
119        out.push_str(&format!(
120            "# redacted-at: {}\n",
121            self.redacted_at.to_rfc3339()
122        ));
123        out.push_str(&format!(
124            "# redactor:    {} <{}>\n",
125            self.redactor.name, self.redactor.email
126        ));
127        out.push_str(&format!("# reason:      {}\n", self.reason));
128        out.push_str(&format!("# redaction:   {}\n", redaction_id.short()));
129        if let Some(purged_at) = self.purged_at {
130            out.push_str(&format!("# purged-at:   {}\n", purged_at.to_rfc3339()));
131            out.push_str("# The original bytes have been purged from local storage.\n");
132        } else {
133            out.push_str("# The original bytes remain on disk pending purge.\n");
134        }
135        out
136    }
137}
138
139/// On-disk blob containing all redactions for a single blob hash. One file
140/// per redacted blob, encoded with `rmp-serde` — matches the
141/// [`ReviewSignaturesBlob`](crate::object::ReviewSignaturesBlob) pattern.
142#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub struct RedactionsBlob {
144    pub format_version: u8,
145    pub redactions: Vec<Redaction>,
146}
147
148impl RedactionsBlob {
149    pub const FORMAT_VERSION: u8 = 1;
150
151    pub fn new(redactions: Vec<Redaction>) -> Self {
152        Self {
153            format_version: Self::FORMAT_VERSION,
154            redactions,
155        }
156    }
157
158    pub fn empty() -> Self {
159        Self::new(Vec::new())
160    }
161
162    pub fn encode(&self) -> Result<Vec<u8>, RedactionError> {
163        rmp_serde::to_vec(self).map_err(|err| RedactionError::Encoding(err.to_string()))
164    }
165
166    pub fn decode(bytes: &[u8]) -> Result<Self, RedactionError> {
167        rmp_serde::from_slice(bytes).map_err(|err| RedactionError::Decoding(err.to_string()))
168    }
169
170    pub fn push(&mut self, redaction: Redaction) {
171        self.redactions.push(redaction);
172    }
173
174    /// `true` iff any redaction in this blob is non-superseded — i.e. the
175    /// reader should see the stub. Today every redaction is active; a
176    /// future "unredact" verb would skip the superseded ones.
177    pub fn has_active(&self) -> bool {
178        !self.redactions.is_empty()
179    }
180
181    /// The most recent redaction, by `redacted_at`. Used as the canonical
182    /// stub source when multiple redactions exist for the same blob (e.g.
183    /// because of `--all-states` plus a later refinement).
184    pub fn latest(&self) -> Option<&Redaction> {
185        self.redactions.iter().max_by_key(|r| r.redacted_at)
186    }
187
188    /// Mark every redaction in this blob as purged. Returns the count that
189    /// actually transitioned (others were already purged).
190    pub fn mark_all_purged(&mut self, at: DateTime<Utc>) -> usize {
191        let mut transitioned = 0;
192        for redaction in &mut self.redactions {
193            if redaction.mark_purged(at) {
194                transitioned += 1;
195            }
196        }
197        transitioned
198    }
199}
200
201/// Errors produced while encoding/decoding redactions.
202#[derive(Debug, thiserror::Error)]
203pub enum RedactionError {
204    #[error("encoding redaction: {0}")]
205    Encoding(String),
206    #[error("decoding redaction: {0}")]
207    Decoding(String),
208}
209
210#[cfg(test)]
211mod tests {
212    use chrono::TimeZone;
213
214    use super::*;
215
216    fn principal() -> Principal {
217        Principal {
218            name: "Grace Hopper".into(),
219            email: "grace@example.com".into(),
220        }
221    }
222
223    fn blob_hash() -> ContentHash {
224        ContentHash::from_bytes([7u8; 32])
225    }
226
227    fn redaction(blob: ContentHash, reason: &str) -> Redaction {
228        Redaction {
229            redacted_blob: blob,
230            state: StateId::from_bytes([1u8; 32]),
231            path: "config/secrets.toml".into(),
232            reason: reason.into(),
233            redactor: principal(),
234            redacted_at: Utc.with_ymd_and_hms(2026, 5, 10, 14, 33, 0).unwrap(),
235            signature: None,
236            purged_at: None,
237            supersedes: None,
238        }
239    }
240
241    #[test]
242    fn round_trips_through_msgpack() {
243        let blob = blob_hash();
244        let original = RedactionsBlob::new(vec![redaction(blob, "leaked credential")]);
245        let encoded = original.encode().expect("encode");
246        let decoded = RedactionsBlob::decode(&encoded).expect("decode");
247        assert_eq!(decoded, original);
248        // Format-version is load-bearing: future readers branch on it.
249        assert_eq!(decoded.format_version, RedactionsBlob::FORMAT_VERSION);
250    }
251
252    #[test]
253    fn canonical_payload_stable_across_field_reordering() {
254        // The signing payload concatenates fields in a fixed order. If we
255        // accidentally derive serialization from struct-field declaration
256        // order alone (rmp-serde's default), reordering the struct would
257        // silently invalidate every existing signature. The explicit
258        // `canonical_signing_payload` is the contract; this test pins it.
259        let r = redaction(blob_hash(), "leaked credential");
260        let payload = r.canonical_signing_payload();
261        // Tag prefix at the front; gives us a versioned signing domain.
262        assert!(payload.starts_with(REDACTION_SIGNING_PAYLOAD_VERSION_TAG));
263        // Reason text is in the payload — otherwise an operator could
264        // re-sign a redaction with a different reason.
265        let payload_text = String::from_utf8_lossy(&payload);
266        assert!(payload_text.contains("leaked credential"));
267        assert!(payload_text.contains("config/secrets.toml"));
268        // RFC3339 timestamp string is included — fixed timezone, fixed
269        // precision, so the payload is reproducible across runs.
270        assert!(payload_text.contains("2026-05-10T14:33:00+00:00"));
271    }
272
273    #[test]
274    fn mark_purged_is_idempotent_and_observable() {
275        let mut r = redaction(blob_hash(), "leaked credential");
276        let before = r.canonical_signing_payload();
277        let at = Utc.with_ymd_and_hms(2026, 5, 11, 0, 0, 0).unwrap();
278        assert!(!r.is_purged());
279        assert!(r.mark_purged(at));
280        assert!(r.is_purged());
281        // Second call is a no-op — operators can safely retry purge
282        // without distorting the `purged_at` audit trail.
283        assert!(!r.mark_purged(Utc.with_ymd_and_hms(2026, 5, 12, 0, 0, 0).unwrap()));
284        assert_eq!(r.purged_at, Some(at));
285        assert_ne!(
286            before,
287            r.canonical_signing_payload(),
288            "purge lifecycle must be covered by the signed payload"
289        );
290    }
291
292    #[test]
293    fn stub_text_mentions_redactor_reason_and_purge_state() {
294        let r = redaction(blob_hash(), "leaked credential");
295        let stub = r.stub_text(&blob_hash());
296        // The stub is the ONLY thing readers see for redacted files. It
297        // must carry every field a reviewer would want: who, when, why,
298        // and whether the bytes are still recoverable.
299        assert!(stub.contains("Grace Hopper"));
300        assert!(stub.contains("grace@example.com"));
301        assert!(stub.contains("leaked credential"));
302        assert!(stub.contains("# redacted-at:"));
303        assert!(stub.contains("# redaction:"));
304        // Pre-purge, the stub should explicitly say bytes remain.
305        assert!(stub.contains("remain on disk pending purge"));
306
307        let mut purged = r.clone();
308        purged.mark_purged(Utc.with_ymd_and_hms(2026, 5, 11, 0, 0, 0).unwrap());
309        let purged_stub = purged.stub_text(&blob_hash());
310        assert!(purged_stub.contains("# purged-at:"));
311        assert!(purged_stub.contains("purged from local storage"));
312    }
313
314    #[test]
315    fn latest_picks_the_most_recent() {
316        let early = redaction(blob_hash(), "first pass");
317        let late = Redaction {
318            redacted_at: Utc.with_ymd_and_hms(2026, 5, 12, 9, 0, 0).unwrap(),
319            reason: "tighter scope".into(),
320            ..redaction(blob_hash(), "tighter scope")
321        };
322        let blob = RedactionsBlob::new(vec![early, late.clone()]);
323        assert_eq!(blob.latest().unwrap(), &late);
324    }
325}
326
327#[cfg(test)]
328mod proptests {
329    //! Property tests for the redaction primitive's data model.
330    //!
331    //! These match the build brief's "Property tests" acceptance
332    //! criteria (`.agents/redaction-primitive.md`):
333    //!
334    //!   1. Encode → decode round-trips losslessly for any well-formed
335    //!      redaction.
336    //!   2. `canonical_signing_payload` is deterministic across clones
337    //!      and stable across `Redaction` field reordering — the
338    //!      contract that lets signatures verify.
339    //!   3. `mark_purged` is idempotent: replaying the call with any
340    //!      later timestamp does not move `purged_at`.
341    //!   4. `stub_text` always carries the redaction id, the reason,
342    //!      and the redactor email, no matter what content went in.
343    //!
344    //! Running with the standard proptest budget produces ~256 cases
345    //! per property by default.
346    use proptest::prelude::*;
347
348    use super::*;
349
350    fn arb_principal() -> impl Strategy<Value = Principal> {
351        // Names + emails are ASCII-printable, length-bounded. We're
352        // not testing unicode tolerance here — the redaction store's
353        // contract is "whatever the principal source serves us" and
354        // we want determinism, not exhaustive locale coverage.
355        let name = "[A-Za-z][A-Za-z0-9 _-]{0,30}";
356        let email = "[a-z][a-z0-9_-]{0,15}@[a-z0-9.-]{1,30}\\.[a-z]{2,4}";
357        (name, email).prop_map(|(name, email)| Principal { name, email })
358    }
359
360    fn arb_blob_hash() -> impl Strategy<Value = ContentHash> {
361        any::<[u8; 32]>().prop_map(ContentHash::from_bytes)
362    }
363
364    fn arb_state_id() -> impl Strategy<Value = StateId> {
365        any::<[u8; 32]>().prop_map(StateId::from_bytes)
366    }
367
368    fn arb_redaction() -> impl Strategy<Value = Redaction> {
369        // Timestamp range is bounded to keep RFC3339 formatting stable
370        // (chrono's print is fine, but the test outputs are easier to
371        // diff with a narrow window). Year 2000–2100 is plenty.
372        let secs = 946_684_800i64..4_102_444_800i64;
373        (
374            arb_blob_hash(),
375            arb_state_id(),
376            "[A-Za-z0-9._/-]{1,40}",
377            "[A-Za-z0-9 ._:'-]{0,80}",
378            arb_principal(),
379            secs,
380            prop::option::of(arb_blob_hash()),
381        )
382            .prop_map(|(blob, state, path, reason, redactor, secs, supersedes)| {
383                Redaction {
384                    redacted_blob: blob,
385                    state,
386                    path,
387                    reason,
388                    redactor,
389                    redacted_at: chrono::DateTime::<Utc>::from_timestamp(secs, 0)
390                        .expect("in-range timestamp"),
391                    signature: None,
392                    purged_at: None,
393                    supersedes,
394                }
395            })
396    }
397
398    proptest! {
399        /// Encode → decode round-trips. If this breaks, the on-disk
400        /// redaction store can't be read back; the leaked-secret stays
401        /// secret only by accident.
402        #[test]
403        fn encode_decode_roundtrip(r in arb_redaction()) {
404            let blob = RedactionsBlob::new(vec![r.clone()]);
405            let bytes = blob.encode().expect("encode");
406            let decoded = RedactionsBlob::decode(&bytes).expect("decode");
407            prop_assert_eq!(decoded.redactions.len(), 1);
408            prop_assert_eq!(&decoded.redactions[0], &r);
409        }
410
411        /// Canonical signing payload is a pure function of the
412        /// redaction's *content*: cloning the value or rebuilding it
413        /// from the same fields must give bit-identical bytes. This is
414        /// what makes a signature stable across read cycles.
415        #[test]
416        fn canonical_payload_is_deterministic(r in arb_redaction()) {
417            let payload1 = r.canonical_signing_payload();
418            let payload2 = r.clone().canonical_signing_payload();
419            prop_assert_eq!(payload1, payload2);
420        }
421
422        /// `purged_at` is monotonic. Once a redaction is purged, a
423        /// later `mark_purged` call with any timestamp must NOT move
424        /// the field — operators can re-run the purge command (or
425        /// retries can ride a partial failure) without distorting the
426        /// audit trail.
427        #[test]
428        fn mark_purged_is_idempotent(
429            mut r in arb_redaction(),
430            t1_secs in 946_684_800i64..4_000_000_000i64,
431            t2_offset in 0i64..1_000_000_000i64,
432        ) {
433            let t1 = chrono::DateTime::<Utc>::from_timestamp(t1_secs, 0).unwrap();
434            let t2 = chrono::DateTime::<Utc>::from_timestamp(t1_secs + t2_offset, 0).unwrap();
435            prop_assert!(r.mark_purged(t1));
436            prop_assert!(r.is_purged());
437            prop_assert_eq!(r.purged_at, Some(t1));
438            // Second purge with a later timestamp is a no-op.
439            prop_assert!(!r.mark_purged(t2));
440            prop_assert_eq!(r.purged_at, Some(t1));
441        }
442
443        /// The stub a reader sees must always identify the redaction.
444        /// If the stub failed to carry the id or the reason, downstream
445        /// auditors would have no way to trace why a file disappeared.
446        #[test]
447        fn stub_always_carries_id_and_reason(r in arb_redaction()) {
448            let id = ContentHash::from_bytes([0xAB; 32]);
449            let stub = r.stub_text(&id);
450            // The short id is what `heddle redact show` displays;
451            // the stub must echo it for back-reference.
452            prop_assert!(
453                stub.contains(&id.short()),
454                "stub must contain redaction id; got: {stub}"
455            );
456            // Empty reasons are allowed (defensive) but if any reason
457            // text is supplied it must surface in the stub.
458            if !r.reason.is_empty() {
459                prop_assert!(
460                    stub.contains(&r.reason),
461                    "stub must carry reason '{}'; got: {stub}",
462                    r.reason
463                );
464            }
465            // The redactor's email is the durable identifier — the
466            // name might be a display label, but the email survives
467            // rename and is what auditors trace back to.
468            prop_assert!(
469                stub.contains(&r.redactor.email),
470                "stub must carry redactor email '{}'; got: {stub}",
471                r.redactor.email
472            );
473        }
474
475        /// Empty `RedactionsBlob` is consistent: `has_active` returns
476        /// `false`, and `latest` returns `None`. The materialize path
477        /// uses these to decide whether to render a stub — if either
478        /// regressed, redacted files would silently materialize their
479        /// real bytes.
480        #[test]
481        fn empty_blob_is_inert(seed in any::<u8>()) {
482            let _ = seed; // unused; exists to exercise the proptest harness
483            let blob = RedactionsBlob::empty();
484            prop_assert!(!blob.has_active());
485            prop_assert!(blob.latest().is_none());
486        }
487
488        /// Adding redactions makes the blob active. Pin: a single
489        /// non-purged redaction is sufficient — readers must see the
490        /// stub from the moment the first declaration lands.
491        #[test]
492        fn single_redaction_makes_blob_active(r in arb_redaction()) {
493            let blob = RedactionsBlob::new(vec![r]);
494            prop_assert!(blob.has_active());
495            prop_assert!(blob.latest().is_some());
496        }
497    }
498}