//! 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, caller: Did, steward_sk: SigningKey, steward: String, costs: fn(Tier) -> u64, now: u64, receipts: Vec>, } /// 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, } 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, caller: Did, steward_sk: SigningKey, steward: impl Into, ) -> Arc { 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 { 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, budget_funds: None, caveats: Vec::new(), 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, costs: fn(Tier) -> u64) -> &Arc { 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(self: &Arc, stage: S) -> GatedStage { 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> { 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 { stage: S, gate: Arc, } impl Stage for GatedStage { fn tier(&self) -> Tier { self.stage.tier() } fn try_resolve(&self, req: &Request) -> Option { 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 { 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 { None } } fn grant_for(actions: &[&str], budget: u64) -> (Vec, 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, budget_funds: None, caveats: Vec::new(), 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, 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, 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, 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, ]); 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, ]); 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, ]); 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::(), 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); } }