From 8a1232b7591a034edbc6d2256d0750a5e4de0a65 Mon Sep 17 00:00:00 2001 From: David Charlot Date: Tue, 1 Sep 2026 05:58:53 -0400 Subject: [PATCH] jcp: an erasure path that neither breaks the chain nor loses the joules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No receipt chain in open-standards had a redaction primitive. Chains are append-only by construction, which is exactly right for evidence and exactly wrong for a deployment that has to honour a data-subject erasure request — so the absence was a hard blocker, not a missing convenience. The chain links on envelope bytes: each entry's `prev` is the content id of the one before it. An entry therefore cannot be edited or removed to satisfy a request, because either breaks every link after it, and a chain that no longer verifies is not evidence of anything — including of the erasure. Erasure has to be forward-only. `ReceiptLog::erase` appends a `jcp.tombstone/1` entry. The erased envelope stops being retained by the operator; its content id is unchanged, so the links still resolve; and the tombstone records that the erasure happened, where in the order, and under what authority. What is lost is the ability to read what was done. What survives is that something was done and what it cost. **The joules stay, deliberately.** Every energy total, efficiency figure and facility rollup over this log is a sum across its entries. An erasure that dropped the cost would silently shrink all of them, and the shrinkage would be indistinguishable from a genuine efficiency gain — a conservation break arriving through the back door of a privacy feature, of exactly the kind the sibling standards refuse elsewhere. The acquisition class rides along for the same reason: a figure without one is not comparable to the others in the same sum. Joules and a provenance label are not personal data; the payload hash, resource, subject and action are, and a test asserts none of them appear. The authority field is a request reference or policy id, never the requesting party's identity — a tombstone that named the subject would re-introduce the datum the erasure removed. A tombstone carries its own profile so no reader mistakes it for a decision that happened to have no payload, and the test compares the sealed payload byte-for-byte against the expected chained wire rather than re-parsing it, pinning the content as well as the signature. Surfaced by the 2026-08-29 sweep, inside its refutation of the IETF agent audit-trail draft: the draft proposes a tombstone with a content fingerprint, and cannot retain the cost because it has no energy field at all. Verified: 264 jcp-rs tests pass (4 new), conformance passes all classes, no clippy warnings in jcp-receipt. --- jcp/jcp-rs/crates/jcp-receipt/src/lib.rs | 189 +++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs index 12b2efc34..c5ee66e62 100644 --- a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs @@ -516,6 +516,52 @@ pub fn replay_spend<'a>( /// point. This is the integrity layer OpenTelemetry lacks and the property the /// EU AI Act's logging obligations (Aug 2026) and the "notarized agents" line /// of research both reach for — built from the family's own JCR-1 envelope. +/// A record that an earlier entry's content was erased. +/// +/// # Why a hash chain needs this shape +/// +/// The chain links on envelope bytes: each entry's `prev` is the content id of +/// the one before it. So an entry cannot be edited or removed to satisfy an +/// erasure request — doing either breaks every link after it, and a chain that +/// no longer verifies is not evidence of anything, including of the erasure. +/// +/// Erasure is therefore **forward-only**. The erased envelope stops being +/// retained; its content id does not change, so the links still resolve; and a +/// tombstone is *appended* declaring what happened. What is lost is the ability +/// to read what was done. What survives is that something was done, where in +/// the order, and what it cost. +/// +/// # Why the energy stays +/// +/// The tombstone retains `cost_uj` and its acquisition class deliberately. +/// Every energy total, efficiency figure and facility rollup computed over this +/// log is a sum across its entries; an erasure that dropped the joules would +/// silently shrink all of them, and the shrinkage would be indistinguishable +/// from a genuine efficiency gain. That is a conservation break of exactly the +/// kind [`crate`]'s sibling standards refuse elsewhere, arriving through the +/// back door of a privacy feature. +/// +/// Joules and an acquisition label are not personal data. The payload hash, the +/// resource, the subject and the action are — and none of them are here. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tombstone { + /// Content id of the erased entry. Unchanged by erasure, which is what + /// keeps the chain intact. + pub erased: jcp_schema::ContentId, + /// When the erasure was performed (microseconds since the epoch). + pub erased_at_us: u64, + /// The authority the erasure was carried out under — a request reference or + /// a policy id, never the requesting party's identity. A tombstone that + /// named the subject would re-introduce the datum the erasure removed. + pub authority: String, + /// The erased entry's energy, retained so rollups stay conserved. + pub cost_uj: u64, + /// Its acquisition class, retained for the same reason: a figure without + /// one is not comparable, and a rollup mixing labelled and unlabelled + /// figures is not either. + pub energy_provenance: String, +} + pub struct ReceiptLog { steward_sk: SigningKey, steward: String, @@ -542,6 +588,24 @@ impl ReceiptLog { } } + /// Append a tombstone recording that an earlier entry's content was erased. + /// + /// This does **not** alter the erased entry: it cannot, because the chain + /// links on envelope bytes. The caller stops retaining that envelope's body; + /// this records the fact, forward, as a new link. The chain continues to + /// verify precisely because nothing behind it moved. + /// + /// Returns the tombstone's envelope bytes. + pub fn erase(&mut self, t: &Tombstone) -> Vec { + let wire = tombstone_wire(t, &self.steward, self.seq, &self.head); + let core = jcr1::signed_core_bytes(&wire).expect("canonical core"); + let env = jcr1::cose_sign1_envelope(&core, &self.steward_sk, &self.steward); + self.head = jcp_schema::ContentId::of_bytes(&env); + self.seq += 1; + self.entries.push(env.clone()); + env + } + /// Seal a decision as the next link in the chain, returning the new entry's /// envelope bytes. Both allows and denies are chained — excising a deny /// breaks the chain exactly as excising an allow would. @@ -595,6 +659,30 @@ impl ReceiptLog { /// Project a decision to logged JCR-1 wire JSON: [`to_jcr1_wire`] plus `seq` /// and `prev` (the previous entry's envelope content id). +/// The JCR-1 wire for a tombstone: its own profile, so a reader can never +/// mistake it for a decision that happened to have no payload. +pub fn tombstone_wire( + t: &Tombstone, + steward: &str, + seq: u64, + prev: &jcp_schema::ContentId, +) -> Value { + json!({ + "jcp_receipt": TOMBSTONE_PROFILE, + "erased": t.erased.to_hex(), + "erased_at_us": t.erased_at_us, + "authority": t.authority, + "cost_uj": t.cost_uj, + "energy_provenance": t.energy_provenance, + "steward": steward, + "seq": seq, + "prev": prev.to_hex(), + }) +} + +/// The profile tag a tombstone carries. +pub const TOMBSTONE_PROFILE: &str = "jcp.tombstone/1"; + pub fn to_jcr1_wire_logged( frame: &Frame, grant: &Grant, @@ -718,6 +806,107 @@ mod tests { /// preimage — or the bump silently invalidated every receipt in the wild. + fn tomb(erased: jcp_schema::ContentId, uj: u64) -> Tombstone { + Tombstone { + erased, + erased_at_us: 1_700_000_000_000_000, + authority: "dsr-2026-0042".into(), + cost_uj: uj, + energy_provenance: "HwShunt".into(), + } + } + + /// The invariant a hash chain makes non-negotiable: erasure must not break + /// the links. Nothing behind the tombstone is touched, so every prior + /// entry's bytes — and therefore every `prev` — are exactly as they were. + #[test] + fn erasure_appends_and_the_chain_survives_it() { + let (frame, grant, d) = scenario(); + let mut log = ReceiptLog::new(sk(3), "did:example:steward"); + + let first = log.append(&frame, &grant, &d); + let before = jcp_schema::ContentId::of_bytes(&first); + let n_before = log.entries().len(); + + let t = log.erase(&tomb(before, d.cost.uj())); + + assert_eq!(log.entries().len(), n_before + 1, "erasure APPENDS, never removes"); + assert_eq!(log.entries()[0], first, "the erased entry's bytes are untouched"); + assert_ne!(t, first); + + // The tombstone verifies under the steward key, and its sealed payload + // is exactly the wire naming the erased entry as `prev` at `seq` 1 — + // compared byte-for-byte rather than re-parsed, which pins the content + // as well as the signature. + let payload = jcr1::verify_cose_sign1( + &t, + "did:example:steward", + &sk(3).verifying_key().to_bytes(), + ) + .expect("tombstone verifies under the steward key"); + let expected = jcr1::signed_core_bytes(&tombstone_wire( + &tomb(before, d.cost.uj()), + "did:example:steward", + 1, + &before, + )) + .expect("canonical core"); + assert_eq!(payload, expected, "the tombstone seals exactly the chained wire"); + } + + /// The TX-specific half: an erased record still counts toward energy + /// totals. Dropping the joules would silently shrink every rollup over the + /// log, and the shrinkage would be indistinguishable from an efficiency + /// gain — a conservation break arriving through a privacy feature. + #[test] + fn erasure_conserves_the_energy_total() { + let (frame, grant, d) = scenario(); + let cost = d.cost.uj(); + let mut log = ReceiptLog::new(sk(3), "did:example:steward"); + let first = log.append(&frame, &grant, &d); + let id = jcp_schema::ContentId::of_bytes(&first); + + let t = tomb(id, cost); + log.erase(&t); + + // The tombstone carries the cost forward, and its acquisition class + // with it — a figure without one is not comparable to the others in + // the same sum. + assert_eq!(t.cost_uj, cost, "the joules survive the erasure"); + assert_eq!(t.energy_provenance, "HwShunt"); + } + + /// A tombstone must not re-introduce what the erasure removed. It carries + /// no payload hash, no resource, no subject, and no action. + #[test] + fn a_tombstone_carries_nothing_personal() { + let (frame, grant, d) = scenario(); + let mut log = ReceiptLog::new(sk(3), "did:example:steward"); + let first = log.append(&frame, &grant, &d); + let id = jcp_schema::ContentId::of_bytes(&first); + let wire = tombstone_wire(&tomb(id, 42), "s", 1, &id); + + for forbidden in ["payload_hash", "resource", "subject", "action", "issuer"] { + assert!( + wire.get(forbidden).is_none(), + "a tombstone must not carry `{forbidden}` — that is the datum erasure removed" + ); + } + // The authority is a request reference, not the requesting party. + assert_eq!(wire["authority"].as_str(), Some("dsr-2026-0042")); + } + + /// A tombstone declares its own profile, so no reader mistakes it for a + /// decision that happened to carry no payload. + #[test] + fn a_tombstone_is_not_a_decision() { + let id = jcp_schema::ContentId::of_bytes(b"x"); + let wire = tombstone_wire(&tomb(id, 1), "s", 0, &id); + assert_eq!(wire["jcp_receipt"].as_str(), Some(TOMBSTONE_PROFILE)); + assert_ne!(wire["jcp_receipt"].as_str(), Some(PROFILE)); + assert!(wire.get("verdict").is_none(), "a tombstone has no verdict"); + } + #[test] fn published_v1_receipts_still_verify() { const V1_ALLOW: &str = "d284581fa201270458196469643a6578616d706c653a6a63702d7265666572656e6365a05901d2ae65747275737467636f6e74726f6c66616374696f6e64726561646669737375657278386469643a6b65793a7a364d6b6f6e334e656364364e6b6b79666f476f48786964327a6e476335394c55334b376d756261526346624c664c5867636f73745f756a181e677374657761726478196469643a6578616d706c653a6a63702d7265666572656e6365677375626a65637478386469643a6b65793a7a364d6b6f39685467674d776a535445614a615055664536747163793278765536426e4e713365336f38715642697948677665726469637465616c6c6f77686772616e745f6964784033623161356139636262373831353134636637353162613030376530373765383038333937376234663037623130346433366565393034643932383939386334687265736f75726365784030663766623535636565393361623937373738323936303865383531666133316536386531326462396466633061383032366164323938333134636633663932696275646765745f756a18646a6672616d655f6b696e6469746f6f6c2d63616c6c6b6a63705f72656365697074656a63702f3171656e657267795f70726f76656e616e63656748775368756e74736275646765745f72656d61696e696e675f756a184658405a7be56b33956c069d4e23127baa7b090e5028b9704dff6ca5ac46da546534e667151f80d7d7a605bcc31ea2bb33ec971e3e8b8794f7248a18b34c33bc438509"; -- 2.54.0 (Apple Git-157)