Specification · raw markdown · v1 spec

Ampoule run predicate, v2 (and v1)

Predicate type: https://ampoule.dev/attestation/run/v2 (current). Receipts signed before v2 carry https://ampoule.dev/attestation/run/v1; see v2: differences from v1.

The URI is permanent: every signed receipt carries it. A breaking change to the fields gets a new version (/v2), never an edit to this one.

An in-toto Statement v1 with this predicate records one execution of untrusted code inside a sealed Ampoule micro-VM: which kernel ran it, what went in, what came out, and how much work it took. It is carried in a DSSE envelope signed with Ed25519.

Statement

{
  "_type": "https://in-toto.io/Statement/v1",
  "subject": [{ "name": "output", "digest": { "sha256": "<output_sha256>" } }],
  "predicateType": "https://ampoule.dev/attestation/run/v2",
  "predicate": {
    "version": 1,
    "kernel_sha256": "60ba9149…",
    "vmm": "qemu",
    "runtime": "js",
    "flags": 260,
    "deterministic": true,
    "seed": 7,
    "payload_sha256": "bf75c921…",
    "output_sha256": "12cece5a…",
    "fuel": 14354058,
    "status": 0
  }
}

The subject is the output, the artifact the run produced. Its digest must equal predicate.output_sha256; verifiers reject a statement where they differ.

Predicate fields

field meaning
version Receipt format version: the JSON integer 1, fixed for v1 and v2. Any other value or type (2, "1", 1.0) is rejected; a format change gets a new predicate type, not a new version.
kernel_sha256 Required. SHA-256 of the kernel ELF the VM booted. The language runtimes (QuickJS, CPython) are embedded in the kernel, so this pins them too.
vmm qemu or firecracker. Informational: deterministic runs give the same output and fuel on both.
runtime rhai, wasm, js or python.
flags Request flags as sent in the mailbox (runtime bit, 0x100 = deterministic).
deterministic Clock and randomness derived from seed (the WASI realtime clock reads 2026-01-01T00:00:00Z + seed s + 1 ms per read), a runaway ends on a fixed budget that replay reproduces exactly: wasmi fuel for wasm (20G), js (2T) and python (2T, warm and cold path alike), an operation count for rhai. A host wall-clock cutoff (js 20 s, python 30 s, wasm 20 s) remains as a safety net for hosts too slow to reach the budget; a run it stops gets terminated and is not replayable. Only these runs can be re-executed to the same result.
seed The seed used for clock and randomness.
payload_sha256 SHA-256 of the exact request bytes: the source for rhai/js/python; for wasm, u32le module_len ‖ module ‖ input. Computed by the guest and checked by the host.
output_sha256 SHA-256 of the exact output bytes, computed by the guest and checked by the host. See Output bytes for what those bytes are.
fuel Work done: wasmi fuel consumed for wasm/js/python, Rhai operations counted by the engine for rhai. Wasmi fuel is a coarse cost measure (charged per block of instructions at weighted costs, plus per byte of bulk memory work), not an instruction count; two different inputs can use the same fuel. For deterministic runs, part of what replay must reproduce.
status The kernel's result code for the run, not the program's exit code: 0 success, 1 program error (an uncaught exception, any nonzero exit, a trap or a limit), 2 bad request or runtime unavailable, other values are limits or faults. A Python program that exits with code 2 gives status 1, as does an uncaught exception (CPython exits with 1): the kernel maps every nonzero WASI exit code N to status 1 and appends !! exit code N to the output, so the program's own code is in the output, not in status. Tools print it as status 1 (program error). A signed receipt with a nonzero status attests a failed run, not a successful one: ampoule-verify prints RUN FAILED: signature valid, status N (meaning) and exits 4, verify --replay prints REPLAY MATCHED, RUN FAILED: status N and exits 4 when the rerun matches (it never says REPLAY OK for such a receipt), the MCP server's receipt-check tool answers RUN FAILED: signature valid, status N (meaning) (…), and the verifier library reports a failed run, never a verified one. terminated outranks it (exit 3): a stopped run's status describes the stop.
stop Optional; absent on every receipt from an earlier kernel and on every run that did not use up its budget. "fuel": the run used up its fuel budget (wasmi fuel for wasm, js and python; the operation limit for rhai), i.e. a runaway, not a crash (response header word at offset 112 = 2). Such a run is deterministic, so it stays replayable: status is 1, and the verdict is the status verdict (ampoule-verify RUN FAILED, exit 4; verify --replay REPLAY MATCHED, RUN FAILED, exit 4, and replay must reproduce the stop: a rerun that does or does not stop on fuel where the receipt says otherwise is REPLAY FAILED: stop differs). Tools print it as a detail line (stop fuel: ... in ampoule-verify, stop: fuel: ... in the MCP receipt-check tool, stop fuel matches receipt in replay); the result line is the same as for the same receipt without stop. It is a separate field, not a terminated value, because any terminated key means not replayable. Verifiers released before this field was added ignore it (unknown extra field) and report the same RUN FAILED, exit 4. Strict: any value other than "fuel", a status other than 1, or stop together with terminated is FAILED (exit 1), and the value is never printed as it is. Inside the signed statement, so removing or adding it without re-signing breaks the signature.
terminated Optional; absent for a run that ended normally. "wall_timeout": the guest was stopped by the host wall-clock safety net, not by fuel (response header word at offset 112 = 1). Whether that fires depends on host speed, so the run is not replayable even when deterministic is true. Verifiers must never report such a receipt as a pass: ampoule-verify prints NOT REPLAYABLE: signature valid, terminated=wall_timeout and verify --replay prints REPLAY N/A, both exit 3. The presence of the key is what counts: any other value, including null, is treated the same way (fail closed). The value is printed only when it is a plain token of 1 to 64 characters from [a-z_] (wall_timeout; null for a JSON null); any other value (the empty string, upper case, spaces, newlines, control characters, objects, numbers) prints as terminated=non-standard, so a signed reason can never add a line or the word VERIFIED to a verifier's output. The verifier library reports such a receipt as not replayable, never as verified. The field is inside the signed statement, so removing or adding it without re-signing breaks the signature.

