From dc84399ffd9ebff36e01544b928d4c81733d8558 Mon Sep 17 00:00:00 2001 From: David Charlot Date: Thu, 3 Sep 2026 15:15:51 -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 — right for evidence, wrong for a deployment that has to honour a data-subject erasure request, so the absence was a hard blocker rather than 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; 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. 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 chain test compares the sealed payload byte-for-byte against the expected chained wire rather than re-parsing it. Re-applied on current `main` after the environment-binding change landed; the original branch conflicted at a shared test-insertion point, and rebuilding was cleaner than resolving text that did not split at function boundaries. Surfaced by the 2026-08-29 sweep, inside its refutation of the IETF agent audit-trail draft: that draft proposes a tombstone with a content fingerprint and cannot retain the cost, having no energy field at all. Verified: full jcp-rs workspace green, conformance passes all classes. Co-Authored-By: Claude Opus 5 --- jcp/jcp-rs/crates/jcp-receipt/src/lib.rs | 164 +++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs index 8d3256cb7..2f3892c5e 100644 --- a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs @@ -599,6 +599,73 @@ 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 — 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 +/// +/// Every energy total, efficiency figure and facility rollup 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 — a conservation break 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 to the others in the same sum. + pub energy_provenance: String, +} + +/// The profile tag a tombstone carries. +pub const TOMBSTONE_PROFILE: &str = "jcp.tombstone/1"; + +/// 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(), + }) +} + pub struct ReceiptLog { steward_sk: SigningKey, steward: String, @@ -625,6 +692,22 @@ 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. + 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. @@ -1094,4 +1177,85 @@ mod tests { assert_eq!(core.verdict, "allow"); assert_eq!(core.cost_uj, 30, "cost_uj is the tool energy, untouched by flow_uj"); } + 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"); + + // The sealed payload is exactly the chained wire — compared byte-for-byte + // rather than re-parsed, pinning 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); + } + + /// An erased record still counts toward energy totals. Dropping the joules + /// would silently shrink every rollup, indistinguishably 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 t = tomb(jcp_schema::ContentId::of_bytes(&first), cost); + log.erase(&t); + 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. + #[test] + fn a_tombstone_carries_nothing_personal() { + let id = jcp_schema::ContentId::of_bytes(b"entry"); + 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" + ); + } + 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"); + } + } -- 2.54.0 (Apple Git-157)