mesh: a grant is spent only by its issuer, at the steward it names, and its spend survives a restart #301
12 changed files with 757 additions and 104 deletions
3
joulehook/joulehook-rs/Cargo.lock
generated
3
joulehook/joulehook-rs/Cargo.lock
generated
|
|
@ -544,6 +544,8 @@ dependencies = [
|
|||
"mesh-receipt",
|
||||
"mesh-router",
|
||||
"mesh-schema",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
|
|
@ -566,7 +568,6 @@ name = "mesh-openai"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"jcp-schema",
|
||||
"mesh-core",
|
||||
"mesh-schema",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ Four roles — **caller**, **router**, **engine**, **steward** — and four phas
|
|||
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.
|
||||
against it afterwards. A grant is spent only by its issuer, who signs each
|
||||
presentation for one request, and only at the steward it was issued to; each
|
||||
decision is journalled before the work, so a restart gives no budget back. See
|
||||
[`SPEC.md`](SPEC.md) §2.
|
||||
|
||||
## The reference workspace
|
||||
|
||||
|
|
|
|||
129
mesh/SPEC.md
129
mesh/SPEC.md
|
|
@ -37,8 +37,8 @@ and proves it (§9).
|
|||
|
||||
A Mesh deployment has four roles:
|
||||
|
||||
- **Caller** — issues a `ServeRequest` and, optionally, the JCP grant that
|
||||
authorises it.
|
||||
- **Caller** — issues a `ServeRequest` and, optionally, presents a JCP grant it
|
||||
issued to the steward, signed for that request (§2.1).
|
||||
- **Router** — selects a backend (§4).
|
||||
- **Engine** — a backend that serves the request (§5).
|
||||
- **Steward** — the endpoint that meters the span (§5/§6) and seals the receipt
|
||||
|
|
@ -48,19 +48,22 @@ One serve proceeds in four phases, in order:
|
|||
|
||||
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),
|
||||
request rides on a grant whose chain carries 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.
|
||||
2. **Authorise.** If the request rides on a grant, the steward first verifies
|
||||
the presentation (§2.1), then decides the grant as a JCP enforcement point,
|
||||
and as the grant's holder, in JCP's two-phase metered path over its chain
|
||||
(JCP §4.2, §7, §4.9). 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, on every link of
|
||||
the chain: signature, attenuation, expiry, revocation, and every
|
||||
cost-independent caveat. The steward MUST also refuse the serve when what the
|
||||
grant has left (the least any link has left, its budget less what earlier
|
||||
serves on it were debited) is below the request's joule ceiling. The
|
||||
decision is recorded in the steward's journal (§2.2) before the engine runs.
|
||||
A failure at any step 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. With a grant, the steward then debits the metered joules
|
||||
|
|
@ -74,6 +77,69 @@ One serve proceeds in four phases, in order:
|
|||
4. **Seal.** The steward builds the receipt (§6), stamps it into its hash-chained
|
||||
log, and seals it as a JCR-1 envelope.
|
||||
|
||||
### §2.1 Presenting a grant
|
||||
|
||||
A grant is a signed public object, so holding one MUST NOT be enough to spend
|
||||
it. A caller presents a grant as:
|
||||
|
||||
```
|
||||
Presentation {
|
||||
chain: [signed-grant…] ; root first, leaf last (JCP §4.2); a chain of one for a direct grant
|
||||
caller: did:key
|
||||
nonce: bytes(16)
|
||||
signature: bytes(64) ; Ed25519 by `caller` over canonical_cbor({request_id, grant_id, caller, nonce})
|
||||
}
|
||||
```
|
||||
|
||||
where `request_id` is the request's content id and `grant_id` the leaf's. The
|
||||
steward MUST refuse a presentation whose `caller` is not the leaf's issuer, or
|
||||
whose signature does not verify for the request being served. The leaf's issuer
|
||||
is the party that authorised the steward to spend on its behalf, so it is the
|
||||
only party that may.
|
||||
|
||||
The leaf's **subject is the steward**: the steward decides the chain as its
|
||||
holder, under the identity of the key it seals receipts with, and MUST refuse a
|
||||
grant held by another steward. One grant therefore spends at one steward. A
|
||||
budget meant for several is sharded by its holder into sibling children, one
|
||||
held by each steward, whose budgets sum to no more than the parent's (JCP §4.5);
|
||||
each steward enforces only its own, and the total stays bounded without the
|
||||
stewards coordinating.
|
||||
|
||||
A presentation spends once at a steward. The steward MUST refuse one it has
|
||||
already spent, identified by the leaf and the nonce, so a captured presentation
|
||||
cannot be replayed there. A caller that wants the same request served again
|
||||
signs a new presentation with a new nonce, and that serve is debited too.
|
||||
|
||||
### §2.2 The decision journal
|
||||
|
||||
A grant's spend at a steward is state, and a steward that restarts with its
|
||||
spend in memory would give every grant its budget back (JCP §4.11). A steward
|
||||
that can restart MUST therefore keep a durable journal of its grant decisions:
|
||||
|
||||
- An `Authorised` entry, holding the presentation, the request, the model, the
|
||||
backend and the decision time, MUST be written **before** the engine runs. A
|
||||
serve whose authorisation cannot be recorded is refused.
|
||||
- A `Debited` entry, holding the metered energy, is written after the debit.
|
||||
|
||||
On restart the steward MUST re-establish the spend before serving by replaying
|
||||
the journal through the gate: each authorisation is verified and decided again,
|
||||
at its recorded time, and each debit applied to the serve it belongs to. A
|
||||
replay the gate refuses, a debit with no authorisation, or a presentation spent
|
||||
twice means the journal is not one the steward produced, and the steward MUST
|
||||
NOT serve from it. Replayed presentations count as spent.
|
||||
|
||||
An authorisation with no debit is a serve that was in flight when the steward
|
||||
stopped, and whether the engine finished is not knowable from the journal. The
|
||||
steward MUST NOT retry it or report it as completed or failed. It stays debited,
|
||||
at its request's joule ceiling, which is what its authorisation admitted, and is
|
||||
surfaced as unresolved, naming the caller and the backend that held it, for an
|
||||
operator to settle.
|
||||
|
||||
The reference journal is an append-only file of one JSON entry per line, each
|
||||
synced before the append returns. A final line with no newline is an append cut
|
||||
short and is dropped: an authorisation never recorded never started its serve,
|
||||
and a debit never recorded leaves its serve unresolved.
|
||||
|
||||
---
|
||||
|
||||
## §3 The request and the energy record
|
||||
|
|
@ -372,18 +438,22 @@ any envelope breaks verification of that receipt and the chain link from the nex
|
|||
|
||||
A conforming implementation MUST:
|
||||
|
||||
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
|
||||
1. Decide a serve's grant as a JCP enforcement point and as its holder before any
|
||||
work (§2): reject a presentation not signed for the request by the grant's
|
||||
issuer, or already spent here (§2.1); reject a chain that fails verification,
|
||||
is held by another steward, 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
|
||||
against every link after the work, recording a breach on the receipt.
|
||||
2. Where the steward can restart, journal each decision before its serve runs
|
||||
and re-establish spend from the journal before serving again (§2.2).
|
||||
3. Choose the cheapest admissible backend and record every candidate weighed
|
||||
(§4).
|
||||
3. Meter energy around the engine and tag provenance honestly, enforcing the
|
||||
4. Meter energy around the engine and tag provenance honestly, enforcing the
|
||||
`Energy` coupling before sealing (§3.3).
|
||||
4. Seal the receipt as a JCR-1 `mesh/1` envelope and compute
|
||||
5. Seal the receipt as a JCR-1 `mesh/1` envelope and compute
|
||||
`uj_per_accepted_token` (§6).
|
||||
5. Maintain a tamper-evident hash chain (§6.3).
|
||||
6. Maintain a tamper-evident hash chain (§6.3).
|
||||
|
||||
The published vectors in [`../conformance/v1/`](../conformance/v1/) pin a
|
||||
deterministic reference serve. `reference/signed_core.hex` MUST canonical-CBOR
|
||||
|
|
@ -400,18 +470,21 @@ cargo run -p mesh-conformance --bin gen-vectors -- ../conformance/v1
|
|||
|
||||
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:
|
||||
cryptography. These properties are correctness, not style:
|
||||
|
||||
- **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).
|
||||
- **A grant spends only for its issuer, once per presentation, at one steward.**
|
||||
A presentation is signed by the grant's issuer for one request (§2.1), so a
|
||||
party that obtains a grant, or a captured presentation, cannot spend it; and
|
||||
the steward is the grant's holder, so a grant cannot be spent at a steward it
|
||||
was not issued to. Spreading a budget over stewards is sharding.
|
||||
- **Spend survives the process.** The journal is written before the work and
|
||||
replayed through the gate on restart (§2.2), so a restart gives no budget back
|
||||
and a crash mid-serve leaves the serve debited and unresolved rather than
|
||||
free. A journal is only as durable as the storage under it, and a steward
|
||||
with an in-memory journal has no such guarantee.
|
||||
- **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.
|
||||
|
|
|
|||
2
mesh/mesh-rs/Cargo.lock
generated
2
mesh/mesh-rs/Cargo.lock
generated
|
|
@ -577,6 +577,7 @@ dependencies = [
|
|||
"mesh-receipt",
|
||||
"mesh-router",
|
||||
"mesh-schema",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
|
@ -621,7 +622,6 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"jcp-schema",
|
||||
"jouleclaw-backend-apple",
|
||||
"mesh-core",
|
||||
"mesh-engine",
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ jcp-grant = { workspace = true }
|
|||
jcp-runtime = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hex = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# The GPU load for `ProcessMeter`'s liveness probe in tests/process_meter.rs.
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use jcp_schema::{Capability, Did, Grant};
|
||||
use mesh_core::{Mesh, INFER_ACTION};
|
||||
use mesh_core::{Mesh, Presentation, INFER_ACTION};
|
||||
use mesh_engine::ReferenceEngine;
|
||||
use mesh_meter::Meter;
|
||||
use mesh_router::BackendSpec;
|
||||
|
|
@ -85,12 +85,15 @@ fn main() {
|
|||
nonce: [0u8; 16],
|
||||
};
|
||||
let signed = jcp_grant::issue(&caller_sk, grant).expect("issue grant");
|
||||
// The caller presents it, signing for this request: the steward spends a
|
||||
// grant only for its issuer, and each presentation only once.
|
||||
let presented = Presentation::sign(vec![signed], &req, &caller_sk, [1u8; 16]);
|
||||
|
||||
// --- serve ---
|
||||
// The prompt token payload travels with the serve call (here a small fixed
|
||||
// stand-in) but is never sealed into the receipt — only `prompt_hash` is.
|
||||
let prompt_tokens: Vec<u32> = vec![1, 2, 3, 4, 5, 6, 7];
|
||||
let resp = mesh.serve(&req, &prompt_tokens, Some(&signed)).expect("serve");
|
||||
let resp = mesh.serve(&req, &prompt_tokens, Some(&presented)).expect("serve");
|
||||
|
||||
println!("\nroute: {}", resp.route.justification);
|
||||
for c in &resp.route.candidates {
|
||||
|
|
|
|||
125
mesh/mesh-rs/crates/mesh-core/src/journal.rs
Normal file
125
mesh/mesh-rs/crates/mesh-core/src/journal.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
//! The steward's decision journal (SPEC §2.2): where a grant's spend at this
|
||||
//! steward survives the process (JCP §4.11).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use jcp_schema::{ContentId, Did};
|
||||
use mesh_schema::{Energy, ServeRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Presentation;
|
||||
|
||||
/// One record in a steward's decision journal.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum JournalEntry {
|
||||
/// A grant was authorised for a serve. Written before the engine runs, so
|
||||
/// a serve the steward was interrupted in is still on record.
|
||||
Authorised {
|
||||
presentation: Presentation,
|
||||
request: ServeRequest,
|
||||
/// The model the chosen backend named, which the grant was decided over.
|
||||
model: Option<ContentId>,
|
||||
/// The backend the serve was dispatched to.
|
||||
backend: String,
|
||||
/// When the grant was decided, unix seconds.
|
||||
now: u64,
|
||||
},
|
||||
/// The serve's metered joules were debited against the grant.
|
||||
Debited { grant_id: ContentId, nonce: [u8; 16], energy: Energy },
|
||||
}
|
||||
|
||||
/// A serve the journal shows authorised and never debited: the steward stopped
|
||||
/// while it was in flight, and whether the engine finished is not knowable from
|
||||
/// the record (JCP §4.11).
|
||||
///
|
||||
/// It stays debited, at the request's joule ceiling, which is what its
|
||||
/// authorisation admitted. It is not retried and not reported as done or as
|
||||
/// failed; the backend that held it is named so an operator can settle it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Unresolved {
|
||||
pub grant_id: ContentId,
|
||||
pub request_id: ContentId,
|
||||
pub caller: Did,
|
||||
pub backend: String,
|
||||
pub debited_uj: u64,
|
||||
}
|
||||
|
||||
/// Where a steward records its grant decisions.
|
||||
pub trait Journal {
|
||||
/// Record one entry durably. An error refuses the serve it belongs to: a
|
||||
/// decision that could not be recorded is not acted on.
|
||||
fn append(&mut self, entry: &JournalEntry) -> io::Result<()>;
|
||||
}
|
||||
|
||||
/// A journal in memory, and the default. It is enough for a steward that never
|
||||
/// restarts; one that can restart MUST use a durable journal such as
|
||||
/// [`FileJournal`] (SPEC §2.2).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MemoryJournal {
|
||||
entries: Vec<JournalEntry>,
|
||||
}
|
||||
|
||||
impl MemoryJournal {
|
||||
/// The entries recorded so far, in order.
|
||||
pub fn entries(&self) -> &[JournalEntry] {
|
||||
&self.entries
|
||||
}
|
||||
}
|
||||
|
||||
impl Journal for MemoryJournal {
|
||||
fn append(&mut self, entry: &JournalEntry) -> io::Result<()> {
|
||||
self.entries.push(entry.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A journal in an append-only file: one JSON entry per line, each synced to
|
||||
/// disk before [`append`](Journal::append) returns.
|
||||
pub struct FileJournal {
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl FileJournal {
|
||||
/// Open the journal at `path`, creating it if absent. Returns it with the
|
||||
/// entries it already holds, for
|
||||
/// [`Mesh::with_journal`](crate::Mesh::with_journal) to replay.
|
||||
///
|
||||
/// A final line with no newline is an append the steward was interrupted
|
||||
/// in. It is dropped and cut from the file, which is safe either way: an
|
||||
/// authorisation that was never recorded never started a serve, and a debit
|
||||
/// that was never recorded leaves its serve authorised and undebited, which
|
||||
/// resolves as [`Unresolved`]. Any other line that does not parse is an
|
||||
/// error, because a journal that cannot be read cannot re-establish spend.
|
||||
pub fn open(path: impl AsRef<Path>) -> io::Result<(Self, Vec<JournalEntry>)> {
|
||||
let mut file = OpenOptions::new().read(true).append(true).create(true).open(path)?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
let complete = bytes.iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1);
|
||||
if complete < bytes.len() {
|
||||
file.set_len(complete as u64)?;
|
||||
file.sync_data()?;
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
for (n, line) in bytes[..complete].split(|&b| b == b'\n').enumerate() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry = serde_json::from_slice(line).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("journal line {}: {e}", n + 1))
|
||||
})?;
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok((FileJournal { file }, entries))
|
||||
}
|
||||
}
|
||||
|
||||
impl Journal for FileJournal {
|
||||
fn append(&mut self, entry: &JournalEntry) -> io::Result<()> {
|
||||
let mut line = serde_json::to_vec(entry)?;
|
||||
line.push(b'\n');
|
||||
self.file.write_all(&line)?;
|
||||
self.file.sync_data()
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,14 @@
|
|||
//! [`Mesh`] ties the four moving parts together for one metered inference:
|
||||
//!
|
||||
//! 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.
|
||||
//! grant, a backend whose model the grant does not permit is inadmissible.
|
||||
//! 2. **Authorise** — check the caller signed the [`Presentation`] and is the
|
||||
//! grant's issuer, then decide the grant's chain in JCP's two-phase metered
|
||||
//! path (`jcp-runtime`) as its holder, this steward, over a frame naming
|
||||
//! `infer` on this exact request and the chosen backend's model: every
|
||||
//! link's signature, attenuation, expiry and cost-independent caveats, and
|
||||
//! whether what the grant has left covers the request ceiling. The decision
|
||||
//! is written to the [`Journal`] 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, and
|
||||
//! debit the grant with it, where a per-call cost caveat binds.
|
||||
|
|
@ -29,11 +30,17 @@
|
|||
))]
|
||||
compile_error!("a hw-meter-* feature must enable mesh-core's `hw-meter` feature");
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use jcp_runtime::Runtime;
|
||||
use jcp_schema::{Caveat, ContentId, DenyReason, Frame, FrameKind, SignedGrant, Trust, Verdict};
|
||||
use jcp_schema::{Caveat, ContentId, DenyReason, Did, Frame, FrameKind, SignedGrant, Trust, Verdict};
|
||||
|
||||
mod journal;
|
||||
mod presentation;
|
||||
|
||||
pub use journal::{FileJournal, Journal, JournalEntry, MemoryJournal, Unresolved};
|
||||
pub use presentation::Presentation;
|
||||
use mesh_engine::ServingEngine;
|
||||
use mesh_meter::{Meter, Session};
|
||||
use mesh_receipt::{MeshReceipt, ReceiptError, ReceiptLog};
|
||||
|
|
@ -62,9 +69,28 @@ pub enum MeshError {
|
|||
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.
|
||||
/// a chain that is broken or amplifies, or a flow ceiling that Mesh
|
||||
/// computes no provenance for.
|
||||
#[error("grant denied: {}", .0.label())]
|
||||
GrantDenied(DenyReason),
|
||||
/// The grant is held by another steward: its subject is not this one's key.
|
||||
#[error("the grant is held by another steward, not this one")]
|
||||
NotThisSteward,
|
||||
/// The presentation was not signed by the grant's issuer, or not for this
|
||||
/// request.
|
||||
#[error("caller not authenticated: {0}")]
|
||||
CallerNotAuthenticated(String),
|
||||
/// This presentation was already spent at this steward.
|
||||
#[error("this presentation was already spent at this steward")]
|
||||
Replayed,
|
||||
/// The decision could not be written to the journal, so it was not acted
|
||||
/// on; or, after the work, its debit could not be.
|
||||
#[error("journal: {0}")]
|
||||
Journal(String),
|
||||
/// A journal being replayed holds an entry the gate would not have
|
||||
/// allowed, or a debit for a serve it never authorised.
|
||||
#[error("journal entry {entry} is inconsistent: {reason}")]
|
||||
JournalInconsistent { entry: usize, reason: String },
|
||||
/// 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}")]
|
||||
|
|
@ -116,24 +142,103 @@ pub struct Mesh<'m> {
|
|||
/// caveat and the grant's cumulative spend therefore bind at Mesh as they
|
||||
/// do at any other JCP enforcement point.
|
||||
grants: Runtime,
|
||||
/// The steward's identity as a grant holder: the subject a grant must name
|
||||
/// to be spent here.
|
||||
steward_did: Did,
|
||||
/// Where each grant decision is recorded before its serve runs.
|
||||
journal: Box<dyn Journal>,
|
||||
/// Presentations already spent here, by leaf and nonce.
|
||||
spent_presentations: HashSet<(ContentId, [u8; 16])>,
|
||||
/// Serves a replayed journal showed in flight when the steward stopped.
|
||||
unresolved: Vec<Unresolved>,
|
||||
/// Unix seconds, for expiry and time caveats.
|
||||
clock: fn() -> u64,
|
||||
}
|
||||
|
||||
impl<'m> Mesh<'m> {
|
||||
/// Build a mesh that meters with `meter` and seals receipts under `steward`
|
||||
/// (the COSE `kid`) using `steward_sk`.
|
||||
/// (the COSE `kid`) using `steward_sk`. The steward holds grants under that
|
||||
/// key's identity, and records its grant decisions in memory until
|
||||
/// [`with_journal`](Self::with_journal) gives it a durable journal.
|
||||
pub fn new(meter: &'m dyn Meter, steward_sk: SigningKey, steward: impl Into<String>) -> Self {
|
||||
let steward_did = Did::from_verifying_key(&steward_sk.verifying_key());
|
||||
Self {
|
||||
router: Router::new(),
|
||||
engines: HashMap::new(),
|
||||
meter,
|
||||
log: ReceiptLog::new(steward_sk, steward),
|
||||
grants: Runtime::new(),
|
||||
steward_did,
|
||||
journal: Box::new(MemoryJournal::default()),
|
||||
spent_presentations: HashSet::new(),
|
||||
unresolved: Vec::new(),
|
||||
clock: system_clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record grant decisions in `journal`, first re-establishing the spend
|
||||
/// from `recorded`, the entries it already holds, by replaying each
|
||||
/// decision through the gate (SPEC §2.2, JCP §4.11). Call it on a new mesh,
|
||||
/// before serving.
|
||||
///
|
||||
/// A replayed authorisation must still verify and be allowed, and a debit
|
||||
/// must belong to a serve the journal authorised; otherwise the journal is
|
||||
/// inconsistent and the mesh is not built. A serve authorised and never
|
||||
/// debited was in flight when the steward stopped: it is debited at its
|
||||
/// request's ceiling and listed in [`unresolved`](Self::unresolved).
|
||||
pub fn with_journal(mut self, journal: impl Journal + 'static, recorded: &[JournalEntry]) -> Result<Self, MeshError> {
|
||||
let inconsistent = |entry: usize, reason: String| MeshError::JournalInconsistent { entry, reason };
|
||||
let mut open: Vec<(&Presentation, &ServeRequest, &str)> = Vec::new();
|
||||
for (i, entry) in recorded.iter().enumerate() {
|
||||
match entry {
|
||||
JournalEntry::Authorised { presentation, request, model, backend, now } => {
|
||||
presentation.verify(request).map_err(|r| inconsistent(i, r))?;
|
||||
if self.spent_presentations.contains(&presentation.spend_key()) {
|
||||
return Err(inconsistent(i, "the presentation was spent twice".into()));
|
||||
}
|
||||
self.authorize(request, presentation, *model, *now)
|
||||
.map_err(|e| inconsistent(i, e.to_string()))?;
|
||||
self.spent_presentations.insert(presentation.spend_key());
|
||||
open.push((presentation, request, backend));
|
||||
}
|
||||
JournalEntry::Debited { grant_id, nonce, energy } => {
|
||||
let at = open
|
||||
.iter()
|
||||
.position(|(p, ..)| p.spend_key() == (*grant_id, *nonce))
|
||||
.ok_or_else(|| inconsistent(i, "a debit for a serve the journal never authorised".into()))?;
|
||||
let (p, ..) = open.remove(at);
|
||||
self.grants.charge_chained(&p.chain, jcp_energy(energy));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (p, request, backend) in open {
|
||||
let ceiling = request.joule_ceiling_uj;
|
||||
self.grants.charge_chained(&p.chain, jcp_schema::Energy::estimator(ceiling));
|
||||
self.unresolved.push(Unresolved {
|
||||
grant_id: p.spend_key().0,
|
||||
request_id: request.id(),
|
||||
caller: p.caller,
|
||||
backend: backend.to_string(),
|
||||
debited_uj: ceiling,
|
||||
});
|
||||
}
|
||||
self.journal = Box::new(journal);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Serves a replayed journal showed in flight when the steward stopped
|
||||
/// (JCP §4.11): each stays debited at its request's ceiling, and none is
|
||||
/// retried.
|
||||
pub fn unresolved(&self) -> &[Unresolved] {
|
||||
&self.unresolved
|
||||
}
|
||||
|
||||
/// The steward's identity as a grant holder: the subject a grant must name
|
||||
/// to be spent here.
|
||||
pub fn steward_did(&self) -> Did {
|
||||
self.steward_did
|
||||
}
|
||||
|
||||
/// Decide grants against `clock` (unix seconds) instead of the system
|
||||
/// clock.
|
||||
pub fn with_clock(mut self, clock: fn() -> u64) -> Self {
|
||||
|
|
@ -168,35 +273,29 @@ impl<'m> Mesh<'m> {
|
|||
self.log.head()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Decide a presented grant for `req`, as its holder, over the frame that
|
||||
/// names the model the chosen backend runs. Returns the leaf's id, to
|
||||
/// record on the receipt. Debits nothing. The presentation has already
|
||||
/// been verified.
|
||||
fn authorize(
|
||||
&mut self,
|
||||
req: &ServeRequest,
|
||||
grant: Option<&SignedGrant>,
|
||||
p: &Presentation,
|
||||
model: Option<ContentId>,
|
||||
) -> Result<Option<ContentId>, MeshError> {
|
||||
let Some(sg) = grant else {
|
||||
return Ok(None);
|
||||
};
|
||||
// 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)(),
|
||||
);
|
||||
now: u64,
|
||||
) -> Result<ContentId, MeshError> {
|
||||
let leaf = p.leaf().ok_or_else(|| MeshError::GrantInvalid("empty chain".into()))?;
|
||||
let d = self.grants.authorize_chained(&infer_frame(leaf, req, model), &p.chain, self.steward_did, now);
|
||||
match d.verdict {
|
||||
Verdict::Allow => {}
|
||||
Verdict::Deny(DenyReason::BadSignature) => {
|
||||
let why = jcp_grant::verify(sg).err().map(|e| e.to_string());
|
||||
let why = jcp_grant::verify_chain(&p.chain).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::WrongSubject) => return Err(MeshError::NotThisSteward),
|
||||
Verdict::Deny(DenyReason::BudgetExceeded) => {
|
||||
return Err(MeshError::BudgetTooLow { budget_uj: 0, ceiling_uj: req.joule_ceiling_uj });
|
||||
}
|
||||
|
|
@ -208,7 +307,7 @@ impl<'m> Mesh<'m> {
|
|||
ceiling_uj: req.joule_ceiling_uj,
|
||||
});
|
||||
}
|
||||
Ok(Some(sg.grant.id()))
|
||||
Ok(leaf.grant.id())
|
||||
}
|
||||
|
||||
/// Serve one metered inference. See the module docs for the four phases.
|
||||
|
|
@ -221,19 +320,29 @@ impl<'m> Mesh<'m> {
|
|||
&mut self,
|
||||
req: &ServeRequest,
|
||||
prompt: &[u32],
|
||||
grant: Option<&SignedGrant>,
|
||||
grant: Option<&Presentation>,
|
||||
) -> Result<ServeResponse, MeshError> {
|
||||
// 0. The caller must be the grant's issuer and have signed this
|
||||
// presentation for this request, and the presentation must not have
|
||||
// been spent here before.
|
||||
if let Some(p) = grant {
|
||||
p.verify(req).map_err(MeshError::CallerNotAuthenticated)?;
|
||||
if self.spent_presentations.contains(&p.spend_key()) {
|
||||
return Err(MeshError::Replayed);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
Some(p) => {
|
||||
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)) {
|
||||
if model_permitted(p, req, model) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(match model {
|
||||
|
|
@ -250,8 +359,27 @@ impl<'m> Mesh<'m> {
|
|||
.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)?;
|
||||
// 2. Authorise, and record the decision, before any work runs: a
|
||||
// decision recorded after the work would lose its debit if the
|
||||
// steward stopped in between (JCP §4.11).
|
||||
let grant_id = match grant {
|
||||
None => None,
|
||||
Some(p) => {
|
||||
let now = (self.clock)();
|
||||
let id = self.authorize(req, p, model, now)?;
|
||||
self.journal
|
||||
.append(&JournalEntry::Authorised {
|
||||
presentation: p.clone(),
|
||||
request: req.clone(),
|
||||
model,
|
||||
backend: backend.0.clone(),
|
||||
now,
|
||||
})
|
||||
.map_err(|e| MeshError::Journal(e.to_string()))?;
|
||||
self.spent_presentations.insert(p.spend_key());
|
||||
Some(id)
|
||||
}
|
||||
};
|
||||
let chosen_fidelity = route
|
||||
.chosen_fidelity
|
||||
.expect("a chosen route carries its fidelity");
|
||||
|
|
@ -274,11 +402,18 @@ impl<'m> Mesh<'m> {
|
|||
// 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,
|
||||
Some(p) => {
|
||||
let d = self.grants.charge_chained(&p.chain, jcp_energy(&energy));
|
||||
let (grant_id, nonce) = p.spend_key();
|
||||
self.journal
|
||||
.append(&JournalEntry::Debited { grant_id, nonce, energy })
|
||||
.map_err(|e| MeshError::Journal(e.to_string()))?;
|
||||
match d.verdict {
|
||||
Verdict::Allow => None,
|
||||
Verdict::Deny(r) => Some(r.label().to_string()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 3b. The engine must have run the weights it named before serving:
|
||||
|
|
@ -382,15 +517,17 @@ fn infer_frame(sg: &SignedGrant, req: &ServeRequest, model: Option<ContentId>) -
|
|||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Whether every link's model caveats admit `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(p: &Presentation, req: &ServeRequest, model: Option<ContentId>) -> bool {
|
||||
let Some(leaf) = p.leaf() else { return false };
|
||||
let frame = infer_frame(leaf, req, model);
|
||||
p.chain
|
||||
.iter()
|
||||
.flat_map(|sg| &sg.grant.caveats)
|
||||
.filter(|c| matches!(c, Caveat::ModelIn(_)))
|
||||
.all(|c| c.holds(frame, sg.grant.subject, 0, 0))
|
||||
.all(|c| c.holds(&frame, leaf.grant.subject, 0, 0))
|
||||
}
|
||||
|
||||
/// The metered span as JCP's energy type, for the debit. Only the debited
|
||||
|
|
@ -460,13 +597,26 @@ mod tests {
|
|||
m
|
||||
}
|
||||
|
||||
/// A grant over exactly `req`, held by a fixed subject.
|
||||
/// The key that issues test grants and presents them: the caller.
|
||||
fn caller_key() -> SigningKey {
|
||||
SigningKey::from_bytes(&[42u8; 32])
|
||||
}
|
||||
|
||||
fn key_did(k: &SigningKey) -> Did {
|
||||
Did::from_verifying_key(&k.verifying_key())
|
||||
}
|
||||
|
||||
/// A root grant over exactly `req`, issued by the caller and held by the
|
||||
/// steward of [`steward_key`].
|
||||
fn grant_over(req: &ServeRequest, budget_uj: u64, expiry: u64, caveats: Vec<Caveat>) -> SignedGrant {
|
||||
let issuer = SigningKey::from_bytes(&[42u8; 32]);
|
||||
let holder = SigningKey::from_bytes(&[43u8; 32]);
|
||||
grant_to(key_did(&steward_key().0), req, budget_uj, expiry, caveats)
|
||||
}
|
||||
|
||||
fn grant_to(holder: Did, req: &ServeRequest, budget_uj: u64, expiry: u64, caveats: Vec<Caveat>) -> SignedGrant {
|
||||
let issuer = caller_key();
|
||||
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()),
|
||||
issuer: key_did(&issuer),
|
||||
subject: holder,
|
||||
capability: jcp_schema::Capability::new(req.id(), [INFER_ACTION]),
|
||||
budget_uj,
|
||||
budget_funds: None,
|
||||
|
|
@ -479,6 +629,20 @@ mod tests {
|
|||
jcp_grant::issue(&issuer, g).expect("issue grant")
|
||||
}
|
||||
|
||||
/// `sg` presented by the caller for `req`, under a nonce no other
|
||||
/// presentation in the test process uses.
|
||||
fn present(sg: &SignedGrant, req: &ServeRequest) -> Presentation {
|
||||
present_chain(vec![sg.clone()], req)
|
||||
}
|
||||
|
||||
fn present_chain(chain: Vec<SignedGrant>, req: &ServeRequest) -> Presentation {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut nonce = [0u8; 16];
|
||||
nonce[..8].copy_from_slice(&n.to_be_bytes());
|
||||
Presentation::sign(chain, req, &caller_key(), nonce)
|
||||
}
|
||||
|
||||
fn at_1000() -> u64 {
|
||||
1_000
|
||||
}
|
||||
|
|
@ -502,7 +666,7 @@ mod tests {
|
|||
),
|
||||
];
|
||||
for (sg, reason) in &refused {
|
||||
match mesh.serve(&req, &[], Some(sg)) {
|
||||
match mesh.serve(&req, &[], Some(&present(sg, &req))) {
|
||||
Err(MeshError::GrantDenied(r)) => assert_eq!(r, *reason),
|
||||
other => panic!("expected {reason:?}, got {other:?}"),
|
||||
}
|
||||
|
|
@ -511,7 +675,7 @@ mod tests {
|
|||
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");
|
||||
let resp = mesh.serve(&req, &[], Some(&present(&fine, &req))).expect("serve");
|
||||
assert_eq!(resp.receipt.grant_id, Some(fine.grant.id()));
|
||||
assert_eq!(mesh.spent(&fine.grant.id()), 1_000);
|
||||
}
|
||||
|
|
@ -525,12 +689,12 @@ mod tests {
|
|||
|
||||
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(_))));
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&present(&forged, &req))), 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)));
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&present(&elsewhere, &req))), Err(MeshError::Unauthorized)));
|
||||
let starved = grant_over(&req, 1, 0, vec![]);
|
||||
assert!(matches!(
|
||||
mesh.serve(&req, &[], Some(&starved)),
|
||||
mesh.serve(&req, &[], Some(&present(&starved, &req))),
|
||||
Err(MeshError::BudgetTooLow { budget_uj: 1, ceiling_uj: 10_000_000 })
|
||||
));
|
||||
assert_eq!(mesh.head(), None);
|
||||
|
|
@ -547,12 +711,12 @@ mod tests {
|
|||
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");
|
||||
mesh.serve(&req, &[], Some(&present(&sg, &req))).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)),
|
||||
mesh.serve(&req, &[], Some(&present(&sg, &req))),
|
||||
Err(MeshError::BudgetTooLow { budget_uj: 1_000_000, ceiling_uj: 4_000_000 })
|
||||
));
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 9_000_000);
|
||||
|
|
@ -584,10 +748,10 @@ mod tests {
|
|||
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"));
|
||||
assert_eq!(mesh.serve(&req, &[], Some(&present(&open, &req))).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");
|
||||
let resp = mesh.serve(&req, &[], Some(&present(&only_b, &req))).expect("serve");
|
||||
assert_eq!(resp.receipt.backend, BackendId::new("frontier"));
|
||||
assert_eq!(resp.receipt.weights_digest, Some(b));
|
||||
let reason = |id: &str| {
|
||||
|
|
@ -600,7 +764,7 @@ mod tests {
|
|||
|
||||
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!(matches!(mesh.serve(&req, &[], Some(&present(&absent, &req))), Err(MeshError::NoRoute(_))));
|
||||
assert_eq!(mesh.head(), head);
|
||||
assert_eq!(mesh.spent(&absent.grant.id()), 0);
|
||||
}
|
||||
|
|
@ -649,7 +813,7 @@ mod tests {
|
|||
);
|
||||
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!(matches!(mesh.serve(&req, &[], Some(&present(&sg, &req))), Err(MeshError::ModelMismatch { .. })));
|
||||
assert_eq!(mesh.head(), None);
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 2_000);
|
||||
}
|
||||
|
|
@ -663,18 +827,215 @@ mod tests {
|
|||
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");
|
||||
let resp = mesh.serve(&req, &[], Some(&present(&capped, &req))).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");
|
||||
let ok = mesh.serve(&req, &[], Some(&present(&roomy, &req))).expect("serve");
|
||||
assert_eq!(ok.receipt.grant_breach, None);
|
||||
assert!(ok.receipt.to_jcr1_wire(mesh.steward()).get("grant_breach").is_none());
|
||||
}
|
||||
|
||||
/// A grant spends only at the steward it names as holder, so the same
|
||||
/// grant presented to another steward is refused there.
|
||||
#[test]
|
||||
fn a_grant_held_by_another_steward_is_refused() {
|
||||
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 elsewhere = grant_to(key_did(&SigningKey::from_bytes(&[12u8; 32])), &req, 20_000_000, 0, vec![]);
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&present(&elsewhere, &req))), Err(MeshError::NotThisSteward)));
|
||||
assert_eq!(mesh.head(), None);
|
||||
}
|
||||
|
||||
/// Only the grant's issuer can spend it, and only on the request it signed
|
||||
/// for. A grant is a public object, so holding one is not enough.
|
||||
#[test]
|
||||
fn the_caller_must_be_the_grants_issuer() {
|
||||
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 sg = grant_over(&req, 20_000_000, 0, vec![]);
|
||||
let thief = SigningKey::from_bytes(&[66u8; 32]);
|
||||
|
||||
let own = Presentation::sign(vec![sg.clone()], &req, &thief, [1; 16]);
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&own)), Err(MeshError::CallerNotAuthenticated(_))));
|
||||
let mut claimed = Presentation::sign(vec![sg.clone()], &req, &thief, [2; 16]);
|
||||
claimed.caller = key_did(&caller_key());
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&claimed)), Err(MeshError::CallerNotAuthenticated(_))));
|
||||
let misdirected = present(&sg, &request(9, Fidelity::F1, 10_000_000));
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&misdirected)), Err(MeshError::CallerNotAuthenticated(_))));
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 0);
|
||||
|
||||
mesh.serve(&req, &[], Some(&present(&sg, &req))).expect("the issuer's own presentation");
|
||||
}
|
||||
|
||||
/// A presentation spends once at a steward: a captured one replayed there
|
||||
/// is refused, and debits nothing.
|
||||
#[test]
|
||||
fn a_presentation_is_spent_once() {
|
||||
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 sg = grant_over(&req, 50_000_000, 0, vec![]);
|
||||
let p = present(&sg, &req);
|
||||
mesh.serve(&req, &[], Some(&p)).expect("serve");
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&p)), Err(MeshError::Replayed)));
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 1_000);
|
||||
mesh.serve(&req, &[], Some(&present(&sg, &req))).expect("a new presentation is a new spend");
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 2_000);
|
||||
}
|
||||
|
||||
/// One budget across two stewards: the holder shards it (JCP §4.5), each
|
||||
/// shard held by one steward, and each spends only at its holder. Shards
|
||||
/// cannot sum past their parent, which bounds the total without the
|
||||
/// stewards coordinating.
|
||||
#[test]
|
||||
fn shards_spread_one_budget_across_stewards() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let (sk_a, _) = steward_key();
|
||||
let sk_b = SigningKey::from_bytes(&[12u8; 32]);
|
||||
let mut a = mesh_with(&meter);
|
||||
let mut b = Mesh::new(&meter, sk_b.clone(), "did:example:mesh-steward-b");
|
||||
b.register(
|
||||
BackendSpec::new("tiny", "ssm-1b", Fidelity::F1, 50_000),
|
||||
Box::new(ReferenceEngine::new("tiny", "ssm-1b", Fidelity::F1, 50_000, 64)),
|
||||
);
|
||||
let req = request(8, Fidelity::F1, 1_000_000);
|
||||
let root = grant_to(key_did(&caller_key()), &req, 10_000_000, 0, vec![]);
|
||||
let slices = |b_uj| [(key_did(&sk_a), 6_000_000), (key_did(&sk_b), b_uj)];
|
||||
assert!(jcp_grant::shard(&root, &caller_key(), &slices(5_000_000), 0, [8; 16]).is_err());
|
||||
let shards = jcp_grant::shard(&root, &caller_key(), &slices(4_000_000), 0, [8; 16]).unwrap();
|
||||
let for_a = vec![root.clone(), shards[0].clone()];
|
||||
let for_b = vec![root.clone(), shards[1].clone()];
|
||||
|
||||
a.serve(&req, &[], Some(&present_chain(for_a.clone(), &req))).expect("A spends its shard");
|
||||
b.serve(&req, &[], Some(&present_chain(for_b.clone(), &req))).expect("B spends its shard");
|
||||
assert!(matches!(b.serve(&req, &[], Some(&present_chain(for_a, &req))), Err(MeshError::NotThisSteward)));
|
||||
assert!(matches!(a.serve(&req, &[], Some(&present_chain(for_b, &req))), Err(MeshError::NotThisSteward)));
|
||||
}
|
||||
|
||||
/// A journal whose entries the test reads after the mesh is gone.
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedJournal(std::rc::Rc<std::cell::RefCell<Vec<JournalEntry>>>);
|
||||
|
||||
impl Journal for SharedJournal {
|
||||
fn append(&mut self, entry: &JournalEntry) -> std::io::Result<()> {
|
||||
self.0.borrow_mut().push(entry.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A restart is not a fresh budget. The steward replays its journal
|
||||
/// through the gate before serving, so the grant has what it had left, and
|
||||
/// a presentation spent before the restart is still spent.
|
||||
#[test]
|
||||
fn a_restart_resumes_the_spend_from_the_journal() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 3_000_000);
|
||||
let req = request(8, Fidelity::F1, 4_000_000);
|
||||
let sg = grant_over(&req, 10_000_000, 0, vec![]);
|
||||
let journal = SharedJournal::default();
|
||||
let first = present(&sg, &req);
|
||||
{
|
||||
let mut mesh = mesh_with(&meter).with_journal(journal.clone(), &[]).unwrap();
|
||||
mesh.serve(&req, &[], Some(&first)).unwrap();
|
||||
mesh.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
|
||||
}
|
||||
let recorded = journal.0.borrow().clone();
|
||||
assert_eq!(recorded.len(), 4, "two authorisations and two debits");
|
||||
|
||||
let mut mesh = mesh_with(&meter).with_journal(journal.clone(), &recorded).unwrap();
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 6_000_000, "the restart gave no budget back");
|
||||
assert!(mesh.unresolved().is_empty());
|
||||
assert!(matches!(mesh.serve(&req, &[], Some(&first)), Err(MeshError::Replayed)));
|
||||
mesh.serve(&req, &[], Some(&present(&sg, &req))).expect("4 J left covers the 4 J ceiling");
|
||||
assert!(matches!(
|
||||
mesh.serve(&req, &[], Some(&present(&sg, &req))),
|
||||
Err(MeshError::BudgetTooLow { budget_uj: 1_000_000, .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// A serve authorised and never debited was in flight when the steward
|
||||
/// stopped. After the restart it stays debited at its ceiling, is not
|
||||
/// retried, and is listed with the backend that held it.
|
||||
#[test]
|
||||
fn an_in_flight_serve_is_debited_and_unresolved_after_a_restart() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let req = request(8, Fidelity::F1, 4_000_000);
|
||||
let sg = grant_over(&req, 10_000_000, 0, vec![]);
|
||||
let journal = SharedJournal::default();
|
||||
let mut before = mesh_with(&meter).with_journal(journal.clone(), &[]).unwrap();
|
||||
before.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
|
||||
drop(before);
|
||||
let recorded = vec![journal.0.borrow()[0].clone()];
|
||||
|
||||
let mesh = mesh_with(&meter).with_journal(MemoryJournal::default(), &recorded).unwrap();
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 4_000_000);
|
||||
let [u] = mesh.unresolved() else { panic!("one unresolved serve, got {:?}", mesh.unresolved()) };
|
||||
assert_eq!((u.grant_id, u.request_id, u.debited_uj), (sg.grant.id(), req.id(), 4_000_000));
|
||||
assert_eq!((u.backend.as_str(), u.caller), ("tiny", key_did(&caller_key())));
|
||||
}
|
||||
|
||||
/// A journal is replayed through the gate, not trusted. An entry the gate
|
||||
/// would not have allowed, a debit with no authorisation, or a presentation
|
||||
/// spent twice each refuse to build the mesh.
|
||||
#[test]
|
||||
fn a_journal_the_gate_would_not_have_produced_is_refused() {
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let req = request(8, Fidelity::F1, 4_000_000);
|
||||
let sg = grant_over(&req, 10_000_000, 0, vec![]);
|
||||
let journal = SharedJournal::default();
|
||||
let mut before = mesh_with(&meter).with_journal(journal.clone(), &[]).unwrap();
|
||||
before.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
|
||||
drop(before);
|
||||
let recorded = journal.0.borrow().clone();
|
||||
let resume = |entries: &[JournalEntry]| mesh_with(&meter).with_journal(MemoryJournal::default(), entries).err();
|
||||
|
||||
let mut forged = recorded.clone();
|
||||
if let JournalEntry::Authorised { presentation, .. } = &mut forged[0] {
|
||||
presentation.nonce[15] ^= 1;
|
||||
}
|
||||
assert!(matches!(resume(&forged), Some(MeshError::JournalInconsistent { entry: 0, .. })));
|
||||
assert!(matches!(resume(&recorded[1..]), Some(MeshError::JournalInconsistent { entry: 0, .. })));
|
||||
let twice = [recorded[0].clone(), recorded[1].clone(), recorded[0].clone()];
|
||||
assert!(matches!(resume(&twice), Some(MeshError::JournalInconsistent { entry: 2, .. })));
|
||||
assert!(resume(&recorded).is_none());
|
||||
}
|
||||
|
||||
/// The file journal round-trips, and an append a crash cut short is
|
||||
/// dropped and cut from the file rather than failing the restart.
|
||||
#[test]
|
||||
fn the_file_journal_survives_a_torn_final_line() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join(format!("mesh-journal-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("journal.jsonl");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
|
||||
let req = request(8, Fidelity::F1, 4_000_000);
|
||||
let sg = grant_over(&req, 10_000_000, 0, vec![]);
|
||||
{
|
||||
let (journal, recorded) = FileJournal::open(&path).unwrap();
|
||||
assert!(recorded.is_empty());
|
||||
let mut mesh = mesh_with(&meter).with_journal(journal, &recorded).unwrap();
|
||||
mesh.serve(&req, &[], Some(&present(&sg, &req))).unwrap();
|
||||
}
|
||||
let mut file = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
file.write_all(br#"{"Authorised":{"presentation":{"cha"#).unwrap();
|
||||
drop(file);
|
||||
|
||||
let (journal, recorded) = FileJournal::open(&path).unwrap();
|
||||
assert_eq!(recorded.len(), 2);
|
||||
assert!(std::fs::read(&path).unwrap().ends_with(b"\n"), "the torn line was cut");
|
||||
let mesh = mesh_with(&meter).with_journal(journal, &recorded).unwrap();
|
||||
assert_eq!(mesh.spent(&sg.grant.id()), 1_000);
|
||||
assert!(mesh.unresolved().is_empty());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_end_to_end_seals_and_verifies() {
|
||||
// A HwShunt stepping meter yields a deterministic 3_200_000 µJ span,
|
||||
|
|
|
|||
86
mesh/mesh-rs/crates/mesh-core/src/presentation.rs
Normal file
86
mesh/mesh-rs/crates/mesh-core/src/presentation.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! A grant as a caller presents it to a steward (SPEC §2.1).
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier};
|
||||
use jcp_schema::{canonical_cbor, ContentId, Did, SignedGrant};
|
||||
use mesh_schema::ServeRequest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A grant as a caller presents it to a steward (SPEC §2.1): the delegation
|
||||
/// chain whose leaf the steward holds, and the caller's signature over the
|
||||
/// request it is being spent on.
|
||||
///
|
||||
/// The caller is the leaf's issuer, the party that authorised the steward to
|
||||
/// spend on its behalf. A chain is a public object, so holding one is not
|
||||
/// enough to spend it; the signature is what a party without the issuer's key
|
||||
/// cannot produce. The nonce makes each presentation spendable once at a
|
||||
/// steward, so a captured one cannot be replayed there.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Presentation {
|
||||
/// The delegation chain, root first, leaf last (JCP §4.2). A grant issued
|
||||
/// directly to the steward is a chain of one.
|
||||
pub chain: Vec<SignedGrant>,
|
||||
/// Who presents it. A steward accepts it only from the leaf's issuer.
|
||||
pub caller: Did,
|
||||
/// Distinguishes this presentation from every other the caller makes.
|
||||
pub nonce: [u8; 16],
|
||||
/// Ed25519 by `caller` over the canonical bytes of the request id, the
|
||||
/// leaf's id, the caller and the nonce.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
/// What a presentation's signature covers.
|
||||
#[derive(Serialize)]
|
||||
struct Presented<'a> {
|
||||
request_id: ContentId,
|
||||
grant_id: ContentId,
|
||||
caller: &'a Did,
|
||||
nonce: [u8; 16],
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
/// Present `chain` for `req`, signed with `caller_sk`. A steward accepts it
|
||||
/// only if that key is the leaf's issuer.
|
||||
pub fn sign(chain: Vec<SignedGrant>, req: &ServeRequest, caller_sk: &SigningKey, nonce: [u8; 16]) -> Self {
|
||||
let caller = Did::from_verifying_key(&caller_sk.verifying_key());
|
||||
let mut p = Presentation { chain, caller, nonce, signature: Vec::new() };
|
||||
p.signature = caller_sk.sign(&p.signed_bytes(req)).to_bytes().to_vec();
|
||||
p
|
||||
}
|
||||
|
||||
/// The grant this presentation spends: the chain's leaf.
|
||||
pub fn leaf(&self) -> Option<&SignedGrant> {
|
||||
self.chain.last()
|
||||
}
|
||||
|
||||
/// Check that the caller is the leaf's issuer and signed this presentation
|
||||
/// for `req`. The error says which failed.
|
||||
pub fn verify(&self, req: &ServeRequest) -> Result<(), String> {
|
||||
let leaf = self.leaf().ok_or("the presentation carries no grant")?;
|
||||
if self.caller != leaf.grant.issuer {
|
||||
return Err(format!(
|
||||
"presented by {}, but the grant was issued by {}",
|
||||
self.caller.to_did_key(),
|
||||
leaf.grant.issuer.to_did_key()
|
||||
));
|
||||
}
|
||||
let key = self.caller.verifying_key().map_err(|e| e.to_string())?;
|
||||
let signature = Signature::from_slice(&self.signature).map_err(|e| e.to_string())?;
|
||||
key.verify(&self.signed_bytes(req), &signature)
|
||||
.map_err(|_| "the caller's signature does not verify for this request".to_string())
|
||||
}
|
||||
|
||||
/// What a steward spends this presentation under, once: the leaf and the
|
||||
/// nonce. The caller is the leaf's issuer, so it adds nothing.
|
||||
pub(crate) fn spend_key(&self) -> (ContentId, [u8; 16]) {
|
||||
(self.leaf_id(), self.nonce)
|
||||
}
|
||||
|
||||
fn leaf_id(&self) -> ContentId {
|
||||
self.leaf().map_or(ContentId([0; 32]), |l| l.grant.id())
|
||||
}
|
||||
|
||||
fn signed_bytes(&self, req: &ServeRequest) -> Vec<u8> {
|
||||
canonical_cbor(&Presented { request_id: req.id(), grant_id: self.leaf_id(), caller: &self.caller, nonce: self.nonce })
|
||||
.expect("a presentation's fields are canonical")
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ use std::time::Instant;
|
|||
use ed25519_dalek::SigningKey;
|
||||
use jcp_schema::{Capability, Did, Grant};
|
||||
use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer;
|
||||
use mesh_core::{Mesh, MeshError, INFER_ACTION};
|
||||
use mesh_core::{Mesh, MeshError, Presentation, INFER_ACTION};
|
||||
use mesh_engine_gguf::FalconEngine;
|
||||
#[cfg_attr(feature = "metal", allow(unused_imports))]
|
||||
use mesh_meter::{EstimatorMeter, Meter};
|
||||
|
|
@ -91,6 +91,7 @@ fn main() {
|
|||
let ids0 = tk.encode(prompts[0]);
|
||||
let req0 = request(prompts[0], ids0.len(), n_tokens);
|
||||
let starved = grant_for(&req0, &caller_sk, &steward_sk, 1); // 1 µJ budget
|
||||
let starved = Presentation::sign(vec![starved], &req0, &caller_sk, [0u8; 16]);
|
||||
match mesh.serve(&req0, &ids0, Some(&starved)) {
|
||||
Err(MeshError::BudgetTooLow { budget_uj, ceiling_uj }) => {
|
||||
println!("AUTH: underfunded grant refused — budget {budget_uj} µJ < ceiling {ceiling_uj} µJ (no decode ran)\n");
|
||||
|
|
@ -104,6 +105,7 @@ fn main() {
|
|||
let ids = tk.encode(prompt_text);
|
||||
let req = request(prompt_text, ids.len(), n_tokens);
|
||||
let grant = grant_for(&req, &caller_sk, &steward_sk, req.joule_ceiling_uj);
|
||||
let grant = Presentation::sign(vec![grant], &req, &caller_sk, [0u8; 16]);
|
||||
let t = Instant::now();
|
||||
let resp = mesh.serve(&req, &ids, Some(&grant)).expect("serve");
|
||||
let dt = t.elapsed();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ description = "OpenAI-compatible API ingest for Mesh — translate a /v1/chat/co
|
|||
[dependencies]
|
||||
mesh-schema = { workspace = true }
|
||||
mesh-core = { workspace = true }
|
||||
jcp-schema = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@
|
|||
//! a request omits them this crate makes an explicit ~chars/4 estimate (honestly
|
||||
//! a stand-in until the engine reports the real count).
|
||||
|
||||
use jcp_schema::SignedGrant;
|
||||
use mesh_core::{Mesh, MeshError, ServeResponse};
|
||||
use mesh_core::{Mesh, MeshError, Presentation, ServeResponse};
|
||||
use mesh_schema::{ContentId, Fidelity, ServeRequest};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
|
|
@ -193,7 +192,7 @@ pub trait ChatBackend {
|
|||
///
|
||||
/// 1. [`to_serve_request`] parses the request (with the real token count from
|
||||
/// `backend.encode`).
|
||||
/// 2. `mesh.serve` authorises the optional JCP `grant`, routes cheapest-sufficient,
|
||||
/// 2. `mesh.serve` authorises the optional presented JCP `grant`, routes cheapest-sufficient,
|
||||
/// meters the decode, and seals a `mesh/1` receipt.
|
||||
/// 3. `backend.decode` turns the served tokens into the assistant message.
|
||||
/// 4. The response's `usage.energy` carries the wedge metric, the route, **and the
|
||||
|
|
@ -202,7 +201,7 @@ pub trait ChatBackend {
|
|||
pub fn complete_chat<B: ChatBackend>(
|
||||
mesh: &mut Mesh,
|
||||
backend: &B,
|
||||
grant: Option<&SignedGrant>,
|
||||
grant: Option<&Presentation>,
|
||||
req: &Value,
|
||||
default_ceiling_uj: u64,
|
||||
created: u64,
|
||||
|
|
@ -408,7 +407,7 @@ fn sealed_responses_response(
|
|||
pub fn complete_responses<B: ChatBackend>(
|
||||
mesh: &mut Mesh,
|
||||
backend: &B,
|
||||
grant: Option<&SignedGrant>,
|
||||
grant: Option<&Presentation>,
|
||||
req: &Value,
|
||||
default_ceiling_uj: u64,
|
||||
created: u64,
|
||||
|
|
|
|||
Loading…
Reference in a new issue