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"); + } + }