All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 6s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 44s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m22s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m41s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m23s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m11s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 3m7s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 3m54s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m16s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 7m37s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m42s
A mesh receipt bound what was asked (request_id) and what it cost, and
nothing about what came back, so no one could later show which answer a
receipt was for. AR-1 had the same gap beside input_hash.
mesh/1 (SPEC §6.4):
- output_commitment: a salted BLAKE3 commitment over the request id, the
digest of the prompt payload the engine was handed, and the output tokens.
It is a commitment, not a hash, because a short output is reversible from
its hash. The opening is derived per request from a key drawn from the
steward's signing key, so a restarted steward can still disclose it.
The field is absent for a typed decision, whose answer the engine
interface does not carry, and omitted when absent, so older receipts keep
their bytes.
- The payload is bound because prompt_hash addresses the prompt's bytes
while an engine is handed token ids, so request_id alone cannot confirm
what a replay runs on.
- ServingEngine::deterministic (default false; the reference engine true).
- mesh_core::replay re-executes a sealed serve. Its verdicts:
- Verified: the re-run opens the commitment.
- Diverged { first_difference }: only when a disclosed output opens the
commitment on the same payload and opening, so the mismatch is the
serve's and not the replayer's input.
- NotReplayable(why): the engine samples; another backend, recipe or
weights; another request; no commitment; or a mismatch nothing
attributes.
- The conformance pack is regenerated (the reference receipt gains the
field; the envelope grows from 663 to 747 bytes).
AR-1: output_hash, an optional field after input_hash, sealed only when
bound (§2.3). It recommends a commitment over a plain hash and names
mesh's output_commitment as one. The published AR-1 vectors regenerate
byte-identical.
Also: ar-verify's `len() % 2 == 0` becomes `is_multiple_of(2)`, a clippy
error that predates this change, so the crate is clippy-clean.
Tests:
- mesh: the commitment binds request, opening, payload and tokens; a
generation commits and a decision does not; deterministic replay
verifies and locates a drift; replay refuses what it cannot settle,
including a wrong payload with the honest output disclosed; a restarted
steward reopens.
- AR-1: output_hash sealed when bound and absent otherwise.
- Mutation testing: 20 mutants, all killed (13, then 7 more after
binding the payload; one of the 13 targets code since replaced).
Verified on the build host:
- mesh-rs: 94 tests plus 19 behind features; clippy and rustdoc clean.
- jcp-rs: 565 tests and the conformance binary; jcp-action-receipt
clippy-clean.
- Repo checks pass.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
265 lines
10 KiB
Rust
265 lines
10 KiB
Rust
//! End-to-end Action Receipt on real hardware.
|
|
//!
|
|
//! ```text
|
|
//! cargo run -p jcp-action-receipt --example end_to_end -- /tmp/action.hex
|
|
//! ```
|
|
//!
|
|
//! Implements §5 step 6 of the blueprint: **one agent action, under a real
|
|
//! delegated grant, with energy read off this host's own counters, sealed into
|
|
//! an AR-1 receipt that a third party verifies from the published spec alone.**
|
|
//!
|
|
//! Only the workload is a stand-in, so the example runs anywhere. Everything
|
|
//! around it is the real machinery:
|
|
//!
|
|
//! - the grant is **really issued** and **really attenuated**, and amplification
|
|
//! is really refused;
|
|
//! - the authorisation is a **real `decide` call** against a real budget, and
|
|
//! the receipt records the verdict that call returned. It is decided before
|
|
//! the work runs, so the cost it debits is the action's estimate
|
|
//! ([`authorised_cost`], `Estimator`) — the work's reading is step 3's;
|
|
//! - the joules are **metered**, and the span's provenance is carried through
|
|
//! verbatim: `HwShunt` only when every record the meter returned is a
|
|
//! hardware reading. Where the host has no live counter — or the span's CPU
|
|
//! counter held — the meter returns its `Estimator` record, and this example
|
|
//! says so rather than inventing `HwShunt`;
|
|
//! - the tier is stamped from what is **actually running**. This is in-process
|
|
//! with no isolation boundary crossed, so it is `S0`. Claiming `S5` without a
|
|
//! microVM is exactly the overreach the ladder exists to prevent.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use jcp_action_receipt::{ActionReceipt, Effect, EffectClass, Egress, EgressClass, verify};
|
|
use jcp_grant::{attenuate, issue};
|
|
use jcp_receipt as authority;
|
|
use jcp_runtime::Runtime;
|
|
use jcp_schema::{
|
|
Capability, ContentId, Did, Energy, Frame, FrameKind, Grant, Trust, Verdict,
|
|
};
|
|
use sandbox_meter::{DeviceEnergy, PowerMeter, Receipt as WorkReceipt};
|
|
use sandbox_schema::Tier;
|
|
|
|
const STEWARD: &str = "did:example:ar-e2e";
|
|
|
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
let out = std::env::args().nth(1).unwrap_or_else(|| "action.hex".into());
|
|
|
|
// ── 1. A real grant, really delegated ────────────────────────────
|
|
let issuer = SigningKey::from_bytes(&[11u8; 32]);
|
|
let agent = SigningKey::from_bytes(&[22u8; 32]);
|
|
let subagent = SigningKey::from_bytes(&[33u8; 32]);
|
|
let agent_did = Did::from_verifying_key(&agent.verifying_key());
|
|
let sub_did = Did::from_verifying_key(&subagent.verifying_key());
|
|
|
|
let resource = ContentId::of_bytes(b"resource/quarterly-report");
|
|
let root = Grant {
|
|
issuer: Did::from_verifying_key(&issuer.verifying_key()),
|
|
subject: agent_did,
|
|
capability: Capability::new(resource, ["read", "summarise"]),
|
|
budget_uj: 50_000_000,
|
|
budget_funds: None,
|
|
caveats: Vec::new(),
|
|
expiry: 0,
|
|
parent: None,
|
|
delegate_depth: None,
|
|
nonce: [1u8; 16],
|
|
};
|
|
let sg = issue(&issuer, root.clone())?;
|
|
|
|
// Strictly narrower: read only, one fifth of the ceiling.
|
|
let delegated = attenuate(
|
|
&sg,
|
|
&agent,
|
|
sub_did,
|
|
Capability::new(resource, ["read"]),
|
|
10_000_000,
|
|
0,
|
|
[2u8; 16],
|
|
)?;
|
|
// And prove the runtime really refuses amplification, rather than asserting it.
|
|
let amplified = attenuate(
|
|
&sg,
|
|
&agent,
|
|
sub_did,
|
|
Capability::new(resource, ["read", "delete"]),
|
|
10_000_000,
|
|
0,
|
|
[3u8; 16],
|
|
);
|
|
assert!(amplified.is_err(), "amplification must be refused");
|
|
println!("1. grant issued, attenuated to read-only — amplification refused: {}",
|
|
amplified.unwrap_err());
|
|
|
|
// ── 2. A real authorisation decision ─────────────────────────────
|
|
let mut rt = Runtime::new();
|
|
let frame = Frame {
|
|
kind: FrameKind::ToolCall,
|
|
trust: Trust::Control,
|
|
grant_id: delegated.grant.id(),
|
|
action: "read".into(),
|
|
resource,
|
|
payload_hash: ContentId::of_bytes(b"input/quarterly-report"),
|
|
model: None,
|
|
};
|
|
let decision = rt.decide(&frame, &delegated, sub_did, authorised_cost(), 0);
|
|
let verdict = match decision.verdict {
|
|
Verdict::Allow => "allow",
|
|
Verdict::Deny(_) => "deny",
|
|
};
|
|
// Seal the REAL authority receipt from the decision that just happened, and
|
|
// take its content id from the bytes rather than naming a placeholder.
|
|
let steward_key = SigningKey::from_bytes(&[44u8; 32]);
|
|
let authority_env =
|
|
authority::seal(&frame, &delegated.grant, &decision, &steward_key, STEWARD)?;
|
|
let authority_core =
|
|
authority::signed_core(&frame, &delegated.grant, &decision, STEWARD)?;
|
|
let authority_id = ContentId::of_bytes(&authority_core);
|
|
// Prove it verifies on its own terms before binding it.
|
|
authority::verify(
|
|
&frame,
|
|
&delegated.grant,
|
|
&decision,
|
|
&authority_env,
|
|
STEWARD,
|
|
steward_key.verifying_key().as_bytes(),
|
|
)?;
|
|
println!(
|
|
"2. decide() → {verdict}, {} µJ remaining; authority receipt sealed + verified, id {}",
|
|
decision.budget_remaining_uj,
|
|
&authority_id.to_hex()[..12]
|
|
);
|
|
|
|
// ── 3. Real work, really measured ────────────────────────────────
|
|
let mut meter = PowerMeter::detect();
|
|
let sources = meter.sources().join(",");
|
|
let is_hw = meter.is_hardware();
|
|
|
|
let span = meter.start();
|
|
let mut acc: u64 = 0;
|
|
for i in 0..40_000_000u64 {
|
|
acc = acc.wrapping_add(i ^ (acc >> 7)).rotate_left(3);
|
|
}
|
|
let measurement = span.stop(&mut meter);
|
|
std::hint::black_box(acc);
|
|
|
|
let joules_uj = measurement.total_uj();
|
|
// The span's own provenance, the weakest of its records. A hardware meter
|
|
// still returns its estimator record for a span whose CPU counter held,
|
|
// so `is_hardware()` says nothing about this span.
|
|
let provenance = {
|
|
use sandbox_meter::Provenance as P;
|
|
let records = &measurement.devices;
|
|
if records.is_empty() || records.iter().any(|d| d.provenance == P::Estimator) {
|
|
"Estimator"
|
|
} else if records.iter().all(|d| d.provenance == P::HwShunt && d.measured_uj.is_some()) {
|
|
"HwShunt"
|
|
} else {
|
|
"ModelBased"
|
|
}
|
|
};
|
|
println!(
|
|
"3. work metered — {joules_uj} µJ [{provenance}] over {:.2}s via {sources}",
|
|
measurement.wall.as_secs_f64()
|
|
);
|
|
if provenance != "HwShunt" {
|
|
println!(
|
|
" note: this span was not a hardware reading; provenance is {provenance}, not HwShunt ({})",
|
|
if is_hw { "its CPU counter held" } else { "no live energy counter here" }
|
|
);
|
|
}
|
|
|
|
// Build the REAL work receipt from the devices the meter actually reported.
|
|
// `Receipt::build` enforces the provenance/measured coupling itself, so a
|
|
// mislabelled device is refused here rather than propagating into AR-1.
|
|
let devices: Vec<DeviceEnergy> = measurement
|
|
.devices
|
|
.iter()
|
|
.map(|d| DeviceEnergy {
|
|
class: d.class,
|
|
measured_uj: d.measured_uj,
|
|
estimated_uj: d.estimated_uj,
|
|
provenance: d.provenance,
|
|
})
|
|
.collect();
|
|
let work = WorkReceipt::build(
|
|
sandbox_schema::ContentId::of_bytes(b"unit/quarterly-report-summarise"),
|
|
Tier::S0,
|
|
devices,
|
|
1,
|
|
[7u8; 8],
|
|
)?;
|
|
work.validate()?;
|
|
// The two families agree on BLAKE3 and disagree only on presentation:
|
|
// sandbox renders `b3:<base64url>`, jcp carries raw 32 bytes. Re-addressing
|
|
// the same canonical core with jcp's hasher yields the identical digest, so
|
|
// the binding is exact rather than a hash of a hash.
|
|
let work_id = ContentId::of_bytes(&work.canonical_core());
|
|
debug_assert_eq!(
|
|
work.content_id().as_str().trim_start_matches("b3:").len(),
|
|
43,
|
|
"sandbox content id should be b3:<base64url of 32 bytes>"
|
|
);
|
|
println!(
|
|
" work receipt sealed over {} metered device(s), id {}",
|
|
measurement.devices.len(),
|
|
&work_id.to_hex()[..12]
|
|
);
|
|
|
|
// ── 4. Seal the Action Receipt ───────────────────────────────────
|
|
let receipt = ActionReceipt {
|
|
closed_at: "2026-08-25T12:00:00Z".into(),
|
|
grant_id: delegated.grant.id(),
|
|
chain_ids: vec![sg.grant.id(), delegated.grant.id()],
|
|
authority_receipt: authority_id,
|
|
verdict: verdict.into(),
|
|
attestation_id: ContentId::of_bytes(b"attest/e2e-local"),
|
|
proof_cell_id: None,
|
|
tier: "S0".into(),
|
|
grounding: if is_hw { "hardware" } else { "software" }.into(),
|
|
work_receipt: work_id,
|
|
input_hash: ContentId::of_bytes(b"input/quarterly-report").to_hex(),
|
|
output_hash: None,
|
|
joules_uj,
|
|
energy_provenance: provenance.into(),
|
|
effect: Effect {
|
|
// read-and-summarise changed nothing in the world
|
|
class: EffectClass::None,
|
|
target: resource,
|
|
before: None,
|
|
after: None,
|
|
compensator: None,
|
|
plan_receipt: None,
|
|
},
|
|
egress: Egress {
|
|
// resolved entirely in this process
|
|
class: EgressClass::None,
|
|
bytes: 0,
|
|
peer: None,
|
|
},
|
|
steward: STEWARD.into(),
|
|
};
|
|
|
|
let envelope = receipt.seal(&steward_key)?;
|
|
println!("4. sealed — receipt id {}", receipt.id()?.to_hex());
|
|
|
|
// ── 5. Verify it back, as a third party would ────────────────────
|
|
let v = verify(&envelope, &steward_key.verifying_key(), STEWARD)?;
|
|
assert_eq!(v.id, receipt.id()?);
|
|
println!("5. verified from the envelope alone — {} byte canonical core", v.core.len());
|
|
|
|
std::fs::write(&out, hex::encode(&envelope))?;
|
|
println!();
|
|
println!("wrote {out}");
|
|
println!(
|
|
"verify independently:\n ar-verify {out} --pubkey {} --steward {STEWARD}",
|
|
hex::encode(steward_key.verifying_key().as_bytes())
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// The cost the authorisation debits: decided before the work runs, it is
|
|
/// the action's estimate, 0.25 J, graded `Estimator` with no measured figure.
|
|
/// Sealed as a reading, the authority receipt would claim a meter read work
|
|
/// that had not yet happened; the joules a meter did read are the work
|
|
/// receipt's, and the action receipt's.
|
|
pub fn authorised_cost() -> Energy {
|
|
Energy::estimator(250_000)
|
|
}
|