jcp: hand the caller its receipt, in MCP's namespaced _meta slot #69

Merged
dcharlot merged 1 commit from jcp/mcp-meta-receipt into main 2026-09-03 15:12:27 -04:00

View file

@ -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::<Value>(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<ContentId> {
@ -312,8 +413,9 @@ fn ceiling_digest(grant: &Grant) -> Option<ContentId> {
/// 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<ContentId> {
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();