The gate's decision logic was reachable only through wrap()/GatedStage —
fine for a Stage-based cascade, but a consumer whose tiers are async
(a model call, a remote ACP turn) can't express them as the sync Stage
trait. Two small additions open the same gate to them:
- JcpGate::authorize is now public: `if gate.authorize(tier, &req) { run }`
— the exact per-consultation decision (decide_chained against the chain,
debit on allow, seal the JCR-1 receipt either way) that GatedStage runs,
callable directly.
- JcpGate::local(resource, actions, budget_uj) issues a self-signed,
single-link grant naming the tier verbs the consumer is willing to use,
under a joule ceiling. The air-gapped, own-issuer case: omit "l4" and the
remote frontier agent is structurally unreachable — no policy server
required. Deterministic local keys; receipts still seal and aggregate.
Two tests: granted tiers allow / ungranted l4 denies with all three
consultations sealed; budget caps total spend at the third L3 attempt.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
375 lines
15 KiB
Rust
375 lines
15 KiB
Rust
//! Joule Code as a JCP consumer: the L0–L4 cascade gated by a metered Grant.
|
||
//!
|
||
//! The cascade's stop-at-first-close rule picks the *cheapest sufficient* tier;
|
||
//! this crate adds the missing half of the sentence — cheapest sufficient
|
||
//! **and authorized**. Every stage consultation is decided by the JCP runtime
|
||
//! against a delegation chain *before* the stage runs: the tier is the
|
||
//! `action`, the source tree is the content-addressed `resource`, and the
|
||
//! tier's energy class is debited from the grant on allow. A tier the grant
|
||
//! doesn't name (no `l4` action → no remote frontier agent, ever) or can't
|
||
//! afford is skipped, and the cascade falls through to nothing rather than
|
||
//! overspend. Every consultation — allowed and denied — seals as a JCR-1
|
||
//! receipt.
|
||
//!
|
||
//! This is the L4 story made structural: the escape hatch to a remote agent
|
||
//! exists only when a signed grant says so, with a joule ceiling the runtime
|
||
//! enforces outside any model.
|
||
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
use ed25519_dalek::SigningKey;
|
||
use jcp_grant::issue;
|
||
use jcp_runtime::Runtime;
|
||
use jcp_schema::{
|
||
Capability, ContentId, Did, Energy, Frame, FrameKind, Grant, SignedGrant, Trust,
|
||
};
|
||
use joule_code_cascade::{Request, Resolution, Stage};
|
||
use joule_code_schema::Tier;
|
||
|
||
/// The JCP action string for a tier — the verb a grant must name for the
|
||
/// cascade to consult that tier.
|
||
pub fn tier_action(tier: Tier) -> &'static str {
|
||
match tier {
|
||
Tier::L0 => "l0",
|
||
Tier::L1 => "l1",
|
||
Tier::L2 => "l2",
|
||
Tier::L3 => "l3",
|
||
Tier::L4 => "l4",
|
||
}
|
||
}
|
||
|
||
/// Order-of-magnitude attempt cost per tier, in µJ, with honest `Estimator`
|
||
/// provenance — the class markers of SPEC §1.1, not measurements. A deployment
|
||
/// with a real meter substitutes measured figures via [`JcpGate::with_costs`].
|
||
pub fn tier_cost_estimate(tier: Tier) -> u64 {
|
||
match tier {
|
||
Tier::L0 => 1, // picojoule class, floored at 1 µJ integer
|
||
Tier::L1 => 10, // nanojoule class
|
||
Tier::L2 => 1_000, // sub-millijoule class
|
||
Tier::L3 => 1_000_000, // joule class
|
||
Tier::L4 => 10_000_000, // tens-of-joules class — the escape hatch
|
||
}
|
||
}
|
||
|
||
struct Inner {
|
||
runtime: Runtime,
|
||
chain: Vec<SignedGrant>,
|
||
caller: Did,
|
||
steward_sk: SigningKey,
|
||
steward: String,
|
||
costs: fn(Tier) -> u64,
|
||
now: u64,
|
||
receipts: Vec<Vec<u8>>,
|
||
}
|
||
|
||
/// A shared JCP gate for one cascade: the runtime that decides every stage
|
||
/// consultation, the delegation chain it decides against, and the receipt
|
||
/// stream it seals. Wrap each stage with [`JcpGate::wrap`].
|
||
pub struct JcpGate {
|
||
inner: Mutex<Inner>,
|
||
}
|
||
|
||
impl JcpGate {
|
||
/// Open a gate over a delegation chain (root first; a single grant is a
|
||
/// one-link chain). `caller` is the acting agent — the leaf's subject.
|
||
pub fn new(
|
||
chain: Vec<SignedGrant>,
|
||
caller: Did,
|
||
steward_sk: SigningKey,
|
||
steward: impl Into<String>,
|
||
) -> Arc<Self> {
|
||
Arc::new(Self {
|
||
inner: Mutex::new(Inner {
|
||
runtime: Runtime::new(),
|
||
chain,
|
||
caller,
|
||
steward_sk,
|
||
steward: steward.into(),
|
||
costs: tier_cost_estimate,
|
||
now: 0,
|
||
receipts: Vec::new(),
|
||
}),
|
||
})
|
||
}
|
||
|
||
/// Open a gate over a fresh, self-signed grant naming `actions` (the tier
|
||
/// verbs `"l0"`..`"l4"`) with a `budget_uj` joule ceiling over `resource` (a
|
||
/// repo/tree identifier). The convenience for a single-endpoint consumer
|
||
/// that is its own issuer — the air-gapped, no-external-authority case: it
|
||
/// grants itself exactly the tiers it is willing to use (omit `"l4"` and the
|
||
/// remote frontier agent is unreachable, structurally) under a ceiling the
|
||
/// runtime enforces. Deterministic local keys; the receipts still seal.
|
||
pub fn local(resource: &str, actions: &[&str], budget_uj: u64) -> Arc<Self> {
|
||
let issuer = SigningKey::from_bytes(&[0x6a; 32]);
|
||
let did = Did::from_verifying_key(&issuer.verifying_key());
|
||
let grant = Grant {
|
||
issuer: did,
|
||
subject: did,
|
||
capability: Capability::new(
|
||
ContentId::of_bytes(resource.as_bytes()),
|
||
actions.iter().copied(),
|
||
),
|
||
budget_uj,
|
||
expiry: 0,
|
||
parent: None,
|
||
delegate_depth: None,
|
||
nonce: [0; 16],
|
||
};
|
||
let signed = issue(&issuer, grant).expect("self-issued local grant is valid");
|
||
let steward = SigningKey::from_bytes(&[0x5e; 32]);
|
||
Self::new(vec![signed], did, steward, "did:joule-code:local")
|
||
}
|
||
|
||
/// Substitute a per-tier cost table (e.g. host-calibrated figures).
|
||
pub fn with_costs(self: &Arc<Self>, costs: fn(Tier) -> u64) -> &Arc<Self> {
|
||
self.inner.lock().expect("gate lock").costs = costs;
|
||
self
|
||
}
|
||
|
||
/// Set decision time (seconds). Time is an input, not ambient state.
|
||
pub fn set_now(&self, now: u64) {
|
||
self.inner.lock().expect("gate lock").now = now;
|
||
}
|
||
|
||
/// Wrap a stage so every consultation is decided by this gate first.
|
||
pub fn wrap<S: Stage + 'static>(self: &Arc<Self>, stage: S) -> GatedStage<S> {
|
||
GatedStage { stage, gate: Arc::clone(self) }
|
||
}
|
||
|
||
/// Joules debited so far against the leaf grant.
|
||
pub fn spent(&self) -> u64 {
|
||
let inner = self.inner.lock().expect("gate lock");
|
||
let leaf = inner.chain.last().expect("non-empty chain").grant.id();
|
||
inner.runtime.spent(&leaf)
|
||
}
|
||
|
||
/// The sealed JCR-1 receipts for every consultation, allowed and denied.
|
||
pub fn receipts(&self) -> Vec<Vec<u8>> {
|
||
self.inner.lock().expect("gate lock").receipts.clone()
|
||
}
|
||
|
||
/// Decide one stage consultation. On allow the tier's cost is debited from
|
||
/// every link of the chain; either way the decision seals. Public so a
|
||
/// consumer whose tiers are not [`Stage`]s (e.g. async tiers) can gate them
|
||
/// directly: `if gate.authorize(tier, &req) { /* run the tier */ }`.
|
||
pub fn authorize(&self, tier: Tier, req: &Request) -> bool {
|
||
let mut inner = self.inner.lock().expect("gate lock");
|
||
let leaf = inner.chain.last().expect("non-empty chain").grant.clone();
|
||
let frame = Frame {
|
||
kind: FrameKind::AgentTask,
|
||
trust: Trust::Control,
|
||
grant_id: leaf.id(),
|
||
action: tier_action(tier).to_string(),
|
||
resource: leaf.capability.resource,
|
||
payload_hash: ContentId::of_bytes(format!("{}\n{}", req.prior, req.intent).as_bytes()),
|
||
};
|
||
let cost = Energy::estimator((inner.costs)(tier));
|
||
let (caller, now) = (inner.caller, inner.now);
|
||
let chain = inner.chain.clone();
|
||
let decision = inner.runtime.decide_chained(&frame, &chain, caller, cost, now);
|
||
let chain_ids: Vec<_> = chain.iter().map(|sg| sg.grant.id()).collect();
|
||
if let Ok(receipt) =
|
||
jcp_receipt::seal_chained(&frame, &chain_ids, &leaf, &decision, &inner.steward_sk, &inner.steward)
|
||
{
|
||
inner.receipts.push(receipt);
|
||
}
|
||
decision.is_allow()
|
||
}
|
||
}
|
||
|
||
/// A cascade stage that consults the JCP gate before running. A denied tier
|
||
/// returns `None` — the cascade falls through exactly as if the tier could not
|
||
/// close the request, because for this caller it can't.
|
||
pub struct GatedStage<S: Stage> {
|
||
stage: S,
|
||
gate: Arc<JcpGate>,
|
||
}
|
||
|
||
impl<S: Stage> Stage for GatedStage<S> {
|
||
fn tier(&self) -> Tier {
|
||
self.stage.tier()
|
||
}
|
||
|
||
fn try_resolve(&self, req: &Request) -> Option<Resolution> {
|
||
if !self.gate.authorize(self.stage.tier(), req) {
|
||
return None;
|
||
}
|
||
self.stage.try_resolve(req)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use jcp_grant::issue;
|
||
use jcp_schema::{Capability, Grant};
|
||
use joule_code_cascade::Cascade;
|
||
|
||
fn sk(s: u8) -> SigningKey {
|
||
SigningKey::from_bytes(&[s; 32])
|
||
}
|
||
fn did(k: &SigningKey) -> Did {
|
||
Did::from_verifying_key(&k.verifying_key())
|
||
}
|
||
|
||
/// A stage that always closes at its tier.
|
||
struct Always(Tier);
|
||
impl Stage for Always {
|
||
fn tier(&self) -> Tier {
|
||
self.0
|
||
}
|
||
fn try_resolve(&self, _req: &Request) -> Option<Resolution> {
|
||
Some(Resolution {
|
||
op: None,
|
||
answer: Some(format!("closed at {:?}", self.0)),
|
||
receipt: joule_code_receipt::Receipt {
|
||
op_id: joule_code_schema::ContentId::of_bytes(b"op"),
|
||
tier: self.0,
|
||
energy: joule_code_receipt::Energy {
|
||
measured_uj: None,
|
||
estimated_uj: tier_cost_estimate(self.0),
|
||
provenance: joule_code_receipt::Provenance::Estimator,
|
||
},
|
||
sequence: 0,
|
||
nonce: [0; 8],
|
||
},
|
||
})
|
||
}
|
||
}
|
||
/// A stage that never closes (e.g. a cache miss).
|
||
struct Never(Tier);
|
||
impl Stage for Never {
|
||
fn tier(&self) -> Tier {
|
||
self.0
|
||
}
|
||
fn try_resolve(&self, _req: &Request) -> Option<Resolution> {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn grant_for(actions: &[&str], budget: u64) -> (Vec<SignedGrant>, Did) {
|
||
let issuer = sk(1);
|
||
let agent = sk(2);
|
||
let tree = ContentId::of_bytes(b"repo:joule-code");
|
||
let g = Grant {
|
||
issuer: did(&issuer),
|
||
subject: did(&agent),
|
||
capability: Capability::new(tree, actions.iter().copied()),
|
||
budget_uj: budget,
|
||
expiry: 0,
|
||
parent: None,
|
||
delegate_depth: None,
|
||
nonce: [0; 16],
|
||
};
|
||
(vec![issue(&issuer, g).unwrap()], did(&agent))
|
||
}
|
||
|
||
fn req() -> Request {
|
||
Request { prior: "tree-hash".into(), intent: "rename the function".into() }
|
||
}
|
||
|
||
#[test]
|
||
fn ungranted_l4_never_fires_even_when_it_would_close() {
|
||
// The grant names l0–l3 but NOT l4: no remote frontier agent, ever.
|
||
let (chain, caller) = grant_for(&["l0", "l1", "l2", "l3"], u64::MAX);
|
||
let gate = JcpGate::new(chain, caller, sk(7), "did:example:joule-code");
|
||
let cascade = Cascade::new([
|
||
Box::new(gate.wrap(Never(Tier::L1))) as Box<dyn Stage>,
|
||
Box::new(gate.wrap(Always(Tier::L4))),
|
||
]);
|
||
assert!(cascade.resolve(&req()).is_none()); // L4 denied → nothing closes
|
||
// both consultations sealed: one allow (L1, didn't close), one deny (L4)
|
||
assert_eq!(gate.receipts().len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn granted_l4_closes_and_debits_its_class() {
|
||
let (chain, caller) = grant_for(&["l1", "l4"], 20_000_000);
|
||
let gate = JcpGate::new(chain, caller, sk(7), "did:example:joule-code");
|
||
let cascade = Cascade::new([
|
||
Box::new(gate.wrap(Never(Tier::L1))) as Box<dyn Stage>,
|
||
Box::new(gate.wrap(Always(Tier::L4))),
|
||
]);
|
||
let res = cascade.resolve(&req()).expect("granted L4 closes");
|
||
assert_eq!(res.receipt.tier, Tier::L4);
|
||
// L1 attempt (10) + L4 attempt (10_000_000) debited
|
||
assert_eq!(gate.spent(), 10_000_010);
|
||
}
|
||
|
||
#[test]
|
||
fn an_exhausted_budget_stops_the_escape_hatch() {
|
||
// budget affords exactly one L4 consultation (plus the L1 probes)
|
||
let (chain, caller) = grant_for(&["l1", "l4"], 10_000_020);
|
||
let gate = JcpGate::new(chain, caller, sk(7), "did:example:joule-code");
|
||
let cascade = Cascade::new([
|
||
Box::new(gate.wrap(Never(Tier::L1))) as Box<dyn Stage>,
|
||
Box::new(gate.wrap(Always(Tier::L4))),
|
||
]);
|
||
assert!(cascade.resolve(&req()).is_some()); // first run closes at L4
|
||
assert!(cascade.resolve(&req()).is_none()); // second: L4 no longer affordable
|
||
// the deny is sealed evidence, not a silent skip
|
||
let steward_vk = sk(7).verifying_key().to_bytes();
|
||
let receipts = gate.receipts();
|
||
let last = jcp_receipt::extract(receipts.last().unwrap(), "did:example:joule-code", &steward_vk).unwrap();
|
||
assert_eq!(last.verdict, "deny");
|
||
assert_eq!(last.deny_reason.as_deref(), Some("budget-exceeded"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_local_self_issued_gate_still_walls_off_l4() {
|
||
// the air-gapped case: the runtime grants itself l0–l3 only
|
||
let gate = JcpGate::local("repo:local", &["l0", "l1", "l2", "l3"], 1_000_000);
|
||
let cascade = Cascade::new([
|
||
Box::new(gate.wrap(Always(Tier::L4))) as Box<dyn Stage>,
|
||
]);
|
||
assert!(cascade.resolve(&req()).is_none()); // no l4 in the self-grant
|
||
assert_eq!(gate.spent(), 0);
|
||
let cascade = Cascade::new([
|
||
Box::new(gate.wrap(Always(Tier::L1))) as Box<dyn Stage>,
|
||
]);
|
||
assert!(cascade.resolve(&req()).is_some()); // granted tiers still close
|
||
}
|
||
|
||
#[test]
|
||
fn cheaper_tiers_stay_cheap_and_receipts_attribute_per_tier() {
|
||
let (chain, caller) = grant_for(&["l0", "l1"], 1_000);
|
||
let gate = JcpGate::new(chain, caller, sk(7), "did:example:joule-code");
|
||
let cascade = Cascade::new([
|
||
Box::new(gate.wrap(Always(Tier::L1))) as Box<dyn Stage>,
|
||
]);
|
||
for _ in 0..3 {
|
||
assert!(cascade.resolve(&req()).is_some());
|
||
}
|
||
assert_eq!(gate.spent(), 30);
|
||
let steward_vk = sk(7).verifying_key().to_bytes();
|
||
let receipts = gate.receipts();
|
||
let (totals, bad) =
|
||
jcp_receipt::aggregate(receipts.iter().map(|r| r.as_slice()), "did:example:joule-code", &steward_vk);
|
||
assert_eq!(bad, 0);
|
||
assert_eq!(totals.values().map(|t| t.spent_uj).sum::<u64>(), 30);
|
||
}
|
||
|
||
#[test]
|
||
fn local_gate_authorizes_granted_tiers_and_structurally_denies_the_rest() {
|
||
// A self-issued local grant naming l0..l3 but NOT l4.
|
||
let gate = JcpGate::local("repo:test", &["l0", "l1", "l2", "l3"], u64::MAX);
|
||
let request = req();
|
||
assert!(gate.authorize(Tier::L0, &request));
|
||
assert!(gate.authorize(Tier::L3, &request));
|
||
// l4 is not in the grant → the remote frontier agent is unreachable.
|
||
assert!(!gate.authorize(Tier::L4, &request));
|
||
// Every consultation — allow and deny — sealed a receipt.
|
||
assert_eq!(gate.receipts().len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn local_gate_budget_caps_total_spend() {
|
||
// Budget affords two L3 consultations (1_000_000 µJ each), not a third.
|
||
let gate = JcpGate::local("repo:test", &["l3"], 2_000_000);
|
||
let request = req();
|
||
assert!(gate.authorize(Tier::L3, &request));
|
||
assert!(gate.authorize(Tier::L3, &request));
|
||
assert!(!gate.authorize(Tier::L3, &request)); // over budget → denied
|
||
assert_eq!(gate.spent(), 2_000_000);
|
||
}
|
||
}
|