mesh/1 + AR-1: receipts commit to the output; deterministic serves replay #378
13 changed files with 565 additions and 16 deletions
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
mesh/SPEC.md
70
mesh/SPEC.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
d284583ea201270458386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335aa059020fb4656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e7475756a5f7065725f61636365707465645f746f6b656e19c350584069106eee1557917c7e7da7498320aab7b17e9dbc0ad36436922269bec771fb4eb6fd0741819a00ce95ac7248b62dda77737e606b1b5492c2f863eea3f6723d08
|
||||
d284583ea201270458386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335aa0590263b5656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e74716f75747075745f636f6d6d69746d656e7478403763393834386661623830316362386563336463316461656463393534343065623732623766633237643734633862306335366634306539346563626436396675756a5f7065725f61636365707465645f746f6b656e19c350584023281d57bf71b2e06936d3a5ca9fbc4503f27a9b18e9e2ee44e352a7c25cab8646ded99d74ca53a638ce15bb654afc0bfc3acf7ac5ff941898d3a1cd1cf01100
|
||||
|
|
|
|||
|
|
@ -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))",
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
b4656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e7475756a5f7065725f61636365707465645f746f6b656e19c350
|
||||
b5656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e74716f75747075745f636f6d6d69746d656e7478403763393834386661623830316362386563336463316461656463393534343065623732623766633237643734633862306335366634306539346563626436396675756a5f7065725f61636365707465645f746f6b656e19c350
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue