jcp: an erasure path that neither breaks the chain nor loses the joules (re-land) #77

Merged
dcharlot merged 1 commit from jcp/erasure-tombstone-v2 into main 2026-09-03 15:17:04 -04:00

View file

@ -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<u8> {
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");
}
}