Field types (strict parsing)

Verifiers check every field, and reject the receipt (FAILED, exit 1) when one is missing, null or of the wrong type:

field type and allowed values
version integer 1
kernel_sha256, payload_sha256, output_sha256 string, exactly 64 lowercase hex characters
vmm "qemu" or "firecracker"
runtime "rhai", "wasm", "js" or "python"
deterministic boolean, equal to bit 0x100 of flags
flags, seed, fuel, status integer from 0 to 2^64-1 (no sign, no fraction, no exponent)
stop optional, exactly "fuel", only with status 1 and without terminated; v2 only
terminated optional, any value (its presence is what counts); v2 only

Unknown extra predicate fields are allowed (in-toto predicates are open) and ignored. stop is a known v2 field, not an unknown extra one. The statement must have exactly one subject.

Output bytes: cap and UTF-8

output_sha256 covers exactly the bytes the guest captured, and those can differ from what the program wrote in two ways. Kernels up to and including the current v2 kernels (60ba9149…) do this without any marker in the output:

Receipts stay valid and replayable (the guest does the same thing on every run), so the hardening release flags these cases instead of changing what is signed: a note is printed when the output is exactly at the cap (a cut of ASCII text always lands there), or 1 to 3 bytes short of it and contains multi-byte text (a character straddling the cap is dropped whole), or when it contains U+FFFD. ASCII output under the cap was not cut and gets no note. The receipt cannot say for certain whether a cut happened (an output of exactly 65,536 bytes is flagged even if nothing was dropped, and an ASCII output left short by a dropped multi-byte character is not); the in-band truncation marker that settles it is planned for the next predicate version. Every tool prints the same newline-terminated note (unsigned): ... lines: the MCP server at the end of the result footer, after the attestation: line (so the output bytes and their digest are unchanged), the MCP receipt-check tool, and ampoule-verify, from --output or, for a saved tool result without --output, from the output above the footer (which it then says it did not check). The notes are not signed. The verifier also reports output that is not valid UTF-8, which this kernel never produces. When the MCP receipt-check tool or ampoule-verify is given a whole tool result, it takes the attestation: line only from the footer (after the last \n--\nvm:). A guest can print its own attestation: line in its output, for example a signed envelope copied from another run, and that line is ignored. An in-band truncation marker and raw output bytes need a kernel change (new kernel_sha256) and are planned with the next predicate version.

Envelope and signing

What a signature does and does not prove

It proves that the holder of the key vouched for this run, and that nothing in the statement changed afterwards. It does not prove that the key holder was honest, or what hardware ran the VM. That is tamper evidence, not remote attestation. For deterministic runs the signer does not need to be trusted at all: anyone with the kernel image, the payload and the seed can re-execute and compare output digest and fuel (verify --replay). Replay needs the exact kernel named by kernel_sha256: receipts produced before Rhai fuel was recorded (older kernel hash) only replay against their own kernel. Kernels before terminated was added leave the header word zero, so their receipts never carry the field; the kernel that sets it has a new kernel_sha256. Statements and envelopes are parsed strictly: a duplicate JSON key at any level (for example terminated or predicateType given twice) makes the receipt FAILED, since the signed bytes must have exactly one meaning.

