From afde4f18ec71d2d3dc568173560cfa43dc3c396d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 09:30:35 -0400 Subject: [PATCH 1/2] mesh: a grant is decided by JCP's runtime, so expiry, caveats, the model and spend bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesh honoured a JCP grant by checking three things: the issuer signature, that the capability permits `infer` on the request, and that the budget covered the request's ceiling. Nothing else was checked: - An expired grant was served. - No caveat was evaluated, so `NotAfter`, `NotBefore`, `PayloadIs`, `MaxCostUj` and `ModelIn` were ignored. - Nothing was ever debited, so one grant paid for any number of serves. mesh-core had no unit test of the grant path. The steward now holds a jcp-runtime `Runtime` and decides every grant in JCP's two-phase metered path: - **Route.** With a grant, a backend whose model the grant's `ModelIn` does not permit is inadmissible, and so is one that names no model. The route is the cheapest permitted backend, and the candidates record why the others were refused (`Router::route_where`). - **Authorise,** before any work. The frame names `infer` on the request, the prompt hash as payload, and the chosen backend's weights as `model`. Signature, expiry, revocation and every cost-independent caveat apply, and what the grant has left must cover the request's ceiling. - **Debit,** after metering. The metered joules are charged whatever happens next. A per-call cost caveat binds here, and a breach is sealed on the receipt as `grant_breach`. - **Model check.** An engine that runs other weights than the ones it named before serving is refused a receipt. Engines name their weights before serving through a new `ServingEngine::weights_digest()`, which defaults to `None`. The refusals that existed keep their errors (`GrantInvalid`, `Unauthorized`, `BudgetTooLow`). `BudgetTooLow` now reports what the grant has left. SPEC §2, §6.1, §7 and §8 and the README describe the new order. §8 states that Mesh does not authenticate the caller, honouring a grant as a bearer token of its subject, and that spend is per steward. Checked on the build host: - mesh-rs: 65 tests pass, six of them new, and every regenerated `mesh/1` vector is byte-identical. - The gguf and openai server features build. - clippy is clean on the changed crates. - Mutations: no debit, a router that ignores the filter, ignoring other denials, and skipping the model check each fail tests. Co-Authored-By: Claude Opus 5.5 --- mesh/README.md | 19 +- mesh/SPEC.md | 48 ++- mesh/mesh-rs/Cargo.lock | 10 + mesh/mesh-rs/Cargo.toml | 1 + mesh/mesh-rs/crates/mesh-core/Cargo.toml | 1 + mesh/mesh-rs/crates/mesh-core/src/lib.rs | 411 ++++++++++++++++++-- mesh/mesh-rs/crates/mesh-engine/src/lib.rs | 17 + mesh/mesh-rs/crates/mesh-receipt/src/lib.rs | 14 + mesh/mesh-rs/crates/mesh-router/src/lib.rs | 18 +- 9 files changed, 496 insertions(+), 43 deletions(-) diff --git a/mesh/README.md b/mesh/README.md index f40161bda..f56e51689 100644 --- a/mesh/README.md +++ b/mesh/README.md @@ -20,17 +20,20 @@ inflate: the joule. ## The shape ``` -ServeRequest ──▶ authorise (JCP grant) ──▶ route (cheapest-sufficient) - │ - ▼ - meter ◀── serve (engine) ──▶ tokens - │ - ▼ +ServeRequest ──▶ route (cheapest-sufficient) ──▶ authorise (JCP grant) + │ + ▼ + meter + debit ◀── serve (engine) ──▶ tokens + │ + ▼ sealed JCR-1 receipt: µJ / accepted token ``` Four roles — **caller**, **router**, **engine**, **steward** — and four phases -per serve: authorise, route, meter, seal. See [`SPEC.md`](SPEC.md) §2. +per serve: route, authorise, meter, seal. The grant is decided by JCP's own +runtime before any work runs, so its expiry, caveats and remaining budget bind +here as at any JCP enforcement point, and the metered joules are debited +against it afterwards. See [`SPEC.md`](SPEC.md) §2. ## The reference workspace @@ -44,7 +47,7 @@ per serve: authorise, route, meter, seal. See [`SPEC.md`](SPEC.md) §2. | `mesh-engine-gguf` | a **real** backend (behind `gguf`): wraps the family's pure-Rust GGUF/Metal inference substrate so the mesh routes and meters actual model tokens | | `mesh-meter` | the meter window; an always-available estimator + an optional bridge to the family hardware meter (RAPL / NVML / Apple) behind `hw-meter`, and `ProcessMeter`: this process's own CPU and GPU energy for an engine that runs in process, measured when a span covers the whole request | | `mesh-receipt` | the `mesh/1` receipt, JCR-1 sealing (det-CBOR + COSE_Sign1), and a hash-chained log | -| `mesh-core` | the orchestration that ties authorise → route → meter → seal together | +| `mesh-core` | the orchestration that ties route → authorise → meter → seal together | | `mesh-conformance` | the deterministic reference scenario, its checks, and the `gen-vectors` bin | Mesh **rides on** [JCP](../jcp/) (a serve request is authorised by a JCP grant; diff --git a/mesh/SPEC.md b/mesh/SPEC.md index 399354aac..10a9a96ba 100644 --- a/mesh/SPEC.md +++ b/mesh/SPEC.md @@ -46,16 +46,31 @@ A Mesh deployment has four roles: One serve proceeds in four phases, in order: -1. **Authorise.** If the request rides on a JCP `SignedGrant`, the steward MUST - verify the issuer signature, confirm the capability permits the action - `infer` on the request's content id, and confirm the grant's joule budget is - ≥ the request's joule ceiling. A failure at any step aborts the serve before - any work is done. -2. **Route.** The router selects the cheapest admissible backend (§4). If none is - admissible, the serve fails with the router's recorded reason. +1. **Route.** The router selects the cheapest admissible backend (§4). If none is + admissible, the serve fails with the router's recorded reason. When the + request rides on a JCP `SignedGrant` carrying a `ModelIn` caveat (JCP §4.9), + a backend whose model the grant does not permit is inadmissible, and so is a + backend that names no model. +2. **Authorise.** If the request rides on a grant, the steward is a JCP + enforcement point and MUST decide the grant in JCP's two-phase metered path + (JCP §7, §4.9) before any work is done. The frame it decides over names the + action `infer` on the request's content id, the request's `prompt_hash` as + payload, and, as `model`, the content address of the weights the chosen + backend runs, when the backend names one. Every check of that decision + applies: signature, expiry, revocation, and every cost-independent caveat. + The steward MUST also refuse the serve when what the grant has left (its + budget less what earlier serves on it were debited) is below the request's + joule ceiling. A failure aborts the serve before any work is done. 3. **Meter.** The steward opens an energy window, calls the engine, and closes the window. Energy MUST be metered **around** the engine; an engine MUST NOT - self-report energy. + self-report energy. With a grant, the steward then debits the metered joules + against it (JCP's second phase), where a cost-dependent caveat such as + `MaxCostUj` binds. The work has run, so the joules are debited even when the + debit breaches a caveat, and the breach is recorded on the receipt + (`grant_breach`, §6.1). An engine that names its weights before serving MUST + report the same weights after; the steward MUST NOT seal a serve whose + engine reports others, since they are not the weights the grant was decided + over. 4. **Seal.** The steward builds the receipt (§6), stamps it into its hash-chained log, and seals it as a JCR-1 envelope. @@ -333,6 +348,7 @@ and nulls; booleans are encoded as `0|1` uints and absent options dropped. | `uj_per_decision` | **the wedge metric** for a decision — `energy.uj() / questions_answered` | | `uj_per_option_scored` | the finer decision figure — `energy.uj() / options_scored` | | `energy_provenance` | `HwShunt | ModelBased | Estimator` | +| `grant_breach` | the JCP denial label when debiting the metered joules breached the grant, e.g. `caveat-unsatisfied` for a per-call cost caveat (OPTIONAL — §2; absent means the debit was within the grant, or the serve rode on none) | | `sequence`, `prev` | hash-chain position and prior envelope's content id | | `nonce`, `steward` | per-receipt nonce and the sealing identity (COSE `kid`) | @@ -356,8 +372,11 @@ any envelope breaks verification of that receipt and the chain link from the nex A conforming implementation MUST: -1. Reject a serve whose grant fails verification, does not permit `infer` on the - request, or carries a budget below the request's joule ceiling (§2.1). +1. Decide a serve's grant as a JCP enforcement point before any work (§2): reject + one that fails verification, has expired or been revoked, does not permit + `infer` on the request, carries a cost-independent caveat that does not hold, + or has less left than the request's joule ceiling; debit the metered joules + against it after the work, recording a breach on the receipt. 2. Choose the cheapest admissible backend and record every candidate weighed (§4). 3. Meter energy around the engine and tag provenance honestly, enforcing the @@ -383,9 +402,16 @@ Mesh composes proven primitives: Ed25519 signatures, BLAKE3 content addressing, RFC 9052 COSE_Sign1, and the JCP capability model. It introduces no new cryptography. Two properties are correctness, not style: -- **Authorisation happens outside the model.** The grant check (§2.1) is enforced +- **Authorisation happens outside the model.** The grant check (§2) is enforced by the steward before any inference runs; a prompt cannot grant itself budget or capability. +- **The caller is not authenticated.** Mesh honours a grant as a bearer token + of its subject: whoever presents it is decided as the subject. A grant that + must not be usable by anyone who obtains it needs a binding Mesh can check + without an identity, such as `PayloadIs` or a short `NotAfter`. +- **A grant's spend is per steward.** The debit ledger is the steward's own, in + memory. A grant presented to several stewards, or to one that restarts, is + spent separately at each (JCP §4.5, §4.11). - **Honesty is enforced at seal time.** A receipt cannot claim a measurement it does not have (§3.3). An operator without a hardware meter produces honest `Estimator` receipts. diff --git a/mesh/mesh-rs/Cargo.lock b/mesh/mesh-rs/Cargo.lock index 8da41400e..3b2c1538f 100644 --- a/mesh/mesh-rs/Cargo.lock +++ b/mesh/mesh-rs/Cargo.lock @@ -432,6 +432,15 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "jcp-runtime" +version = "0.1.0" +dependencies = [ + "jcp-grant", + "jcp-schema", + "serde", +] + [[package]] name = "jcp-schema" version = "0.1.0" @@ -560,6 +569,7 @@ dependencies = [ "ed25519-dalek", "hex", "jcp-grant", + "jcp-runtime", "jcp-schema", "jouleclaw-backend-apple", "mesh-engine", diff --git a/mesh/mesh-rs/Cargo.toml b/mesh/mesh-rs/Cargo.toml index 1a3d1e310..dd021985c 100644 --- a/mesh/mesh-rs/Cargo.toml +++ b/mesh/mesh-rs/Cargo.toml @@ -31,6 +31,7 @@ mesh-core = { path = "crates/mesh-core" } # Family standards Mesh rides on (cross-workspace path deps). jcp-schema = { path = "../../jcp/jcp-rs/crates/jcp-schema" } jcp-grant = { path = "../../jcp/jcp-rs/crates/jcp-grant" } +jcp-runtime = { path = "../../jcp/jcp-rs/crates/jcp-runtime" } # Optional: the family's honest energy meter (RAPL / NVML / Apple), behind `hw-meter`. jouleclaw-energy = { path = "../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy" } jouleclaw-backend-apple = { path = "../../jouleclaw/jouleclaw-rs/crates/jouleclaw-backend-apple" } diff --git a/mesh/mesh-rs/crates/mesh-core/Cargo.toml b/mesh/mesh-rs/crates/mesh-core/Cargo.toml index dceaec7cc..8c3622a88 100644 --- a/mesh/mesh-rs/crates/mesh-core/Cargo.toml +++ b/mesh/mesh-rs/crates/mesh-core/Cargo.toml @@ -15,6 +15,7 @@ mesh-engine = { workspace = true } mesh-router = { workspace = true } jcp-schema = { workspace = true } jcp-grant = { workspace = true } +jcp-runtime = { workspace = true } ed25519-dalek = { workspace = true } thiserror = { workspace = true } diff --git a/mesh/mesh-rs/crates/mesh-core/src/lib.rs b/mesh/mesh-rs/crates/mesh-core/src/lib.rs index 22517d79d..45ec0389d 100644 --- a/mesh/mesh-rs/crates/mesh-core/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-core/src/lib.rs @@ -2,12 +2,17 @@ //! //! [`Mesh`] ties the four moving parts together for one metered inference: //! -//! 1. **Authorise** — if the request rides on a JCP [`SignedGrant`], verify the -//! issuer signature, confirm the capability permits `infer` on this exact -//! request, and confirm the grant's joule budget covers the request ceiling. -//! 2. **Route** — pick the cheapest-sufficient backend (`mesh-router`). +//! 1. **Route** — pick the cheapest-sufficient backend (`mesh-router`). With a +//! JCP [`SignedGrant`], a backend whose model the grant does not permit is +//! inadmissible. +//! 2. **Authorise** — decide the grant in JCP's two-phase metered path +//! (`jcp-runtime`), over a frame naming `infer` on this exact request and +//! the chosen backend's model: signature, expiry, revocation, every +//! cost-independent caveat, and whether what the grant has left covers the +//! request ceiling. All of it before any work runs. //! 3. **Meter** — open an energy window around the engine's `serve` call -//! (`mesh-meter`), so the energy figure is measured, not self-reported. +//! (`mesh-meter`), so the energy figure is measured, not self-reported, and +//! debit the grant with it, where a per-call cost caveat binds. //! 4. **Seal** — build the [`MeshReceipt`], append it to the steward's //! hash-chained log, and return the sealed JCR-1 envelope. //! @@ -27,7 +32,8 @@ compile_error!("a hw-meter-* feature must enable mesh-core's `hw-meter` feature" use std::collections::HashMap; use ed25519_dalek::SigningKey; -use jcp_schema::{ContentId, SignedGrant}; +use jcp_runtime::Runtime; +use jcp_schema::{Caveat, ContentId, DenyReason, Frame, FrameKind, SignedGrant, Trust, Verdict}; use mesh_engine::ServingEngine; use mesh_meter::{Meter, Session}; use mesh_receipt::{MeshReceipt, ReceiptError, ReceiptLog}; @@ -49,9 +55,20 @@ pub enum MeshError { /// The grant does not authorise inference on this request. #[error("grant does not permit `{INFER_ACTION}` on this request")] Unauthorized, - /// The grant's budget is below the request's joule ceiling. - #[error("grant budget {budget_uj} µJ below request ceiling {ceiling_uj} µJ")] + /// What the grant has left at this steward is below the request's joule + /// ceiling. `budget_uj` is the remainder: the grant's budget less what + /// earlier serves riding on it were debited. + #[error("grant has {budget_uj} µJ left, below request ceiling {ceiling_uj} µJ")] BudgetTooLow { budget_uj: u64, ceiling_uj: u64 }, + /// The JCP decision refused the grant for a reason the variants above do + /// not name: it expired, was revoked, carries a caveat that does not hold, + /// or carries a flow ceiling that Mesh computes no provenance for. + #[error("grant denied: {}", .0.label())] + GrantDenied(DenyReason), + /// The engine reported running other weights than the ones it named + /// before serving, which are the ones the grant was decided over. + #[error("backend `{backend}` named model {named} before serving but reported {reported}")] + ModelMismatch { backend: String, named: String, reported: String }, /// The chosen backend has no registered engine (internal invariant). #[error("internal: no engine registered for backend `{0}`")] NoEngine(String), @@ -93,6 +110,14 @@ pub struct Mesh<'m> { engines: HashMap>, meter: &'m dyn Meter, log: ReceiptLog, + /// The steward's JCP enforcement point. Every grant a serve rides on is + /// decided here, in JCP's two-phase metered path: authorised before any + /// work, then debited with the metered joules. Expiry, revocation, every + /// caveat and the grant's cumulative spend therefore bind at Mesh as they + /// do at any other JCP enforcement point. + grants: Runtime, + /// Unix seconds, for expiry and time caveats. + clock: fn() -> u64, } impl<'m> Mesh<'m> { @@ -104,9 +129,23 @@ impl<'m> Mesh<'m> { engines: HashMap::new(), meter, log: ReceiptLog::new(steward_sk, steward), + grants: Runtime::new(), + clock: system_clock, } } + /// Decide grants against `clock` (unix seconds) instead of the system + /// clock. + pub fn with_clock(mut self, clock: fn() -> u64) -> Self { + self.clock = clock; + self + } + + /// The microjoules debited so far against a grant at this steward. + pub fn spent(&self, grant_id: &ContentId) -> u64 { + self.grants.spent(grant_id) + } + /// Register a backend: its routing spec and the engine that serves it. pub fn register(&mut self, spec: BackendSpec, engine: Box) -> &mut Self { self.engines.insert(spec.id.0.clone(), engine); @@ -129,23 +168,43 @@ impl<'m> Mesh<'m> { self.log.head() } - /// Authorise a request against an optional JCP grant. Returns the grant id - /// to record on the receipt (or `None` for an ungranted request). + /// Authorise a request against an optional JCP grant, over the frame that + /// names the model the chosen backend runs. Returns the grant id to record + /// on the receipt (or `None` for an ungranted request). Debits nothing. fn authorize( - &self, + &mut self, req: &ServeRequest, grant: Option<&SignedGrant>, + model: Option, ) -> Result, MeshError> { let Some(sg) = grant else { return Ok(None); }; - jcp_grant::verify(sg).map_err(|e| MeshError::GrantInvalid(e.to_string()))?; - if !sg.grant.capability.permits(&req.id(), INFER_ACTION) { - return Err(MeshError::Unauthorized); + // Mesh does not authenticate who presents a grant (SPEC §8): it honours + // one as a bearer token of its subject, so the subject is the caller. + let d = self.grants.authorize( + &infer_frame(sg, req, model), + sg, + sg.grant.subject, + (self.clock)(), + ); + match d.verdict { + Verdict::Allow => {} + Verdict::Deny(DenyReason::BadSignature) => { + let why = jcp_grant::verify(sg).err().map(|e| e.to_string()); + return Err(MeshError::GrantInvalid(why.unwrap_or_else(|| "bad-signature".into()))); + } + Verdict::Deny(DenyReason::WrongResource | DenyReason::ActionNotGranted) => { + return Err(MeshError::Unauthorized); + } + Verdict::Deny(DenyReason::BudgetExceeded) => { + return Err(MeshError::BudgetTooLow { budget_uj: 0, ceiling_uj: req.joule_ceiling_uj }); + } + Verdict::Deny(r) => return Err(MeshError::GrantDenied(r)), } - if sg.grant.budget_uj < req.joule_ceiling_uj { + if d.budget_remaining_uj < req.joule_ceiling_uj { return Err(MeshError::BudgetTooLow { - budget_uj: sg.grant.budget_uj, + budget_uj: d.budget_remaining_uj, ceiling_uj: req.joule_ceiling_uj, }); } @@ -164,15 +223,35 @@ impl<'m> Mesh<'m> { prompt: &[u32], grant: Option<&SignedGrant>, ) -> Result { - // 1. Authorise. - let grant_id = self.authorize(req, grant)?; - - // 2. Route. - let route = self.router.route(req); + // 1. Route. With a grant, a backend whose model the grant does not + // permit is inadmissible, so the route is the cheapest backend the + // grant allows rather than one the decision would then refuse. This + // only steers: the decision in step 2 is what authorises. + let route = match grant { + None => self.router.route(req), + Some(sg) => { + let engines = &self.engines; + self.router.route_where(req, |b| { + let model = engines.get(&b.0).and_then(|e| e.weights_digest()); + if model_permitted(sg, &infer_frame(sg, req, model)) { + Ok(()) + } else { + Err(match model { + Some(m) => format!("model {} not permitted by the grant", m.to_hex()), + None => "names no model, and the grant permits only named models".into(), + }) + } + }) + } + }; let backend = route .chosen .clone() .ok_or_else(|| MeshError::NoRoute(route.justification.clone()))?; + let model = self.engines.get(&backend.0).and_then(|e| e.weights_digest()); + + // 2. Authorise, before any work runs. + let grant_id = self.authorize(req, grant, model)?; let chosen_fidelity = route .chosen_fidelity .expect("a chosen route carries its fidelity"); @@ -190,7 +269,31 @@ impl<'m> Mesh<'m> { let served = engine.serve(req, prompt); let energy = session.stop(); - // 3a. Decide whether the floor was met, from what came back rather than + // 3a. Debit the grant with what was metered: JCP's phase two, where a + // per-call cost caveat binds. The work has run and the joules are + // real, so they are debited whatever comes next, including a + // refusal to seal below; a breach is recorded on the receipt. + let grant_breach = match grant { + Some(sg) => match self.grants.charge(&sg.grant, jcp_energy(&energy)).verdict { + Verdict::Allow => None, + Verdict::Deny(r) => Some(r.label().to_string()), + }, + None => None, + }; + + // 3b. The engine must have run the weights it named before serving: + // those are the ones the grant was decided over. + if let (Some(named), Some(reported)) = (model, served.outcome.weights_digest) { + if named != reported { + return Err(MeshError::ModelMismatch { + backend: backend.0.clone(), + named: named.to_hex(), + reported: reported.to_hex(), + }); + } + } + + // 3c. Decide whether the floor was met, from what came back rather than // from the route table. The old code computed this at step 2, before // the engine ran, so a backend that degraded at serve time still // sealed `fidelity_met: 1`. @@ -246,6 +349,7 @@ impl<'m> Mesh<'m> { fidelity_delivered: delivered, fidelity_met, energy, + grant_breach, sequence: 0, // stamped by the log prev: None, // stamped by the log nonce, @@ -263,6 +367,54 @@ impl<'m> Mesh<'m> { } } +/// The JCP frame a serve exercises its grant with: the action `infer` on the +/// request's content id, the prompt as payload, and the model the backend +/// runs, if it named one. +fn infer_frame(sg: &SignedGrant, req: &ServeRequest, model: Option) -> Frame { + Frame { + kind: FrameKind::ToolCall, + trust: Trust::Control, + grant_id: sg.grant.id(), + action: INFER_ACTION.into(), + resource: req.id(), + payload_hash: req.prompt_hash, + model, + } +} + +/// Whether the grant's model caveats admit `frame`'s model. `ModelIn` is the +/// only caveat that depends on which backend serves, so it is the only one +/// that steers the route. +fn model_permitted(sg: &SignedGrant, frame: &Frame) -> bool { + sg.grant + .caveats + .iter() + .filter(|c| matches!(c, Caveat::ModelIn(_))) + .all(|c| c.holds(frame, sg.grant.subject, 0, 0)) +} + +/// The metered span as JCP's energy type, for the debit. Only the debited +/// figure is read from it; Mesh's receipt carries the span itself. +fn jcp_energy(e: &Energy) -> jcp_schema::Energy { + let provenance = match e.provenance { + mesh_schema::Provenance::HwShunt => jcp_schema::Provenance::HwShunt, + mesh_schema::Provenance::ModelBased => jcp_schema::Provenance::ModelBased, + mesh_schema::Provenance::Estimator => jcp_schema::Provenance::Estimator, + }; + jcp_schema::Energy { + measured_uj: e.measured_uj, + estimated_uj: e.estimated_uj, + provenance, + coverage: jcp_schema::Coverage::Whole, + } +} + +fn system_clock() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + /// Derive a deterministic per-receipt nonce from the request id (first 8 bytes). fn nonce_from(id: &ContentId) -> [u8; 8] { let b = id.0; @@ -308,6 +460,221 @@ mod tests { m } + /// A grant over exactly `req`, held by a fixed subject. + fn grant_over(req: &ServeRequest, budget_uj: u64, expiry: u64, caveats: Vec) -> SignedGrant { + let issuer = SigningKey::from_bytes(&[42u8; 32]); + let holder = SigningKey::from_bytes(&[43u8; 32]); + let g = jcp_schema::Grant { + issuer: jcp_schema::Did::from_verifying_key(&issuer.verifying_key()), + subject: jcp_schema::Did::from_verifying_key(&holder.verifying_key()), + capability: jcp_schema::Capability::new(req.id(), [INFER_ACTION]), + budget_uj, + budget_funds: None, + caveats, + expiry, + parent: None, + delegate_depth: None, + nonce: [5u8; 16], + }; + jcp_grant::issue(&issuer, g).expect("issue grant") + } + + fn at_1000() -> u64 { + 1_000 + } + + /// Expiry and every cost-independent caveat bind before any work runs. + /// Mesh used to check only the signature, the capability and the budget, + /// so each of these grants was served. + #[test] + fn a_grant_is_decided_whole_before_any_work() { + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000); + let mut mesh = mesh_with(&meter).with_clock(at_1000); + let req = request(8, Fidelity::F1, 10_000_000); + + let refused = [ + (grant_over(&req, 20_000_000, 999, vec![]), DenyReason::Expired), + (grant_over(&req, 20_000_000, 0, vec![Caveat::NotAfter(999)]), DenyReason::CaveatUnsatisfied), + (grant_over(&req, 20_000_000, 0, vec![Caveat::NotBefore(1_001)]), DenyReason::CaveatUnsatisfied), + ( + grant_over(&req, 20_000_000, 0, vec![Caveat::PayloadIs(ContentId::of_bytes(b"another prompt"))]), + DenyReason::CaveatUnsatisfied, + ), + ]; + for (sg, reason) in &refused { + match mesh.serve(&req, &[], Some(sg)) { + Err(MeshError::GrantDenied(r)) => assert_eq!(r, *reason), + other => panic!("expected {reason:?}, got {other:?}"), + } + assert_eq!(mesh.spent(&sg.grant.id()), 0); + } + assert_eq!(mesh.head(), None, "nothing ran, so nothing was sealed"); + + let fine = grant_over(&req, 20_000_000, 1_001, vec![Caveat::NotAfter(1_000), Caveat::PayloadIs(req.prompt_hash)]); + let resp = mesh.serve(&req, &[], Some(&fine)).expect("serve"); + assert_eq!(resp.receipt.grant_id, Some(fine.grant.id())); + assert_eq!(mesh.spent(&fine.grant.id()), 1_000); + } + + /// The refusals Mesh made before keep their errors. + #[test] + fn signature_capability_and_budget_refusals_keep_their_errors() { + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000); + let mut mesh = mesh_with(&meter); + let req = request(8, Fidelity::F1, 10_000_000); + + let mut forged = grant_over(&req, 20_000_000, 0, vec![]); + forged.grant.budget_uj = 1_000_000_000; + assert!(matches!(mesh.serve(&req, &[], Some(&forged)), Err(MeshError::GrantInvalid(_)))); + let elsewhere = grant_over(&request(9, Fidelity::F1, 10_000_000), 20_000_000, 0, vec![]); + assert!(matches!(mesh.serve(&req, &[], Some(&elsewhere)), Err(MeshError::Unauthorized))); + let starved = grant_over(&req, 1, 0, vec![]); + assert!(matches!( + mesh.serve(&req, &[], Some(&starved)), + Err(MeshError::BudgetTooLow { budget_uj: 1, ceiling_uj: 10_000_000 }) + )); + assert_eq!(mesh.head(), None); + } + + /// A grant's budget is what it may ever spend, so serves riding on it spend + /// it down. Before, each serve checked the whole budget against its own + /// ceiling and debited nothing, so one grant paid for any number of them. + #[test] + fn a_grant_is_spent_down_across_serves() { + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 3_000_000); + let mut mesh = mesh_with(&meter); + let req = request(8, Fidelity::F1, 4_000_000); + let sg = grant_over(&req, 10_000_000, 0, vec![]); + + for n in 1..=3 { + mesh.serve(&req, &[], Some(&sg)).expect("within what the grant has left"); + assert_eq!(mesh.spent(&sg.grant.id()), 3_000_000 * n); + } + // 1 J left, below the 4 J ceiling: refused before any work. + assert!(matches!( + mesh.serve(&req, &[], Some(&sg)), + Err(MeshError::BudgetTooLow { budget_uj: 1_000_000, ceiling_uj: 4_000_000 }) + )); + assert_eq!(mesh.spent(&sg.grant.id()), 9_000_000); + } + + /// `ModelIn` makes a backend whose model the grant does not permit + /// inadmissible, so the route is the cheapest permitted one, and the + /// candidates record why the others were refused. + #[test] + fn model_in_routes_to_the_cheapest_backend_the_grant_permits() { + let (a, b) = (ContentId::of_bytes(b"ssm-1b weights"), ContentId::of_bytes(b"frontier weights")); + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000); + let (sk, steward) = steward_key(); + let mut mesh = Mesh::new(&meter, sk, steward); + mesh.register( + BackendSpec::new("anon", "unnamed", Fidelity::F1, 10_000), + Box::new(ReferenceEngine::new("anon", "unnamed", Fidelity::F1, 10_000, 64)), + ); + mesh.register( + BackendSpec::new("tiny", "ssm-1b", Fidelity::F1, 50_000), + Box::new(ReferenceEngine::new("tiny", "ssm-1b", Fidelity::F1, 50_000, 64).reporting_model_identity(a, "fp16")), + ); + mesh.register( + BackendSpec::new("frontier", "wire-rpc", Fidelity::F4, 5_000_000), + Box::new( + ReferenceEngine::new("frontier", "wire-rpc", Fidelity::F4, 5_000_000, 64).reporting_model_identity(b, "fp16"), + ), + ); + let req = request(8, Fidelity::F1, 100_000_000); + + let open = grant_over(&req, 1_000_000_000, 0, vec![]); + assert_eq!(mesh.serve(&req, &[], Some(&open)).unwrap().receipt.backend, BackendId::new("anon")); + + let only_b = grant_over(&req, 1_000_000_000, 0, vec![Caveat::ModelIn(vec![b])]); + let resp = mesh.serve(&req, &[], Some(&only_b)).expect("serve"); + assert_eq!(resp.receipt.backend, BackendId::new("frontier")); + assert_eq!(resp.receipt.weights_digest, Some(b)); + let reason = |id: &str| { + let c = resp.route.candidates.iter().find(|c| c.backend.0 == id).unwrap(); + assert!(!c.admissible); + c.reason.clone().unwrap() + }; + assert!(reason("tiny").contains("not permitted"), "{}", reason("tiny")); + assert!(reason("anon").contains("names no model"), "{}", reason("anon")); + + let head = mesh.head(); + let absent = grant_over(&req, 1_000_000_000, 0, vec![Caveat::ModelIn(vec![ContentId::of_bytes(b"absent")])]); + assert!(matches!(mesh.serve(&req, &[], Some(&absent)), Err(MeshError::NoRoute(_)))); + assert_eq!(mesh.head(), head); + assert_eq!(mesh.spent(&absent.grant.id()), 0); + } + + /// Names one model before serving and reports another after. + struct SwappingEngine { + inner: ReferenceEngine, + named: ContentId, + } + + impl ServingEngine for SwappingEngine { + fn backend_id(&self) -> BackendId { + self.inner.backend_id() + } + fn recipe_id(&self) -> String { + self.inner.recipe_id() + } + fn fidelity(&self) -> Fidelity { + self.inner.fidelity() + } + fn est_uj_per_token(&self) -> u64 { + self.inner.est_uj_per_token() + } + fn weights_digest(&self) -> Option { + Some(self.named) + } + fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> mesh_engine::Served { + self.inner.serve(req, prompt) + } + } + + /// An engine that runs other weights than the ones the grant was decided + /// over is refused a receipt. The joules it spent are still debited. + #[test] + fn an_engine_that_swaps_its_model_is_refused_a_receipt() { + let (named, ran) = (ContentId::of_bytes(b"approved weights"), ContentId::of_bytes(b"other weights")); + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 2_000); + let (sk, steward) = steward_key(); + let mut mesh = Mesh::new(&meter, sk, steward); + mesh.register( + BackendSpec::new("swap", "approved", Fidelity::F1, 50_000), + Box::new(SwappingEngine { + inner: ReferenceEngine::new("swap", "approved", Fidelity::F1, 50_000, 64).reporting_model_identity(ran, "fp16"), + named, + }), + ); + let req = request(8, Fidelity::F1, 10_000_000); + let sg = grant_over(&req, 20_000_000, 0, vec![Caveat::ModelIn(vec![named])]); + assert!(matches!(mesh.serve(&req, &[], Some(&sg)), Err(MeshError::ModelMismatch { .. }))); + assert_eq!(mesh.head(), None); + assert_eq!(mesh.spent(&sg.grant.id()), 2_000); + } + + /// A per-call cost caveat binds at the debit: the work has run, so the + /// serve is debited and sealed, and the receipt carries the breach. + #[test] + fn a_per_call_cost_breach_is_debited_and_sealed_on_the_receipt() { + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 3_000_000); + let mut mesh = mesh_with(&meter); + let req = request(8, Fidelity::F1, 10_000_000); + + let capped = grant_over(&req, 20_000_000, 0, vec![Caveat::MaxCostUj(1_000_000)]); + let resp = mesh.serve(&req, &[], Some(&capped)).expect("the work ran, so the serve seals"); + assert_eq!(resp.receipt.grant_breach.as_deref(), Some("caveat-unsatisfied")); + assert_eq!(mesh.spent(&capped.grant.id()), 3_000_000); + assert_eq!(resp.receipt.to_jcr1_wire(mesh.steward())["grant_breach"], "caveat-unsatisfied"); + assert!(resp.receipt.verify_envelope(&resp.envelope, mesh.steward(), &mesh.verifying_key()).is_ok()); + + let roomy = grant_over(&req, 20_000_000, 0, vec![Caveat::MaxCostUj(5_000_000)]); + let ok = mesh.serve(&req, &[], Some(&roomy)).expect("serve"); + assert_eq!(ok.receipt.grant_breach, None); + assert!(ok.receipt.to_jcr1_wire(mesh.steward()).get("grant_breach").is_none()); + } + #[test] fn measured_end_to_end_seals_and_verifies() { // A HwShunt stepping meter yields a deterministic 3_200_000 µJ span, diff --git a/mesh/mesh-rs/crates/mesh-engine/src/lib.rs b/mesh/mesh-rs/crates/mesh-engine/src/lib.rs index a22caf540..457fd30d5 100644 --- a/mesh/mesh-rs/crates/mesh-engine/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-engine/src/lib.rs @@ -39,6 +39,19 @@ pub trait ServingEngine { fn fidelity(&self) -> Fidelity; /// Modeled microjoules per token (the router's a-priori cost estimate). fn est_uj_per_token(&self) -> u64; + + /// The content address of the weights this engine runs, when it knows + /// them before serving. Mesh names this model in the JCP frame a grant is + /// decided over, so a grant's `ModelIn` caveat can admit or refuse the + /// backend before any work runs (SPEC §2). `None`, the default, names no + /// model, and a grant that permits only named models refuses it. + /// + /// An engine that reports one here MUST report the same digest in + /// [`ServeOutcome::weights_digest`]: Mesh refuses to seal a serve whose + /// engine ran other weights than the ones the grant was decided over. + fn weights_digest(&self) -> Option { + None + } /// Serve the request. `prompt` is the input token payload — passed /// alongside the request, never sealed into the receipt (the receipt records /// only the content-addressed `prompt_hash`, §3.1). Implementations MUST do @@ -189,6 +202,10 @@ impl ServingEngine for ReferenceEngine { self.uj_per_token } + fn weights_digest(&self) -> Option { + self.weights_digest + } + fn serve(&mut self, req: &ServeRequest, _prompt: &[u32]) -> Served { // The reference engine is synthetic: its output is a deterministic // function of the request's content id, independent of the prompt diff --git a/mesh/mesh-rs/crates/mesh-receipt/src/lib.rs b/mesh/mesh-rs/crates/mesh-receipt/src/lib.rs index c5a567d19..5afe7aa8e 100644 --- a/mesh/mesh-rs/crates/mesh-receipt/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-receipt/src/lib.rs @@ -110,6 +110,14 @@ pub struct MeshReceipt { pub fidelity_met: bool, /// The honest, provenance-tagged energy span. pub energy: Energy, + /// The JCP denial label when debiting the metered joules against the grant + /// breached it (SPEC §2): a per-call cost caveat the measured cost + /// exceeded. The work had already run, so the joules were debited anyway; + /// this field is what keeps the breach visible instead of absorbed. + /// `None`, skipped on the wire, means the debit was within the grant, or + /// the serve rode on no grant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_breach: Option, /// Monotonic sequence in the steward's receipt log. pub sequence: u64, /// Content id of the previous envelope (hash chain); `None` at genesis. @@ -211,6 +219,11 @@ impl MeshReceipt { "energy_provenance".into(), json!(self.energy.provenance.jcr1_label()), ); + // Emitted only on a breach, so every receipt whose debit was within its + // grant seals to the bytes it sealed to before the field existed. + if let Some(b) = &self.grant_breach { + obj.insert("grant_breach".into(), json!(b)); + } obj.insert("sequence".into(), json!(self.sequence)); if let Some(p) = &self.prev { obj.insert("prev".into(), json!(p.to_hex())); @@ -345,6 +358,7 @@ mod tests { fidelity_delivered: Some(Fidelity::F2), fidelity_met: true, energy, + grant_breach: None, sequence: 0, prev: None, nonce: [1, 2, 3, 4, 5, 6, 7, 8], diff --git a/mesh/mesh-rs/crates/mesh-router/src/lib.rs b/mesh/mesh-rs/crates/mesh-router/src/lib.rs index 76ce29248..b9d9c9183 100644 --- a/mesh/mesh-rs/crates/mesh-router/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-router/src/lib.rs @@ -404,10 +404,24 @@ impl Router { /// Route a request: weigh every backend, choose the cheapest admissible one, /// and return an auditable decision. pub fn route(&self, req: &ServeRequest) -> RouteDecision { + self.route_where(req, |_| Ok(())) + } + + /// [`route`](Self::route), with one more admissibility test: `permit` may + /// refuse a backend, giving the reason the decision records. Mesh uses it + /// to admit only backends whose model a JCP grant permits, so the route + /// is the cheapest backend the grant allows, not the cheapest overall. + pub fn route_where( + &self, + req: &ServeRequest, + permit: impl Fn(&BackendId) -> Result<(), String>, + ) -> RouteDecision { let mut candidates = Vec::with_capacity(self.backends.len()); for b in &self.backends { let est = b.estimate(req); - let (admissible, reason) = if b.fidelity < req.fidelity_floor { + let (admissible, reason) = if let Err(why) = permit(&b.id) { + (false, Some(why)) + } else if b.fidelity < req.fidelity_floor { ( false, Some(format!( @@ -468,7 +482,7 @@ impl Router { } None => { let justification = format!( - "no admissible backend: all {} candidate(s) below fidelity floor {} or over ceiling {} µJ", + "no admissible backend: all {} candidate(s) refused, below fidelity floor {} or over ceiling {} µJ", candidates.len(), req.fidelity_floor.label(), req.joule_ceiling_uj -- 2.54.0 (Apple Git-157) From 3e2e3f85f3ef782fd7de209e535d776a9d4a30ed Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 11:46:15 -0400 Subject: [PATCH 2/2] joulehook-rs: lock mesh-core's new dependency on jcp-runtime Co-Authored-By: Claude Opus 5.5 --- joulehook/joulehook-rs/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/joulehook/joulehook-rs/Cargo.lock b/joulehook/joulehook-rs/Cargo.lock index eb2be9136..defc52d66 100644 --- a/joulehook/joulehook-rs/Cargo.lock +++ b/joulehook/joulehook-rs/Cargo.lock @@ -537,6 +537,7 @@ version = "0.1.0" dependencies = [ "ed25519-dalek", "jcp-grant", + "jcp-runtime", "jcp-schema", "mesh-engine", "mesh-meter", -- 2.54.0 (Apple Git-157)