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