All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 15s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m24s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 2m3s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 58s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 10m11s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m52s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m11s
standards workspaces build + test / joulecontract-rs (joulecontract/joulecontract-rs, joulecontract-rs, linux-arm64, true) (pull_request) Successful in 4m31s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m14s
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 17m16s
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 1m45s
standards workspaces build + test / proof-rs (proof-conformance, proof/proof-rs, proof-rs, linux, true) (pull_request) Successful in 1m37s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m28s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 7m41s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 1m54s
A grant's capability names one resource by content address. When that resource is an inference endpoint, the model it runs was chosen inside the payload, and the decision point sees only the payload's hash. So a grant could authorize "this endpoint" but never "this endpoint, running these models". Only PayloadIs came close, and it makes the grant single-use. JCP enforced a model's integrity (refuse wrong bytes) but not authorization over which model runs. - `Frame.model: Option<ContentId>` is the content address of the model the action runs. The dispatching runtime fills it, since it knows what it will load. It is omitted from the encoding when absent, so frames and every receipt and vector over them keep their bytes. - `Caveat::ModelIn(Vec<ContentId>)` requires the frame to name one of these models. It fails closed: a frame naming no model is denied `caveat-unsatisfied`, so a dispatcher that does not say what it will run cannot exercise the grant. A delegate narrows the set by adding a smaller ModelIn beside the inherited one; both must hold, so the intersection governs. This is authorization by content address, not a registry (SPEC §12). - SPEC §4.9 and §6 describe both, and C24's conformance check now covers ModelIn. - Every Frame literal in the repository gains `model: None`. That is 28 sites across jcp-rs, joule-code-rs, joule-ui-rs, jouleclaw-rs and sandbox-rs. Checked on the build host: - jcp-rs: 496 tests pass, and the conformance suite passes all checks, including C24 with ModelIn. - Every vector gen-vectors writes is byte-identical to the published one. - All eight workspaces that depend on jcp-schema pass `cargo check --all-targets`. The consumers jouleclaw-jcp, joule-code-jcp, sandbox-jcp and joule-ui pass their tests. - Mutations: letting a frame with no model pass, and encoding an absent model, each fail tests. Co-Authored-By: Claude Opus 5.5 <[email protected]>
227 lines
9.8 KiB
Rust
227 lines
9.8 KiB
Rust
//! The full "metered, attested UI" with **both recognized edges real at once**:
|
||
//! a joule-ui interaction gated by an actual signed **JCP grant** (the
|
||
//! Authorizer edge, SPEC §6) and metered by the actual **Sandbox PowerMeter**
|
||
//! (the Meter edge, SPEC §5). Each interaction's figure is debited from the
|
||
//! grant's budget, and once the budget is spent the **real JCP runtime** — not
|
||
//! a stub — denies the next interaction. Each one seals into the chain.
|
||
//!
|
||
//! Each interaction is metered as one `PowerMeter` span and sealed with that
|
||
//! span's own figure and provenance (the `SpanMeter` in `support/`, the
|
||
//! adapter `joule-ui-serve` uses), and the output says which it is. The
|
||
//! handlers run on this process's own thread and spawn nothing, so the meter
|
||
//! is `PowerMeter::detect_in_process()`: where this process's own CPU energy
|
||
//! counter is live (every Apple Silicon host checked, macOS 27.0.1) each
|
||
//! figure is `HwShunt`, that counter's delta over the interaction. Where it
|
||
//! is not, the meter is `detect()`'s: a live machine-wide counter's delta, or
|
||
//! the `Estimator` figure, wall-clock × TDP for the interaction's span — never
|
||
//! 0 µJ for work that ran. Interactions fire until the runtime denies one, so
|
||
//! the budget binds whatever the figures are.
|
||
//!
|
||
//! The grant is debited with the figure AND its provenance: `Authorizer::charge`
|
||
//! is handed the interaction's sealed figure with its grade, and the authorizer
|
||
//! here hands the JCP runtime an estimate as an estimate, never wrapped as a
|
||
//! measurement. JCP debits a budget by the reading if there is one, else by the
|
||
//! estimate, so estimated interactions spend the grant too.
|
||
//!
|
||
//! ```bash
|
||
//! cargo run -p joule-ui-interaction --example jcp_metered --release
|
||
//! ```
|
||
|
||
use joule_ui_interaction::{
|
||
Authorizer, ContentId, Energy, InteractionLog, Provenance, run_interaction, verify_chain,
|
||
};
|
||
use sandbox_meter::PowerMeter;
|
||
use std::time::{Duration, Instant};
|
||
|
||
#[path = "support/span_meter.rs"]
|
||
mod span_meter;
|
||
use span_meter::SpanMeter;
|
||
|
||
/// The JCP runtime as a joule-ui `Authorizer` (SPEC §6 edge): a real signed,
|
||
/// joule-budgeted grant, enforced by the real two-phase JCP runtime.
|
||
struct JcpAuthorizer {
|
||
runtime: jcp_runtime::Runtime,
|
||
sg: jcp_schema::SignedGrant,
|
||
grant: jcp_schema::Grant,
|
||
caller: jcp_schema::Did,
|
||
}
|
||
impl JcpAuthorizer {
|
||
fn frame(&self, action: &str) -> jcp_schema::Frame {
|
||
jcp_schema::Frame {
|
||
kind: jcp_schema::FrameKind::ToolCall,
|
||
trust: jcp_schema::Trust::Control,
|
||
grant_id: self.grant.id(),
|
||
action: action.to_string(),
|
||
resource: jcp_schema::ContentId::of_bytes(action.as_bytes()),
|
||
payload_hash: jcp_schema::ContentId::of_bytes(b""),
|
||
model: None,
|
||
}
|
||
}
|
||
}
|
||
impl Authorizer for JcpAuthorizer {
|
||
fn authorize(&mut self, action: &str) -> Result<u64, String> {
|
||
let frame = self.frame(action);
|
||
let d = self.runtime.authorize(&frame, &self.sg, self.caller, 0);
|
||
match d.verdict {
|
||
jcp_schema::Verdict::Allow => Ok(d.budget_remaining_uj),
|
||
jcp_schema::Verdict::Deny(r) => Err(r.label().to_string()),
|
||
}
|
||
}
|
||
fn charge(&mut self, _action: &str, cost: &Energy) -> u64 {
|
||
// The figure's own provenance, as the interaction is sealed with it: a
|
||
// measurement is charged as one, an estimate as an estimate.
|
||
let uj = cost.uj();
|
||
let cost = match cost.provenance {
|
||
Provenance::HwShunt => jcp_schema::Energy::measured(uj),
|
||
Provenance::ModelBased => jcp_schema::Energy::modeled(uj),
|
||
Provenance::Estimator => jcp_schema::Energy::estimator(uj),
|
||
};
|
||
self.runtime.charge(&self.grant, cost).budget_remaining_uj
|
||
}
|
||
fn grant_id(&self) -> Option<String> {
|
||
Some(self.grant.id().to_hex())
|
||
}
|
||
}
|
||
|
||
/// What an interaction's figure is, read from its own record: a 0 for work
|
||
/// that ran is no figure, and only a hardware record is called a reading.
|
||
fn figure(e: &Energy) -> String {
|
||
match (e.uj(), e.provenance) {
|
||
(0, p) => format!("no figure (0 µJ [{}]: the meter gave no reading for this work)", p.label()),
|
||
(uj, Provenance::HwShunt) => format!("{uj} µJ [HwShunt]"),
|
||
(uj, p) => format!("{uj} µJ [{}, an estimate]", p.label()),
|
||
}
|
||
}
|
||
|
||
/// The grant's budget, as minted below.
|
||
const BUDGET_UJ: u64 = 15_000_000;
|
||
|
||
fn main() {
|
||
// A real signed JCP grant for the `apply` action, with a joule budget the
|
||
// interactions below exhaust.
|
||
let issuer = ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]);
|
||
let did = jcp_schema::Did::from_verifying_key(&issuer.verifying_key());
|
||
let grant = jcp_schema::Grant {
|
||
issuer: did,
|
||
subject: did,
|
||
capability: jcp_schema::Capability::new(
|
||
jcp_schema::ContentId::of_bytes(b"apply"),
|
||
["apply"],
|
||
),
|
||
budget_uj: BUDGET_UJ, // 15 J
|
||
// Joules-only grant: no money ceiling, no caveats. An empty caveat list is
|
||
// omitted from the canonical encoding, so this stays byte-identical to the
|
||
// grant this example minted before JCP gained dual ceilings.
|
||
budget_funds: None,
|
||
caveats: Vec::new(),
|
||
expiry: 0,
|
||
parent: None,
|
||
delegate_depth: None,
|
||
nonce: [0; 16],
|
||
};
|
||
let sg = jcp_grant::issue(&issuer, grant.clone()).expect("issue grant");
|
||
|
||
// The handlers below run on this thread and spawn nothing: in-process work.
|
||
let pm = PowerMeter::detect_in_process();
|
||
println!(
|
||
"edges: JCP grant (15 J budget, real signed) + {}",
|
||
if pm.is_hardware() {
|
||
format!("Sandbox hardware meter ({})", pm.sources().join(", "))
|
||
} else {
|
||
let why = match sandbox_meter::ioreport_status() {
|
||
// Apple Silicon: say why the SoC counters were declined.
|
||
sandbox_meter::IoReportStatus::Unavailable => String::new(),
|
||
status => format!(" ({status})"),
|
||
};
|
||
format!(
|
||
"no hardware probe{why} — each interaction below is charged and sealed with the \
|
||
estimator's wall-clock × TDP figure for its own span"
|
||
)
|
||
}
|
||
);
|
||
let meter_sources = pm.sources();
|
||
let meter = SpanMeter::new(pm);
|
||
let mut auth = JcpAuthorizer {
|
||
runtime: jcp_runtime::Runtime::new(),
|
||
sg,
|
||
grant,
|
||
caller: did,
|
||
};
|
||
|
||
let surface = ContentId::of_bytes(b"<Form><Slider action='apply'/></Form>");
|
||
let mut log = InteractionLog::new(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]));
|
||
|
||
// Fire interactions doing ~150ms of real work each, until the real JCP
|
||
// runtime denies one (at most MAX_FIRES). Each one's figure is debited from
|
||
// the grant, so once the figures spend it the next one is denied. The first
|
||
// five and the last three lines are printed.
|
||
const MAX_FIRES: u64 = 200;
|
||
let mut lines = Vec::new();
|
||
for n in 1..=MAX_FIRES {
|
||
let it = run_interaction(&surface, "apply", "did:key:z6MkDemoUser", &n.to_le_bytes(), &meter, Some(&mut auth), || {
|
||
let t = Instant::now();
|
||
let mut x = n;
|
||
while t.elapsed() < Duration::from_millis(150) {
|
||
for _ in 0..50_000 {
|
||
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||
}
|
||
}
|
||
std::hint::black_box(x).to_le_bytes().to_vec()
|
||
});
|
||
let denied = !it.verdict.is_allow();
|
||
let charged = if denied { "not run".into() } else { figure(&it.energy) };
|
||
lines.push(format!(
|
||
" apply #{n}: {} · {charged} · grant {} µJ left",
|
||
it.verdict.tag(),
|
||
it.budget_remaining_uj.map(|b| b.to_string()).unwrap_or_else(|| "—".into()),
|
||
));
|
||
log.record(it);
|
||
if denied {
|
||
break;
|
||
}
|
||
}
|
||
if lines.len() <= 8 {
|
||
lines.iter().for_each(|l| println!("{l}"));
|
||
} else {
|
||
lines[..5].iter().for_each(|l| println!("{l}"));
|
||
println!(" … {} more allowed", lines.len() - 8);
|
||
lines[lines.len() - 3..].iter().for_each(|l| println!("{l}"));
|
||
}
|
||
|
||
let verified = verify_chain(log.entries()).expect("chain verifies");
|
||
let allowed: Vec<&Energy> = log
|
||
.entries()
|
||
.iter()
|
||
.filter(|e| e.interaction.verdict.is_allow())
|
||
.map(|e| &e.interaction.energy)
|
||
.collect();
|
||
let denied = verified - allowed.len();
|
||
let sealed: u64 = allowed.iter().map(|e| e.uj()).sum();
|
||
// What the grant was actually debited: the runtime never debits past the
|
||
// budget, so the last figure may have been only partly charged.
|
||
let left = log
|
||
.entries()
|
||
.iter()
|
||
.rev()
|
||
.filter(|e| e.interaction.verdict.is_allow())
|
||
.find_map(|e| e.interaction.budget_remaining_uj)
|
||
.unwrap_or(BUDGET_UJ);
|
||
let debited = BUDGET_UJ - left;
|
||
let unmetered = allowed.iter().filter(|e| e.uj() == 0).count();
|
||
// Label the figures by the sealed records, not by the meter.
|
||
let what = if unmetered > 0 {
|
||
format!("{unmetered} had no figure, 0 µJ for work that ran")
|
||
} else if allowed.iter().all(|e| e.provenance == Provenance::HwShunt) {
|
||
format!("{sealed} µJ sealed, hardware counter deltas (HwShunt) from {}", meter_sources.join(", "))
|
||
} else if allowed.iter().all(|e| e.provenance == Provenance::Estimator) {
|
||
format!("{sealed} µJ sealed, estimated (wall-clock × TDP over each interaction's span), not measured")
|
||
} else {
|
||
format!("{sealed} µJ sealed, partly estimated, not measured")
|
||
};
|
||
let what = format!("{what}; {debited} of the {BUDGET_UJ} µJ budget debited");
|
||
println!(
|
||
"\n{verified} interactions sealed: {} allowed; {what}; {denied} denied by the real JCP runtime{}.",
|
||
allowed.len(),
|
||
if denied > 0 { " once the budget was spent" } else { ", as the budget was never spent" }
|
||
);
|
||
}
|