jcp: an erasure path that neither breaks the chain nor loses the joules #74
1 changed files with 189 additions and 0 deletions
|
|
@ -516,6 +516,52 @@ pub fn replay_spend<'a>(
|
||||||
/// point. This is the integrity layer OpenTelemetry lacks and the property the
|
/// 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
|
/// 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.
|
/// 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 {
|
pub struct ReceiptLog {
|
||||||
steward_sk: SigningKey,
|
steward_sk: SigningKey,
|
||||||
steward: String,
|
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<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
|
/// 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
|
/// envelope bytes. Both allows and denies are chained — excising a deny
|
||||||
/// breaks the chain exactly as excising an allow would.
|
/// 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`
|
/// Project a decision to logged JCR-1 wire JSON: [`to_jcr1_wire`] plus `seq`
|
||||||
/// and `prev` (the previous entry's envelope content id).
|
/// 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(
|
pub fn to_jcr1_wire_logged(
|
||||||
frame: &Frame,
|
frame: &Frame,
|
||||||
grant: &Grant,
|
grant: &Grant,
|
||||||
|
|
@ -718,6 +806,107 @@ mod tests {
|
||||||
/// preimage — or the bump silently invalidated every receipt in the wild.
|
/// 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]
|
#[test]
|
||||||
fn published_v1_receipts_still_verify() {
|
fn published_v1_receipts_still_verify() {
|
||||||
const V1_ALLOW: &str = "d284581fa201270458196469643a6578616d706c653a6a63702d7265666572656e6365a05901d2ae65747275737467636f6e74726f6c66616374696f6e64726561646669737375657278386469643a6b65793a7a364d6b6f6e334e656364364e6b6b79666f476f48786964327a6e476335394c55334b376d756261526346624c664c5867636f73745f756a181e677374657761726478196469643a6578616d706c653a6a63702d7265666572656e6365677375626a65637478386469643a6b65793a7a364d6b6f39685467674d776a535445614a615055664536747163793278765536426e4e713365336f38715642697948677665726469637465616c6c6f77686772616e745f6964784033623161356139636262373831353134636637353162613030376530373765383038333937376234663037623130346433366565393034643932383939386334687265736f75726365784030663766623535636565393361623937373738323936303865383531666133316536386531326462396466633061383032366164323938333134636633663932696275646765745f756a18646a6672616d655f6b696e6469746f6f6c2d63616c6c6b6a63705f72656365697074656a63702f3171656e657267795f70726f76656e616e63656748775368756e74736275646765745f72656d61696e696e675f756a184658405a7be56b33956c069d4e23127baa7b090e5028b9704dff6ca5ac46da546534e667151f80d7d7a605bcc31ea2bb33ec971e3e8b8794f7248a18b34c33bc438509";
|
const V1_ALLOW: &str = "d284581fa201270458196469643a6578616d706c653a6a63702d7265666572656e6365a05901d2ae65747275737467636f6e74726f6c66616374696f6e64726561646669737375657278386469643a6b65793a7a364d6b6f6e334e656364364e6b6b79666f476f48786964327a6e476335394c55334b376d756261526346624c664c5867636f73745f756a181e677374657761726478196469643a6578616d706c653a6a63702d7265666572656e6365677375626a65637478386469643a6b65793a7a364d6b6f39685467674d776a535445614a615055664536747163793278765536426e4e713365336f38715642697948677665726469637465616c6c6f77686772616e745f6964784033623161356139636262373831353134636637353162613030376530373765383038333937376234663037623130346433366565393034643932383939386334687265736f75726365784030663766623535636565393361623937373738323936303865383531666133316536386531326462396466633061383032366164323938333134636633663932696275646765745f756a18646a6672616d655f6b696e6469746f6f6c2d63616c6c6b6a63705f72656365697074656a63702f3171656e657267795f70726f76656e616e63656748775368756e74736275646765745f72656d61696e696e675f756a184658405a7be56b33956c069d4e23127baa7b090e5028b9704dff6ca5ac46da546534e667151f80d7d7a605bcc31ea2bb33ec971e3e8b8794f7248a18b34c33bc438509";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue