mesh/1 + AR-1: receipts commit to the output; deterministic serves replay #378

Merged
dcharlot merged 1 commit from feat/receipts-bind-the-output into main 2026-10-21 04:50:48 -04:00
13 changed files with 565 additions and 16 deletions

View file

@ -73,6 +73,7 @@ ActionReceipt {
// binding 3 — work
work_receipt: ContentId
input_hash: Hash
output_hash: Hash? // a commitment to what the work returned (§2.3)
joules_uj: u64 // the work's cost, graded by energy_provenance
energy_provenance: "HwShunt" | "ModelBased" | "Estimator"
@ -118,6 +119,21 @@ arithmetic.
reading only when `energy_provenance` is `HwShunt`, and a verifier or display
MUST NOT present it as a measurement otherwise.
### 2.3 `output_hash` commits to what came back
Without it a receipt shows what went in (`input_hash`) and what changed in the
world (`effect`), and not what the work returned. It therefore cannot show
which answer it was for. Where the producer binds the output, it seals
`output_hash`. It is omitted when absent, so a receipt sealed before the field
existed keeps its bytes.
A producer SHOULD seal a **commitment** rather than a plain hash wherever the
output can be short. A plain hash of a one-token answer can be reversed by
trying every answer, and a receipt is shown to parties who were not owed the
output. `mesh/1`'s `output_commitment` (mesh SPEC §6.4), which commits under
an opening its steward discloses per receipt, is such a commitment and can be
carried here as it is.
## 3. The three rules
These MUST be enforced at verification, re-derived from the wire projection —

View file

@ -47,6 +47,7 @@ fn base() -> ActionReceipt {
grounding: "hardware".into(),
work_receipt: cid("jcr1/resolution"),
input_hash: "a".repeat(64),
output_hash: None,
joules_uj: 19_538_280,
energy_provenance: "HwShunt".into(),
effect: Effect {

View file

@ -216,6 +216,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
grounding: if is_hw { "hardware" } else { "software" }.into(),
work_receipt: work_id,
input_hash: ContentId::of_bytes(b"input/quarterly-report").to_hex(),
output_hash: None,
joules_uj,
energy_provenance: provenance.into(),
effect: Effect {

View file

@ -92,7 +92,7 @@ fn read_envelope(path: &str) -> Result<Vec<u8>, String> {
let raw = std::fs::read(path).map_err(|e| e.to_string())?;
if let Ok(text) = std::str::from_utf8(&raw) {
let t: String = text.split_whitespace().collect();
if !t.is_empty() && t.len() % 2 == 0 && t.bytes().all(|c| c.is_ascii_hexdigit()) {
if !t.is_empty() && t.len().is_multiple_of(2) && t.bytes().all(|c| c.is_ascii_hexdigit()) {
return hex::decode(&t).map_err(|e| e.to_string());
}
}

View file

@ -201,6 +201,18 @@ pub struct ActionReceipt {
pub work_receipt: ContentId,
/// Hash of the input the work consumed.
pub input_hash: String,
/// A commitment to the output the work produced, where the producer binds
/// one. Without it the receipt shows what went in and what changed in the
/// world, and not what the work returned, so it cannot show which answer
/// it was for.
///
/// A producer SHOULD seal a commitment rather than a plain hash where the
/// output can be short. A hash of a one-token answer can be reversed by
/// trying every answer, and a receipt is shown to parties who were not owed
/// the output. `mesh/1`'s `output_commitment` is one such commitment, and
/// can be carried here as it is. Omitted from the wire when `None`, so every
/// receipt sealed before the field existed keeps its bytes.
pub output_hash: Option<String>,
/// The work's energy cost in microjoules. Whether it is a hardware
/// reading, a model or an estimate is not this field's to say: it is
/// graded by [`energy_provenance`](Self::energy_provenance), and is a
@ -263,6 +275,9 @@ impl ActionReceipt {
o.insert("work_receipt".into(), json!(self.work_receipt.to_hex()));
o.insert("input_hash".into(), json!(self.input_hash));
if let Some(h) = &self.output_hash {
o.insert("output_hash".into(), json!(h));
}
o.insert("joules_uj".into(), json!(self.joules_uj));
o.insert("energy_provenance".into(), json!(self.energy_provenance));
@ -408,6 +423,7 @@ mod tests {
grounding: "hardware".into(),
work_receipt: cid(6),
input_hash: "a".repeat(64),
output_hash: None,
joules_uj: 19_538_280,
energy_provenance: "HwShunt".into(),
effect: Effect {
@ -564,4 +580,21 @@ mod tests {
assert_eq!(v.wire["energy_provenance"], prov);
}
}
/// The output commitment is sealed when the producer binds one, read back
/// by the verifier, and absent otherwise, so a receipt without it keeps
/// the bytes it sealed to before the field existed.
#[test]
fn an_output_commitment_is_sealed_when_bound_and_absent_otherwise() {
let sk = key();
let bare = base();
assert!(!bare.to_wire().as_object().unwrap().contains_key("output_hash"));
let mut bound = base();
bound.output_hash = Some("b".repeat(64));
let env = bound.seal(&sk).unwrap();
let v = verify(&env, &sk.verifying_key(), &bound.steward).unwrap();
assert_eq!(v.wire["output_hash"], "b".repeat(64).as_str());
assert_ne!(bound.id().unwrap(), bare.id().unwrap(), "the output is in the receipt's identity");
}
}

View file

@ -434,9 +434,10 @@ drops to a cheaper path — reports what it served. Omitting it is conformant an
means `fidelity_met` rests on the advertisement (§3.2.1).
The input token payload travels **with** the serve call, alongside the request —
it is never part of the request's sealed identity and never enters the receipt,
which records only the content-addressed `prompt_hash` (§3.1). A receipt is
therefore safe to publish without disclosing the prompt.
it is never part of the request's sealed identity and never enters the receipt
in the clear. The receipt records the content-addressed `prompt_hash` (§3.1),
and the payload's digest only inside the output commitment, under its opening
(§6.4). A receipt is therefore safe to publish without disclosing the prompt.
`tokens_accepted` is the denominator of the wedge metric. For greedy decode it
equals `tokens_out`; under speculative decoding it is the number of proposed
@ -445,6 +446,11 @@ prefix/result cache (energy saved). An engine MUST do real work proportional to
the tokens it serves so the surrounding meter reads a real span; it MUST NOT
self-report energy.
An engine declares whether it is **deterministic**: whether serving the same
request and prompt payload again yields the same tokens. Only a deterministic
engine's serve can be re-executed against its receipt (§6.4). An engine that
samples MUST NOT declare it, and the default is not to.
The reference engine (`mesh-engine::ReferenceEngine`) is deterministic and
weights-free, for conformance. Production backends — a quantized device-resident
decoder, a high-throughput batching server — implement the same interface and
@ -485,6 +491,7 @@ and nulls; booleans are encoded as `0|1` uints and absent options dropped.
| `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) |
| `output_commitment` | hex commitment to the output tokens of a generation and the prompt payload they were served on (§6.4; absent for a typed decision, whose answer the engine interface does not carry, and in receipts sealed before the field existed) |
| `sequence`, `prev` | hash-chain position and prior envelope's content id |
| `nonce`, `steward` | per-receipt nonce and the sealing identity (COSE `kid`) |
@ -506,6 +513,63 @@ receipt takes the next sequence and chains to the last envelope sealed, rather
than starting a new chain at 0, so a restart is not mistaken for a log that lost
its history.
### §6.4 The output commitment, and replay
A receipt that binds what was asked (`request_id`, which content-addresses the
prompt) and what it cost, but nothing about what came back, cannot show which
answer it was for. A generation's receipt therefore carries
`output_commitment`:
```text
payload = BLAKE3("mesh/1 prompt payload\0" ‖ prompt)
output_commitment = BLAKE3("mesh/1 output commitment\0" ‖ opening ‖ request_id ‖ payload ‖ tokens)
```
Here `prompt` is the input token payload the engine was handed (§5) and
`tokens` are the output token ids, each as little-endian `u32`s in order;
`request_id` and `payload` are their 32 bytes; and `opening` is a 32-byte
value the steward derives per request from a key it holds. In the reference
steward, the key is derived from its signing key, so a steward that restarts
can disclose any receipt's opening without having stored it.
- **It is a commitment, not a hash.** A plain hash of a short output, such as a
one-token answer, can be reversed by trying every output, and a receipt is
shown to parties who were not owed the answer. Without its opening, the
commitment reveals nothing about the tokens.
- **Disclosure.** A steward that discloses an output for one receipt discloses
that receipt's opening and `payload` with it. Anyone can then check that this
is the output the receipt sealed. The opening binds `request_id`, so it opens
no other receipt.
- **The payload is bound.** `prompt_hash` addresses the prompt's bytes, and an
engine is handed token ids. Nothing ties the two together without the
tokenizer, so `request_id` alone cannot confirm the payload a replay runs on.
The commitment binds it instead.
- **Replay.** Where the engine is deterministic (§5: its tokens are a function
of the request and the prompt payload alone), a party holding the prompt
payload and the opening can re-execute the serve and compare.
- Re-execution yields one of three verdicts: *verified* (the re-run opens the
commitment), *diverged*, or *not replayable*.
- It is *diverged* only when a disclosed output opens the commitment on the
payload the replay ran. That confirms the opening, the payload and the
output together, so the mismatch is the serve's. The verdict names the
first token at which the two outputs part.
- A replay MUST NOT report a divergence it cannot attribute to the serve. It
is *not replayable* when:
- the engine samples;
- it is not the backend and recipe the receipt names, or not the weights,
where the receipt sealed `weights_digest`;
- the request is not the receipt's;
- the receipt commits to no output;
- the re-run does not open the commitment, and no disclosed output opens it
on the replay's payload and opening. The mismatch might then be the
replayer's own input.
- A sampled generation is not expected to repeat, and re-running it settles
nothing.
These hold a serve to its record. Whether such a record carries any weight in a
given dispute is not this specification's to say. It is evidence anyone can
produce and check without the steward's help, opening apart.
---
## §7 Conformance

View file

@ -1 +1 @@
d284583ea201270458386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335aa059020fb4656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e7475756a5f7065725f61636365707465645f746f6b656e19c350584069106eee1557917c7e7da7498320aab7b17e9dbc0ad36436922269bec771fb4eb6fd0741819a00ce95ac7248b62dda77737e606b1b5492c2f863eea3f6723d08
d284583ea201270458386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335aa0590263b5656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e74716f75747075745f636f6d6d69746d656e7478403763393834386661623830316362386563336463316461656463393534343065623732623766633237643734633862306335366634306539346563626436396675756a5f7065725f61636365707465645f746f6b656e19c350584023281d57bf71b2e06936d3a5ca9fbc4503f27a9b18e9e2ee44e352a7c25cab8646ded99d74ca53a638ce15bb654afc0bfc3acf7ac5ff941898d3a1cd1cf01100

View file

@ -8,6 +8,7 @@
"measured_uj": 3200000,
"mesh_receipt": "mesh/1",
"nonce": "3adc393e8a5a2053",
"output_commitment": "7c9848fab801cb8ec3dc1daedc95440eb72b7fc27d74c8b0c56f40e94ecbd69f",
"recipe": "ssm-1b",
"request_id": "3adc393e8a5a2053049e46650e1f15ffc529c0d5ae6872f2d19d2ea8abfa364e",
"route": "cheapest-sufficient: tiny at 3550000 µJ, fidelity F1 ≥ floor F1 (weighed 4 backend(s))",

View file

@ -1 +1 @@
b4656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e7475756a5f7065725f61636365707465645f746f6b656e19c350
b5656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e74716f75747075745f636f6d6d69746d656e7478403763393834386661623830316362386563336463316461656463393534343065623732623766633237643734633862306335366634306539346563626436396675756a5f7065725f61636365707465645f746f6b656e19c350

View file

@ -164,7 +164,9 @@ fn envelope_is_reproduced_from_signed_core_and_rfc_9052_alone() {
assert_eq!(sk.verifying_key().to_bytes(), pubkey, "meta.json pubkey is not the seed's");
let rebuilt = cose_sign1(&protected, &core, &sk.sign(&to_sign).to_bytes());
assert_eq!(rebuilt, envelope, "hand-built COSE_Sign1 does not match envelope.hex");
assert_eq!(envelope.len(), 663);
// 663 before the receipt committed to its output (SPEC §6.2), whose field
// the reference generation now carries.
assert_eq!(envelope.len(), 747);
}
/// The verifier direction is not vacuous: a single changed payload byte, or the right

View file

@ -43,7 +43,7 @@ pub use journal::{FileJournal, Journal, JournalEntry, JournalRecord, MemoryJourn
pub use presentation::Presentation;
use mesh_engine::ServingEngine;
use mesh_meter::{Meter, Session};
use mesh_receipt::{MeshReceipt, ReceiptError, ReceiptLog};
use mesh_receipt::{output_commitment, payload_digest, MeshReceipt, ReceiptError, ReceiptLog};
use mesh_router::{BackendSpec, Router};
use mesh_schema::{Energy, RouteDecision, ServeOutcome, ServeRequest};
@ -175,6 +175,11 @@ pub struct Mesh<'m> {
unresolved: Vec<Unresolved>,
/// Unix seconds, for expiry and time caveats.
clock: fn() -> u64,
/// The key each receipt's output opening is drawn from
/// ([`Mesh::output_opening`]), itself drawn from the steward key, so a
/// steward that restarts can disclose the opening of any receipt it
/// sealed, without having stored one.
output_key: [u8; 32],
}
impl<'m> Mesh<'m> {
@ -184,6 +189,7 @@ impl<'m> Mesh<'m> {
/// [`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());
let output_key = ContentId::of_bytes(&[b"mesh/1 output opening key\0".as_slice(), &steward_sk.to_bytes()].concat()).0;
Self {
router: Router::new(),
engines: HashMap::new(),
@ -198,9 +204,19 @@ impl<'m> Mesh<'m> {
spent_presentations: HashSet::new(),
unresolved: Vec::new(),
clock: system_clock,
output_key,
}
}
/// The opening of the output commitment in this steward's receipt for
/// `request_id` (SPEC §6.4): what the steward discloses, with the output,
/// for a party to check one receipt by [`MeshReceipt::opens`] or to
/// re-execute it ([`replay`]). One opening opens one request's receipt and
/// says nothing about any other.
pub fn output_opening(&self, request_id: &ContentId) -> [u8; 32] {
ContentId::of_bytes(&[b"mesh/1 output opening\0".as_slice(), &self.output_key, &request_id.0].concat()).0
}
/// Record grant decisions and seals in `journal`, first re-establishing
/// the spend and the receipt chain from `recorded`, the records it already
/// holds, by replaying each through the gate (SPEC §2.2, JCP §4.11). Call
@ -406,10 +422,12 @@ impl<'m> Mesh<'m> {
/// Serve one metered inference. See the module docs for the four phases.
///
/// `prompt` is the input token payload. It is passed to the engine but never
/// sealed into the receipt — the receipt records only the request's
/// content-addressed `prompt_hash` (SPEC §3.1). Pass `&[]` for engines that
/// do not consume a payload (e.g. the synthetic reference engine).
/// `prompt` is the input token payload. It is passed to the engine and never
/// sealed into the receipt in the clear: the receipt records the request's
/// content-addressed `prompt_hash` (SPEC §3.1), and binds the payload's
/// digest only inside the output commitment, under its opening (§6.4).
/// Pass `&[]` for engines that do not consume a payload (e.g. the synthetic
/// reference engine).
pub fn serve(
&mut self,
req: &ServeRequest,
@ -578,6 +596,14 @@ impl<'m> Mesh<'m> {
fidelity_met,
energy,
grant_breach,
// A generation commits to its tokens. A typed decision's answer is
// not carried by the engine interface, so there is nothing to
// commit to, and the field is absent rather than a commitment to an
// empty output that a decision never produced.
output_commitment: req
.decision
.is_none()
.then(|| output_commitment(&self.output_opening(&req.id()), &req.id(), &payload_digest(prompt), &served.tokens)),
sequence: 0, // stamped by the log
prev: None, // stamped by the log
nonce,
@ -657,6 +683,80 @@ fn nonce_from(id: &ContentId) -> [u8; 8] {
[b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]
}
/// What re-executing a sealed serve established (SPEC §6.4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Replay {
/// The engine, run again on the request and prompt, produced the output
/// the receipt commits to.
Verified,
/// It produced another output than the one the steward disclosed, which
/// opens the receipt's commitment on the same prompt payload.
/// `first_difference` is the first token position at which the two part.
Diverged { first_difference: usize },
/// Re-execution cannot settle it, and why. A replay never reports a
/// divergence it cannot attribute to the serve.
NotReplayable(&'static str),
}
/// Re-execute a sealed serve on `engine` and check its output against the
/// receipt's commitment, under the `opening` the steward disclosed for it
/// ([`Mesh::output_opening`]).
///
/// It settles something only where it can: the engine must be deterministic
/// ([`ServingEngine::deterministic`]), since a sampled generation is not
/// expected to repeat; it must be the engine the receipt names, by backend,
/// recipe and, where the receipt sealed one, weights; the request must be the
/// one the receipt was sealed for; and the receipt must commit to an output.
/// Otherwise the answer is [`Replay::NotReplayable`], with the reason.
///
/// `prompt` is the payload to re-run on. The request's `prompt_hash`
/// addresses the prompt's bytes, not the token ids an engine is handed, so it
/// cannot confirm the payload. The commitment binds the payload's digest.
///
/// `claimed` is the output the steward disclosed. A re-run that opens the
/// commitment is [`Replay::Verified`]. One that does not is a divergence only
/// when `claimed` opens it on the same payload: that confirms the opening,
/// the payload and the claim together, so what remains is the serve.
/// Otherwise the mismatch might be a wrong payload or opening rather than the
/// serve, and the replay says it cannot settle it.
pub fn replay(
receipt: &MeshReceipt,
request: &ServeRequest,
prompt: &[u32],
opening: &[u8; 32],
engine: &mut dyn ServingEngine,
claimed: Option<&[u32]>,
) -> Replay {
if receipt.output_commitment.is_none() {
return Replay::NotReplayable("the receipt commits to no output");
}
if request.id() != receipt.request_id {
return Replay::NotReplayable("not the request the receipt was sealed for");
}
if engine.backend_id() != receipt.backend || engine.recipe_id() != receipt.recipe {
return Replay::NotReplayable("not the backend and recipe the receipt names");
}
if receipt.weights_digest.is_some() && engine.weights_digest() != receipt.weights_digest {
return Replay::NotReplayable("not the weights the receipt names");
}
if !engine.deterministic() {
return Replay::NotReplayable("the engine is not deterministic, so a re-run is not expected to repeat");
}
let payload = payload_digest(prompt);
let again = engine.serve(request, prompt).tokens;
if receipt.opens(opening, &payload, &again) {
return Replay::Verified;
}
match claimed.filter(|c| receipt.opens(opening, &payload, c)) {
Some(c) => Replay::Diverged {
first_difference: c.iter().zip(&again).position(|(a, b)| a != b).unwrap_or(c.len().min(again.len())),
},
None => Replay::NotReplayable(
"no disclosed output opens the commitment on this payload and opening, so a mismatch cannot be told from a wrong input",
),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -1540,4 +1640,221 @@ mod tests {
added.sort();
assert_eq!(added, vec!["precision", "weights_digest"]);
}
// ── the output, bound and re-executed (SPEC §6.4) ───────────────────────
/// A deterministic engine whose tokens are the reference engine's, each
/// shifted by one from position `from` on: the same engine identity,
/// another output.
struct Drifting {
inner: ReferenceEngine,
from: usize,
}
impl ServingEngine for Drifting {
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 deterministic(&self) -> bool {
true
}
fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> mesh_engine::Served {
let mut s = self.inner.serve(req, prompt);
for t in s.tokens.iter_mut().skip(self.from) {
*t = t.wrapping_add(1);
}
s
}
}
fn tiny() -> ReferenceEngine {
ReferenceEngine::new("tiny", "ssm-1b", Fidelity::F1, 50_000, 256)
}
/// A generation's receipt commits to its output: the tokens the serve
/// returned open it under the steward's opening, and nothing else does.
/// The receipt used to bind what was asked and what it cost, and nothing
/// about what came back.
#[test]
fn a_generation_commits_to_its_output() {
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let mut mesh = mesh_with(&meter);
let req = request(16, Fidelity::F1, 5_000_000);
let resp = mesh.serve(&req, &[], None).unwrap();
let opening = mesh.output_opening(&req.id());
assert!(resp.receipt.output_commitment.is_some());
let payload = payload_digest(&[]);
assert!(resp.receipt.opens(&opening, &payload, &resp.tokens), "the tokens served open it");
let mut other = resp.tokens.clone();
other[3] ^= 1;
assert!(!resp.receipt.opens(&opening, &payload, &other), "another output does not");
assert!(!resp.receipt.opens(&[0u8; 32], &payload, &resp.tokens), "nor does another opening");
assert!(!resp.receipt.opens(&opening, &payload_digest(&[0]), &resp.tokens), "nor another payload");
assert_ne!(
mesh.output_opening(&request(17, Fidelity::F1, 5_000_000).id()),
opening,
"an opening opens one request's receipt"
);
// Sealed: it is in the signed core, so the envelope binds it.
let wire = resp.receipt.to_jcr1_wire(mesh.log.steward());
assert_eq!(wire["output_commitment"], resp.receipt.output_commitment.unwrap().to_hex().as_str());
resp.receipt.verify_envelope(&resp.envelope, mesh.log.steward(), &mesh.log.verifying_key()).unwrap();
}
/// A typed decision's answer is not carried, so its receipt commits to
/// nothing rather than to an empty output it never produced.
#[test]
fn a_typed_decision_commits_to_no_output() {
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let mut mesh = mesh_with(&meter);
let mut req = request(16, Fidelity::F1, 5_000_000);
req.decision = Some(mesh_schema::DecisionShape { questions: 2, options_scored: 5, schema_tokens: 3 });
let resp = mesh.serve(&req, &[], None).unwrap();
assert_eq!(resp.receipt.output_commitment, None);
assert!(!resp.receipt.to_jcr1_wire(mesh.log.steward()).as_object().unwrap().contains_key("output_commitment"));
}
/// A sealed serve from a deterministic engine re-executes to the output
/// its receipt commits to, and one that does not is a divergence, located
/// at the first token where it parts from the disclosed output.
#[test]
fn a_deterministic_serve_replays_to_its_commitment() {
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let mut mesh = mesh_with(&meter);
let req = request(16, Fidelity::F1, 5_000_000);
let resp = mesh.serve(&req, &[], None).unwrap();
let opening = mesh.output_opening(&req.id());
assert_eq!(replay(&resp.receipt, &req, &[], &opening, &mut tiny(), None), Replay::Verified);
let mut drifting = Drifting { inner: tiny(), from: 5 };
assert_eq!(
replay(&resp.receipt, &req, &[], &opening, &mut drifting, Some(&resp.tokens)),
Replay::Diverged { first_difference: 5 }
);
}
/// A mismatch is the serve's only once the disclosed output opens the
/// commitment on the payload the replay ran: otherwise it may be the
/// replayer's input that is wrong, and a replay says it cannot settle it.
#[test]
fn a_replay_attributes_no_mismatch_its_inputs_could_explain() {
/// Deterministic, and reads its payload: each token moves by the sum
/// of the prompt's.
struct Reading(ReferenceEngine);
impl ServingEngine for Reading {
fn backend_id(&self) -> BackendId {
self.0.backend_id()
}
fn recipe_id(&self) -> String {
self.0.recipe_id()
}
fn fidelity(&self) -> Fidelity {
self.0.fidelity()
}
fn est_uj_per_token(&self) -> u64 {
self.0.est_uj_per_token()
}
fn deterministic(&self) -> bool {
true
}
fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> mesh_engine::Served {
let shift = prompt.iter().fold(0u32, |a, t| a.wrapping_add(*t));
let mut s = self.0.serve(req, prompt);
for t in &mut s.tokens {
*t = t.wrapping_add(shift);
}
s
}
}
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let mut mesh = mesh_with(&meter);
let req = request(16, Fidelity::F1, 5_000_000);
// The reference engine ignores the payload, so what it served is what
// `Reading` serves on a payload summing to zero.
let resp = mesh.serve(&req, &[0], None).unwrap();
let opening = mesh.output_opening(&req.id());
let not = |r: Replay| match r {
Replay::NotReplayable(why) => why,
other => panic!("settled what it could not: {other:?}"),
};
assert_eq!(replay(&resp.receipt, &req, &[0], &opening, &mut Reading(tiny()), Some(&resp.tokens)), Replay::Verified);
// Another payload: the honest output, disclosed, is no evidence against
// the serve, because it does not open the commitment on that payload.
let wrong = replay(&resp.receipt, &req, &[5], &opening, &mut Reading(tiny()), Some(&resp.tokens));
assert!(not(wrong).contains("wrong input"));
// Nor is anything without a disclosed output, or under another opening.
let mut drifting = Drifting { inner: tiny(), from: 5 };
assert!(not(replay(&resp.receipt, &req, &[0], &opening, &mut drifting, None)).contains("wrong input"));
let mut drifting = Drifting { inner: tiny(), from: 5 };
assert!(not(replay(&resp.receipt, &req, &[0], &opening, &mut drifting, Some(&[1, 2, 3]))).contains("wrong input"));
assert!(not(replay(&resp.receipt, &req, &[0], &[7u8; 32], &mut tiny(), Some(&resp.tokens))).contains("wrong input"));
}
/// A replay settles nothing it cannot attribute to the serve, and says why.
#[test]
fn a_replay_refuses_what_it_cannot_settle() {
struct Sampling(ReferenceEngine);
impl ServingEngine for Sampling {
fn backend_id(&self) -> BackendId {
self.0.backend_id()
}
fn recipe_id(&self) -> String {
self.0.recipe_id()
}
fn fidelity(&self) -> Fidelity {
self.0.fidelity()
}
fn est_uj_per_token(&self) -> u64 {
self.0.est_uj_per_token()
}
fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> mesh_engine::Served {
self.0.serve(req, prompt)
}
}
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let mut mesh = mesh_with(&meter);
let req = request(16, Fidelity::F1, 5_000_000);
let resp = mesh.serve(&req, &[], None).unwrap();
let opening = mesh.output_opening(&req.id());
let not = |r: Replay| match r {
Replay::NotReplayable(why) => why,
other => panic!("settled what it could not: {other:?}"),
};
assert!(not(replay(&resp.receipt, &req, &[], &opening, &mut Sampling(tiny()), None)).contains("not deterministic"));
let other_req = request(17, Fidelity::F1, 5_000_000);
assert!(not(replay(&resp.receipt, &other_req, &[], &opening, &mut tiny(), None)).contains("request"));
let mut other_engine = ReferenceEngine::new("frontier", "wire-rpc", Fidelity::F4, 5_000_000, 256);
assert!(not(replay(&resp.receipt, &req, &[], &opening, &mut other_engine, None)).contains("backend"));
let mut named = resp.receipt.clone();
named.weights_digest = Some(ContentId::of_bytes(b"other weights"));
assert!(not(replay(&named, &req, &[], &opening, &mut tiny(), None)).contains("weights"));
let mut bare = resp.receipt.clone();
bare.output_commitment = None;
assert!(not(replay(&bare, &req, &[], &opening, &mut tiny(), None)).contains("no output"));
}
/// The opening is drawn from the steward key, so a steward that restarts
/// can disclose the opening of a receipt it sealed before, without having
/// stored it.
#[test]
fn a_restarted_steward_can_disclose_an_earlier_opening() {
let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000);
let req = request(16, Fidelity::F1, 5_000_000);
let resp = mesh_with(&meter).serve(&req, &[], None).unwrap();
let again = mesh_with(&meter);
assert!(resp.receipt.opens(&again.output_opening(&req.id()), &payload_digest(&[]), &resp.tokens));
}
}

View file

@ -83,11 +83,21 @@ pub trait ServingEngine {
fn weights_digest(&self) -> Option<ContentId> {
None
}
/// Whether serving the same request and prompt again produces the same
/// tokens. A sealed serve from a deterministic engine can be re-executed
/// and checked against its receipt's output commitment
/// (`mesh_core::replay`). `false`, the default, for any engine that
/// samples: re-running it settles nothing, and a replay says so rather
/// than reporting a divergence that is only the sampler.
fn deterministic(&self) -> bool {
false
}
/// 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
/// work proportional to the tokens produced so the surrounding meter reads a
/// real span.
/// alongside the request, never sealed into the receipt in the clear (the
/// receipt records the content-addressed `prompt_hash`, §3.1, and the
/// payload's digest only inside its output commitment, §6.4).
/// Implementations MUST do work proportional to the tokens produced so the
/// surrounding meter reads a real span.
fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> Served;
}
@ -237,6 +247,13 @@ impl ServingEngine for ReferenceEngine {
self.weights_digest
}
/// Its tokens are a pure function of the request: a SplitMix64 stream
/// seeded from `prompt_hash` and `max_tokens`. Its cache changes the
/// cache-hit count, never the tokens.
fn deterministic(&self) -> bool {
true
}
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

View file

@ -118,6 +118,22 @@ pub struct MeshReceipt {
/// the serve rode on no grant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grant_breach: Option<String>,
/// A commitment to the output the serve produced (SPEC §6.4), for a
/// generation: [`output_commitment`] over the output tokens, under an
/// opening the steward holds and discloses for this receipt alone.
///
/// Without it the receipt bound what was asked (`request_id`, which
/// content-addresses the prompt) and what it cost, and nothing about what
/// came back, so no one could later show which answer a receipt was for.
/// It is a commitment, not a hash of the tokens: a hash of a short output
/// (a one-token answer) can be reversed by trying every output, and a
/// receipt is shown to parties who were not owed the answer. With the
/// opening, anyone checks a disclosed output against the receipt
/// ([`MeshReceipt::opens`]); without it the commitment says nothing about
/// the output. `None`, skipped on the wire, for a typed decision (whose
/// answer is not carried) and for receipts sealed before the field existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_commitment: Option<ContentId>,
/// Monotonic sequence in the steward's receipt log.
pub sequence: u64,
/// Content id of the previous envelope (hash chain); `None` at genesis.
@ -224,6 +240,11 @@ impl MeshReceipt {
if let Some(b) = &self.grant_breach {
obj.insert("grant_breach".into(), json!(b));
}
// Emitted only for a serve that committed to its output, so a receipt
// sealed before the field existed keeps its bytes.
if let Some(c) = &self.output_commitment {
obj.insert("output_commitment".into(), json!(c.to_hex()));
}
obj.insert("sequence".into(), json!(self.sequence));
if let Some(p) = &self.prev {
obj.insert("prev".into(), json!(p.to_hex()));
@ -233,6 +254,15 @@ impl MeshReceipt {
serde_json::Value::Object(obj)
}
/// Whether `tokens` are the output this receipt committed to, served on
/// the prompt payload whose [`payload_digest`] is `payload`, under
/// `opening`: the check a party runs on what the steward disclosed for
/// this receipt (SPEC §6.4). `false` for a receipt that commits to no
/// output.
pub fn opens(&self, opening: &[u8; 32], payload: &ContentId, tokens: &[u32]) -> bool {
self.output_commitment == Some(output_commitment(opening, &self.request_id, payload, tokens))
}
/// The canonical signed-core bytes (JCR-1 det-CBOR). Validates the
/// provenance ↔ measured coupling first, so a dishonest receipt cannot be
/// turned into bytes a verifier would accept.
@ -263,6 +293,52 @@ impl MeshReceipt {
}
}
/// The domain an output commitment is drawn in, so that it can equal no other
/// content address the family computes.
const OUTPUT_DOMAIN: &[u8] = b"mesh/1 output commitment\0";
/// The domain a prompt payload's digest is drawn in.
const PAYLOAD_DOMAIN: &[u8] = b"mesh/1 prompt payload\0";
/// The digest of the prompt payload an engine was handed: the content
/// address of the domain and the token ids as little-endian `u32`s (SPEC
/// §6.4).
///
/// The request's `prompt_hash` addresses the prompt's bytes, and the engine is
/// handed token ids. Nothing ties the two together without the tokenizer, so
/// the output commitment binds this digest as well.
pub fn payload_digest(prompt: &[u32]) -> ContentId {
let mut bytes = Vec::with_capacity(PAYLOAD_DOMAIN.len() + prompt.len() * 4);
bytes.extend_from_slice(PAYLOAD_DOMAIN);
for t in prompt {
bytes.extend_from_slice(&t.to_le_bytes());
}
ContentId::of_bytes(&bytes)
}
/// Commit to a generation's output `tokens`, for the request `request_id`
/// served on the prompt payload whose [`payload_digest`] is `payload`, under
/// `opening` (SPEC §6.4): the content address of the domain, the opening, the
/// request id, the payload digest and the tokens as little-endian `u32`s.
///
/// BLAKE3 with a secret 32-byte prefix is a commitment: without the opening
/// it reveals nothing about the tokens, however few they are, and no one can
/// find a second output under the same opening. Binding the request id means
/// an opening disclosed for one receipt opens no other. Binding the payload
/// means an output that opens it was served on that payload, so a replay can
/// tell a different payload from a different output.
pub fn output_commitment(opening: &[u8; 32], request_id: &ContentId, payload: &ContentId, tokens: &[u32]) -> ContentId {
let mut bytes = Vec::with_capacity(OUTPUT_DOMAIN.len() + 96 + tokens.len() * 4);
bytes.extend_from_slice(OUTPUT_DOMAIN);
bytes.extend_from_slice(opening);
bytes.extend_from_slice(&request_id.0);
bytes.extend_from_slice(&payload.0);
for t in tokens {
bytes.extend_from_slice(&t.to_le_bytes());
}
ContentId::of_bytes(&bytes)
}
/// A steward's hash-chained receipt log. Each [`append`](ReceiptLog::append)
/// stamps the next sequence and the prior envelope's content id, seals the
/// receipt, and advances the head — so the log is tamper-evident end to end.
@ -386,6 +462,7 @@ mod tests {
fidelity_met: true,
energy,
grant_breach: None,
output_commitment: None,
sequence: 0,
prev: None,
nonce: [1, 2, 3, 4, 5, 6, 7, 8],
@ -564,4 +641,24 @@ mod tests {
assert!(r.verify_envelope(env, log.steward(), &vk).is_ok());
}
}
/// The commitment binds the request, the opening, the prompt payload and
/// every token: change any one and it is another commitment, so an opening
/// disclosed for one receipt opens no other, and no other output, nor the
/// same output on another payload, opens it.
#[test]
fn an_output_commitment_binds_request_opening_payload_and_tokens() {
let (a, b) = (ContentId::of_bytes(b"request a"), ContentId::of_bytes(b"request b"));
let o = [3u8; 32];
let p = payload_digest(&[1, 2]);
let t = [7u32, 9, 11];
let c = output_commitment(&o, &a, &p, &t);
assert_ne!(c, output_commitment(&o, &b, &p, &t), "the request");
assert_ne!(c, output_commitment(&[4u8; 32], &a, &p, &t), "the opening");
assert_ne!(c, output_commitment(&o, &a, &payload_digest(&[1, 3]), &t), "the payload");
assert_ne!(c, output_commitment(&o, &a, &p, &[7, 9, 12]), "a token");
assert_ne!(c, output_commitment(&o, &a, &p, &[7, 9]), "the length");
assert_eq!(c, output_commitment(&o, &a, &p, &t), "and nothing else");
assert_ne!(payload_digest(&[]), payload_digest(&[0]), "an empty payload is not a zero token");
}
}