From 0b76ae8eae952f4b8ae79d2ea9ce043b71a3fed8 Mon Sep 17 00:00:00 2001 From: David Charlot Date: Sat, 29 Aug 2026 20:52:05 -0400 Subject: [PATCH] jcp: hand the caller its receipt, in MCP's namespaced _meta slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway authorized every `tools/call`, metered it on real hardware, debited the grant and sealed allow and deny alike into a JCR-1 chain — and the client never saw any of it. The receipt lived in `gateway.receipts()`: the enforcing party keeping an artifact about itself, which the subject of the decision could not present, dispute, or forward. That is most of what a receipt is for. MCP's 2026-07-28 revision supplies the place to put it. `_meta` is now a first-class namespaced extension point with a 12-month deprecation policy behind it, so a policy object carried there has a defined home rather than an invented one, and the home stops moving. - An allowed call returns its sealed receipt in `result._meta` under `science.transaction/jcp`, beside the upstream's own untouched payload, with the measured joules and the remaining budget. - A denial returns its sealed refusal too, in `error.data._meta` — JSON-RPC reserves `data` for exactly this. A refusal the caller cannot see is an enforcement nobody can audit, and a receipt's value does not depend on the verdict going the caller's way. - The witness moves from the hand-rolled flat `params._jcp_witness` into the namespaced slot, and the flat key keeps working. A silent behaviour change at an enforcement point is worse than a legacy key, so the fallback stays until something forces it out. The returned bytes are the same sealed envelope the gateway logged, not a summary of it — asserted directly rather than described. Attaching is defensive: a response that is not a JSON-RPC result object comes back untouched, because this must never mangle an upstream payload it does not understand. Base64 is hand-rolled (no new dependency for one encode) and pinned against the RFC 4648 §10 vectors, since a receipt that does not decode is not a receipt. Found by the 2026-08-29 sweep, whose repo-check phase also refuted two things I had queued as gaps — the IETF agent-audit-trail draft and a hardware-rooted attestation paper are both already shipped, the draft being a strictly weaker subset with an optional signature and no energy field. Not in this change, and worth its own: MCP's new `CacheableResult`/`ttlMs` makes a cache hit into joules avoided, and nothing in the family binds a TTL'd result to an energy receipt. Verified: 245 jcp-rs tests pass (5 new), conformance passes all classes, published vectors unchanged on disk, no new clippy warnings. --- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 196 ++++++++++++++++++++++- 1 file changed, 193 insertions(+), 3 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index da9073ca4..36568a1fe 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -274,6 +274,14 @@ impl Gateway { self.log.head(), ); + // The receipt for THIS call is the one just appended, so the caller + // receives evidence for its own request rather than having to trust + // that the gateway logged something somewhere. + let response = match self.log.entries().last() { + Some(r) => attach_receipt(&response, r, measured_uj, decision.budget_remaining_uj), + None => response, + }; + Ok(Relayed::Allowed { response, measured_uj, @@ -295,13 +303,106 @@ impl Gateway { let d = self.runtime.authorize(&frame, &anchor, self.caller, self.now); self.log.append(&frame, &grant, &d); } + // A denial the caller cannot see is an enforcement nobody can audit, so + // the sealed refusal rides back in `error.data._meta` — the same + // namespace as an allow, because a receipt's value does not depend on + // the verdict going the caller's way. + let response = match self.log.entries().last() { + Some(r) => attach_denial_receipt(id, -32601, &format!("JCP denied: {reason} `{tool}`"), r), + None => jsonrpc_error(id, -32601, &format!("JCP denied: {reason} `{tool}`")), + }; Relayed::Denied { - response: jsonrpc_error(id, -32601, &format!("JCP denied: {reason} `{tool}`")), + response, reason, } } } +/// The `_meta` namespace this gateway reads and writes. +/// +/// MCP's 2026-07-28 revision made `_meta` a first-class, namespaced extension +/// point and put a 12-month deprecation policy behind it, so a policy object +/// carried here has a defined place to live rather than an invented one. The +/// prefix is reverse-domain, as the spec requires for third-party keys. +pub const JCP_META_NS: &str = "science.transaction/jcp"; + +/// Read this gateway's side-data out of a request's `params`. +/// +/// Prefers the namespaced `_meta` slot and falls back to the flat +/// `params._jcp_witness` this gateway used before MCP had one. The fallback is +/// deliberate: a client that has not been updated must keep working, and a +/// silent behaviour change at an enforcement point is worse than a legacy key. +fn jcp_meta(params: &Value) -> Option<&Value> { + params.get("_meta").and_then(|m| m.get(JCP_META_NS)) +} + +/// Attach the sealed receipt for this call to a JSON-RPC response's +/// `result._meta`, so the caller receives the artifact for its own request. +/// +/// Until now the receipt existed only in the gateway's own log: the client got +/// its tool result and no evidence, which makes the receipt an artifact the +/// enforcing party keeps about itself. A receipt the subject never sees cannot +/// be presented, disputed, or forwarded, and that is most of what a receipt is +/// for. Returns the response unchanged if it is not a JSON-RPC result object — +/// this must never mangle an upstream payload it does not understand. +fn attach_receipt(response: &str, receipt: &[u8], measured_uj: u64, remaining_uj: u64) -> String { + let Ok(mut v) = serde_json::from_str::(response) else { + return response.to_string(); + }; + let Some(result) = v.get_mut("result").and_then(Value::as_object_mut) else { + return response.to_string(); + }; + let meta = result + .entry("_meta") + .or_insert_with(|| json!({})) + .as_object_mut(); + let Some(meta) = meta else { return response.to_string() }; + meta.insert( + JCP_META_NS.to_string(), + json!({ + "receipt_b64": b64(receipt), + "measured_uj": measured_uj, + "budget_remaining_uj": remaining_uj, + }), + ); + v.to_string() +} + +/// A JSON-RPC error carrying the sealed refusal in `error.data._meta`. +/// +/// JSON-RPC reserves `error.data` for implementation-defined detail, which is +/// where a refusal's evidence belongs: the caller is told not merely that it was +/// denied but handed the signed record of the denial, which it can present to a +/// third party without the gateway's cooperation. +fn attach_denial_receipt(id: Option<&Value>, code: i64, message: &str, receipt: &[u8]) -> String { + json!({ + "jsonrpc": "2.0", + "id": id.cloned().unwrap_or(Value::Null), + "error": { + "code": code, + "message": message, + "data": { "_meta": { JCP_META_NS: { "receipt_b64": b64(receipt) } } } + } + }) + .to_string() +} + +/// Standard base64 (RFC 4648 §4, padded) — the receipt is a COSE_Sign1 envelope +/// and JSON has no byte type. +fn b64(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for c in bytes.chunks(3) { + let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + out.push(T[(n >> 18 & 63) as usize] as char); + out.push(T[(n >> 12 & 63) as usize] as char); + out.push(if c.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' }); + out.push(if c.len() > 2 { T[(n & 63) as usize] as char } else { '=' }); + } + out +} + /// The digest of a grant's flow ceiling, for the receipt seal. `None` when the /// grant carries no flow caveat. fn ceiling_digest(grant: &Grant) -> Option { @@ -312,8 +413,9 @@ fn ceiling_digest(grant: &Grant) -> Option { /// of hex content ids), if present — an explicit "this argument came from these /// values" the harness can assert beyond what the scan finds. fn declared_witness_ids(params: &Value) -> BTreeSet { - params - .get("_jcp_witness") + jcp_meta(params) + .and_then(|m| m.get("witness")) + .or_else(|| params.get("_jcp_witness")) .and_then(Value::as_array) .map(|arr| { arr.iter() @@ -383,6 +485,94 @@ mod tests { Gateway::new("files", &[("read_file", 50_000_000), ("search", 50_000_000)], SigningKey::from_bytes(&[7u8; 32])) } + /// The caller receives the receipt for its OWN call. Until now the receipt + /// existed only in the gateway's log — the enforcing party keeping an + /// artifact about itself, which the subject could never present. + #[test] + fn the_caller_gets_its_receipt_back() { + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + let Relayed::Allowed { response, measured_uj, budget_remaining_uj } = + g.relay(&call("read_file", 1), &mut up).unwrap() + else { + panic!("expected Allowed") + }; + + let v: Value = serde_json::from_str(&response).unwrap(); + let meta = v.pointer(&format!("/result/_meta/{}", JCP_META_NS.replace('/', "~1"))) + .expect("receipt must ride in result._meta under the namespaced key"); + assert_eq!(meta["measured_uj"].as_u64(), Some(measured_uj)); + assert_eq!(meta["budget_remaining_uj"].as_u64(), Some(budget_remaining_uj)); + + // The returned receipt is the same sealed bytes the gateway logged — + // not a summary of them. + let b64_returned = meta["receipt_b64"].as_str().expect("receipt_b64"); + assert_eq!(b64_returned, b64(g.receipts().last().unwrap())); + + // And the upstream's own payload survived untouched beside it. + assert_eq!(v["result"]["ok"].as_bool(), Some(true)); + } + + /// A refusal the caller cannot see is an enforcement nobody can audit, so a + /// denial carries its sealed record too. + #[test] + fn a_denial_hands_back_its_sealed_refusal() { + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + let Relayed::Denied { response, .. } = g.relay(&call("shell", 1), &mut up).unwrap() else { + panic!("expected Denied") + }; + let v: Value = serde_json::from_str(&response).unwrap(); + let ptr = format!("/error/data/_meta/{}", JCP_META_NS.replace('/', "~1")); + let meta = v.pointer(&ptr).expect("a denial must carry its receipt"); + assert_eq!(meta["receipt_b64"].as_str(), Some(b64(g.receipts().last().unwrap()).as_str())); + assert_eq!(up.calls, 0, "upstream still never contacted"); + } + + /// The witness moves to MCP's namespaced `_meta`, and the flat key a client + /// may still be sending keeps working — a silent behaviour change at an + /// enforcement point is worse than a legacy key. + #[test] + fn witness_reads_the_meta_slot_and_still_honours_the_legacy_key() { + let id = ContentId::of_bytes(b"w"); + let hex = id.to_hex(); + + let modern = json!({"name":"t","arguments":{}, + "_meta": { JCP_META_NS: { "witness": [hex] } }}); + assert!(declared_witness_ids(&modern).contains(&id), "namespaced _meta slot"); + + let legacy = json!({"name":"t","arguments":{}, "_jcp_witness": [hex]}); + assert!(declared_witness_ids(&legacy).contains(&id), "legacy flat key still honoured"); + + // `_meta` present but carrying someone else's namespace must not be read + // as ours, and must not resurrect the legacy key by accident. + let foreign = json!({"name":"t","arguments":{}, "_meta": { "other/ns": { "witness": [hex] } }}); + assert!(declared_witness_ids(&foreign).is_empty(), "another vendor's key is not ours"); + } + + /// Attaching must never mangle a payload it does not understand. + #[test] + fn a_response_the_gateway_cannot_parse_is_returned_untouched() { + let junk = "not json at all"; + assert_eq!(attach_receipt(junk, b"r", 1, 2), junk); + // Valid JSON, but no `result` object to extend. + let err = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"x"}}"#; + assert_eq!(attach_receipt(err, b"r", 1, 2), err); + } + + /// Base64 is RFC 4648 with padding — pinned against known vectors, because a + /// receipt that does not decode is not a receipt. + #[test] + fn base64_matches_rfc4648() { + assert_eq!(b64(b""), ""); + assert_eq!(b64(b"f"), "Zg=="); + assert_eq!(b64(b"fo"), "Zm8="); + assert_eq!(b64(b"foo"), "Zm9v"); + assert_eq!(b64(b"foob"), "Zm9vYg=="); + assert_eq!(b64(b"fooba"), "Zm9vYmE="); + assert_eq!(b64(b"foobar"), "Zm9vYmFy"); + } + #[test] fn granted_tool_is_allowed_metered_and_debited() { let mut g = gw(); -- 2.54.0 (Apple Git-157)