A receipt with terminated present is not replayable (see the field table). Binding the signing key to a measured kernel through a TEE is future work.

Trust boundary

A signature proves which host key signed a receipt; it does not prove the host told the truth. Anyone holding the signing key can produce any receipt, including one that drops terminated from a run the host killed.

v2: differences from v1

v2 exists so that a verifier that only knows v1 fails closed. A v1-only verifier checks the signature and the predicate type, and would print VERIFIED for a receipt whose run the host stopped (terminated), because v1 never defined that field. Under v2 the same verifier rejects every new receipt for its unknown predicate type instead.

What a v2 verifier accepts:

predicateType terminated absent terminated present
.../run/v2 accepted (VERIFIED, exit 0 with the kernel pinned, INCOMPLETE, exit 5, without; RUN FAILED, exit 4, when status ≠ 0) signature checked, NOT REPLAYABLE (exit 3)
.../run/v1 accepted (legacy receipt, exit 0 or 5 as for v2; exit 4 when status ≠ 0) rejected (FAILED, exit 1): v1 never defined the field
anything else rejected (exit 1) rejected (exit 1)

stop: "fuel" (v2, terminated absent, status 1) is RUN FAILED, exit 4, like the same receipt without it; in a v1 statement it is rejected (exit 1).

Exit-code change in the hardening release

Deliberate: a receipt whose signed status is nonzero (and that has no terminated) used to exit 0 (VERIFIED) and now exits 4 (RUN FAILED: signature valid, status N, meaning (<scope>)). Nothing else changes exit code for a receipt that was valid before; receipts that violate the field types above, or lack kernel_sha256, now fail (exit 1), and a missing or unreadable --output/--payload/--kernel file exits 2 instead of 1: an I/O problem says nothing about the receipt, so it is a usage/I-O error like an unreadable ENVELOPE (which was already 2), not FAILED. Every file is read before any verdict, so 2 outranks 1. Callers that treat "exit 0 or 3" as "the signature is fine" should add 4. Also new: exit 6 for an input over the 64 MiB cap (nothing verified), verify --replay exits 4 (REPLAY MATCHED, RUN FAILED) instead of 0 for a matching replay of a receipt with a nonzero status, weak (small-order) keys are refused (exit 2), and an attestation: line in text without the MCP footer is ignored unless it is the whole text.

Also deliberate: exit 5, INCOMPLETE. A good receipt (signature valid, status 0, no terminated, every given digest matching) whose kernel was not pinned (neither --kernel nor --expected-kernel-sha256) used to exit 0 and now exits 5. Its result line leads with INCOMPLETE and never contains the word VERIFIED, so a caller that greps for VERIFIED cannot mistake it for a pass. It ends with the same scope suffix as NOT REPLAYABLE and RUN FAILED, then how to pin:

result     INCOMPLETE: signature valid, kernel not pinned (payload checked; output, kernel not checked); pin it with --kernel or --expected-kernel-sha256

The suffix is (<checked> checked; <unchecked> not checked), or (signature only; <unchecked> not checked) when no file was given; the unchecked list is always complete, in the order output, payload, kernel. Exit 0 means a fully pinned check and its line starts with VERIFIED.

The same rule holds for every non-pass verdict: the word VERIFIED appears only in exit 0's line. Verdicts lead with their own word, then signature valid where the signature checked out:

result     NOT REPLAYABLE: signature valid, terminated=wall_timeout (signature only; output, payload, kernel not checked)
result     RUN FAILED: signature valid, status 1, program error (payload checked; output, kernel not checked)
result     INCOMPLETE: signature valid, kernel not pinned (signature only; output, payload, kernel not checked); pin it with --kernel or --expected-kernel-sha256
result     FAILED

The MCP server's receipt-check tool never checks the kernel, so it answers INCOMPLETE: signature valid, kernel not checked by this tool (<scope>) for a good receipt, and NOT REPLAYABLE: … / RUN FAILED: … in the same form. Callers that only want the signature should accept 5 as well.

Precedence when several apply: 2 (and 6, both decided before any verdict) > 1 FAILED > 3 NOT REPLAYABLE > 4 RUN FAILED > 5 INCOMPLETE > 0 VERIFIED. So an unpinned check of a failed run is 4, and an unpinned not-replayable run is 3.

Verifying

ampoule-verify envelope.json (--key <pubkey-hex|file> | --trusted-keys FILE)... \
    [--output F] [--payload F] [--kernel F] [--expected-kernel-sha256 HEX]
verify --replay envelope.json --key <pubkey> --js code.js

The public key is the trust anchor and must reach the verifier by some other channel than the envelope. ampoule-verify pubkey prints the key a host signs with.