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}