agentplane

Record format

The normative wire specification for agentplane's journal records, hash chain, Merkle log and export file — enough to verify a history without this crate.

On this page
  1. 1. Versioning
  2. 2. Primitives
  3. 3. Canonical JSON
  4. 4. The record body
  5. 5. The hash chain
  6. 6. Record signature
  7. 7. The Merkle log
    1. Checkpoint
    2. Cosignature
  8. 8. Sealed payloads
  9. 9. The export file
    1. Header
    2. Run block
    3. Record line
    4. Case block
    5. Trailer
    6. Disclosure package
  10. 10. Verifying an export
    1. Verifying a package
    2. A grader’s verdict beside an export
  11. 11. Conformance vectors
  12. 12. Algorithm agility
    1. Domain-separated digests
  13. Vocabulary
  14. What this format does not promise

This is the specification an independent implementation reads. Everything here is what a verifier must reproduce byte for byte to check a history it did not write; everything not here is implementation freedom.

Audit rests on the party under examination not being the only party able to examine, and an auditor who has to read someone’s Rust to check their evidence has not escaped that dependency.

Scope. Verification is payload-agnostic. A verifier recomputes digests over bytes and never interprets what a record says, so this document specifies the envelope, the chain, the log and the file completely, and treats record payloads as opaque JSON. The payload vocabulary is listed below and pinned by machine-readable vectors.

1. Versioning

Each version number answers a different question. A reader that cannot interpret one refuses; it never guesses, and it never treats an unrecognised value as the current one.

NumberWhere it appearsWhat it governs
canonRunAdmitted.canon, export headerThe derivation the run’s digests were computed under: the canonical-JSON rule and the digest algorithm — see algorithm agility
vevery record bodyThe record body’s own shape
export versionexport headerThe export file’s framing
envelope byte 0every sealed payloadThe sealed-envelope layout
sidecar versiona grader-verdict sidecarThe sidecar’s own shape

Each is 1 in this specification.

They are deliberately independent. A run written under another canon is unverifiable by this reader, which is a different finding from this run diverged — an audit must report unknown scope as prominently as corruption and never as corruption.

A record’s hash covers its bytes at whatever v they carry. A reader compares v before it believes the shape: a record at an older v is lifted to the shape the reader knows, or refused as a version skew — never as an edit, since its bytes still hash as written. A restore writes each record back byte for byte, so a chain restored across a shape change hashes as the exported one. The order a deployment takes a new v in is readers before writers.

2. Primitives

  • Digest — SHA-256. Thirty-two bytes, written in JSON as 64 lowercase hex characters.
  • RunId, CaseId, BatchId — a type prefix, _, then a ULID in Crockford base32, 26 characters: run_01ARZ3NDEKTSV4RRFFQ69G5FAV, case_…, batch_…. The same spelling everywhere — record bodies, export blocks, store keys, log lines — so an identifier says which kind it is wherever it is read, and _ is outside Crockford base32 so the prefix can never be part of the ULID. A reader may accept a bare ULID; a writer emits the prefix.
  • Seq, Epoch — unsigned 64-bit integers.
  • Timestamp — RFC 3339 with an offset, as a JSON string.
  • Bytes in JSON — base64, RFC 4648 standard alphabet, padded, and canonical: padding must be present and correct, and the bits below the last whole byte must be zero. A decoder that accepts a second spelling of one value accepts two artifacts that both verify and are not the same bytes.

3. Canonical JSON

canon version 1 is RFC 8785 (JCS) with one stated departure.

  1. Object members are sorted by UTF-16 code unit of the member name. Not by UTF-8 byte: the two agree throughout the Basic Multilingual Plane and disagree above it, so an ASCII-only test suite cannot tell them apart.
  2. No insignificant whitespace. No space after : or ,, none at either end.
  3. Strings are escaped as RFC 8785 requires: " and \ backslash-escaped, the C0 controls that have short escapes written as \b \t \n \f \r, every other code point below U+0020 as \u00XX with lowercase hex, and everything else emitted as itself in UTF-8.
  4. Doubles are formatted by ECMAScript’s number-to-string algorithm at radix 10 — shortest round-tripping digits, positional notation for magnitudes in [1e-6, 1e21), exponential with an explicit sign outside it. So 100.0 is 100 and 1e30 is 1e+30.
  5. The departure: integers stay exact. JCS treats every number as an IEEE-754 double, under which two distinct 64-bit integers above 2⁵³ share one representation — and a canonicalizer that gives two different values one byte string gives two different effects one key. Inside ±2⁵³ the two rules agree, so nothing interoperable is lost; outside it, I-JSON draws the same line.

Sorting is recursive: nested objects are canonicalized by the same rule.

canon(  {"b":1,"a":{"d":2,"c":3}}  )  =  {"a":{"c":3,"d":2},"b":1}

4. The record body

A record body is a JSON object: the envelope members below, then the payload members of exactly one kind, flattened into the same object.

MemberTypePresence
seqintegeralways
runid string, run_ + ULIDalways
caseid string, case_ + ULIDomitted when absent
stepintegeromitted when absent
phase"forward" or "compensating"omitted when forward
epochintegeralways
vintegeralways
effect_keydigest hexomitted when absent
kindone of the vocabularyalways

phase is omitted rather than written when it is forward, so the overwhelmingly common record costs no bytes and no hash input.

Unknown members are refused, in both directions. A body carrying a member this reader does not know — at the top level, or inside any nested object whose shape the record vocabulary defines (a descriptor, a label, a spend, a principal, an operator, a correlation key, a suspension reason, an agent identity, and every other) — is an error, not a member to skip. The caller’s own JSON a record carries verbatim (input, output, args, plan) is a value, not a shape, and holds whatever members it holds. That is the opposite of what a message format does, and deliberately: a record is evidence. Its members are the inputs to an authorization, retry or recovery verdict, so dropping one is reaching a conclusion over evidence the reader did not see. The same argument forbids reading a missing v as “the current shape”.

A record body is at most 1 MiB of canonical bytes. A writer refuses a larger one rather than truncating it; bytes that do not fit belong in a blob store addressed by digest.

5. The hash chain

Let raw be the canonical bytes of the record body and prev the previous record’s hash. Then

hash = SHA-256( prev ‖ raw )

with prev the 32 raw bytes of the digest — not its hex form — and raw the canonical UTF-8 JSON. The first record of a run uses prev = 32 zero bytes.

Because prev is fixed-width, the concatenation is unambiguous and needs no length prefix.

Verification never re-serializes. A verifier hashes the bytes it was given and compares; it does not parse the body and canonicalize it again. This is the load-bearing rule of the whole format: if the chain were taken over a re-serialization, then the first time a reader’s canonicalizer differed from the writer’s — a version, a library, a locale — every historical hash would move, and tamper evidence would be destroyed by an upgrade. Reading a record at a newer shape is a view; the chain is over history as written.

6. Record signature

A record may carry a signature. It sits beside the hash, never inside the body — a signature inside the body would make the hash cover the signature that covers the hash.

signing input = SHA-256( domain ‖ 0x00 ‖ hash )
domain        = "io.github.hupe1980.agentplane/record/v1"

hash is the 32 raw bytes from the chain. The 0x00 separates the domain from the payload; the domain string contains no NUL, so the split is unambiguous.

The signature is Ed25519 over that input. It travels as

{"key_id": "...", "signature": "<hex>"}

key_id names the key, not the algorithm: a self-described algorithm is a downgrade attack waiting to be written, so a verifier resolves the algorithm from its own trust configuration.

Because the hash chains, one signature transitively commits to every record before it: rewriting any part of the prefix invalidates every later signature, not only its own.

Two sibling domains exist and must not be confused with this one — they are signed by the same key: …/manifest/v1 and …/provenance/v1.

7. The Merkle log

A per-run chain leaves one gap: deleting an entire run leaves every remaining run verifying perfectly. What closes it is committing to the set of runs.

Leaves are the terminal chain hashes of sealed runs, in seal order. Hashing is RFC 6962, unchanged:

leaf(d)        = SHA-256( 0x00 ‖ d )
node(l, r)     = SHA-256( 0x01 ‖ l ‖ r )
root([])       = SHA-256( "" )
root([x])      = x
root(xs)       = node( root(xs[..k]), root(xs[k..]) )    k = largest power of two < |xs|

The prefix bytes are not decoration: without them a leaf can be made to collide with an interior node, and an attacker who controls leaf content can present a subtree as a leaf.

The split is at the largest power of two below the length, not at the midpoint. That is what keeps a tree’s left subtree stable as the log grows, which is what consistency proofs between two checkpoints rely on.

The empty root is SHA-256("") and not thirty-two zero bytes, because zeroes are also what an uninitialised buffer, a default-constructed struct and a truncated read produce.

Checkpoint

{"origin": "...", "size": 1234, "root": "<hex>"}

A checkpoint claiming size 0 beside any root other than the empty root describes a log that cannot exist and must be refused. A witness has no prior memory to check a first submission against, so one incoherent size-0 submission would poison an origin permanently.

Its text form is the C2SP tlog-checkpoint note body — origin, decimal size, base64 root, each on its own line, with a trailing newline:

example.com/agentplane
1234
irNutGGI+kpEEwQrBb3xjEEWKohYs7xnm603736/WBQ=

The size is a canonical decimal number: no sign, no leading zero, no surrounding space, because each of those is a second spelling of one log.

Cosignature

A witness vouches that it saw a checkpoint by cosigning its note body. A cosigned checkpoint travels as a C2SP signed-note: the body, one blank line, then one line per signature.

<body>
                                         ← the blank line; not part of the body
— <name> <base64( key_id ‖ payload )>

Each signature line begins with an em dash (U+2014) and a space; the name ends at the next space; the rest is standard base64 with padding. A note with several lines carries several signatures over the same body.

For an Ed25519 cosigner (C2SP tlog-cosignature):

key_id    = SHA-256( name ‖ 0x0A ‖ 0x04 ‖ public key )[0..4]
payload   = timestamp (8 bytes, big-endian, seconds since the Unix epoch)
          ‖ signature (64 bytes)                         — exactly 72 bytes
message   = "cosignature/v1\n" ‖ "time " ‖ decimal(timestamp) ‖ "\n" ‖ body
signature = Ed25519( message )

body is the checkpoint note above, including its final newline and excluding the blank line and every signature line. The timestamp in the message is the payload’s own, in canonical decimal. There is no domain hash: the cosignature/v1 header is the domain separation, and it is what keeps a cosignature from being read as the log’s own note signature (type 0x01), which covers the body alone.

A line is a cosignature only when all of these hold:

  • its name and its four-byte key id both match one trusted key — a line whose name matches and whose id does not, or the reverse, is ignored;
  • its payload is exactly 72 bytes;
  • its timestamp is not zero and is at most 2^63 − 1;
  • the signature verifies over the message under that key.

A note none of whose lines is a cosignature is a checkpoint the operator could have produced, however many lines it carries.

Freshness. The timestamp is the witness’s own claim about when it saw the log, covered by its signature. A verifier holding a maximum age judges it per witness key: the latest timestamp among that key’s verified cosignatures over every anchor it was given. Older than the maximum before the verifier’s clock, or ahead of the verifier’s clock by more than the maximum, is a finding naming the key. Keys are never compared with each other. Only a timestamp whose cosignature verified is judged; an unverified one is an unauthenticated number. A suffix appended and removed after a key’s latest time is not detectable — freshness bounds when truncation could have happened, not whether.

8. Sealed payloads

Where a key ring is configured, a payload is replaced in the record before it is hashed, so the chain commits to ciphertext and destroying the wrapping key reaches every copy at once — including one somebody exported last month.

A sealed JSON payload is the object {"$sealed": "<base64 envelope>"}; a sealed string field is "$sealed:<base64 envelope>".

What is never sealed, and why you can rely on it. Two classes stay readable with no key at all. The first is everything the runtime routes on — the record kind, seq, run, case, effect_key, a disposition, a conclusion’s outcome — so exactly-once, the case scan and the chain all work against an erased journal. The second is who did it: every operator act names its actor in the clear, and where the act was a person’s judgement rather than a machine’s, their own account of it is clear beside the name. A run whose payloads a lawful erasure destroyed still answers who cancelled this, who withheld the authority, who crossed under break-glass, who declared an undecided effect landed and on what grounds, and who approved the action it took. What goes is what a provider, a tool or a model said — free text over the caller’s values, which is what the erasure was for.

The envelope is binary:

byte  0      format version (1)
bytes 1..5   wrapped-key length, u32 big-endian
bytes 5..5+n wrapped key, canonical JSON
next 24      XChaCha20-Poly1305 nonce
rest         ciphertext ‖ Poly1305 tag

The version leads, so no offset is trusted before the layout is known. A reader that cannot interpret the version reports a build skew, not tampering: those two reach different people.

The wrapped key is canonical JSON with the members scope, wrapped_by and sealed, and a member this reader does not know is refused — the same rule a record body is held to, for the same reason. This header travels with the payload it sealed, so a reader that skipped a member would unwrap under parameters somebody else wrote down. Unlike a record, nothing has established the bytes at that point — the tag that would is inside the payload — so the refusal cannot claim which of the two causes it is, and a drill that meets one must say so rather than call it tampering.

Sealed bytes are rotation-immutable. The chain commits to the envelope, which carries the wrapped key inline, so re-wrapping is not expressible — the erasure scope is the rotation unit.

This one envelope is what every sealed store keeps, not only record payloads. Each seals to associated data naming what the bytes belong to, so an envelope copied elsewhere fails to authenticate rather than opening as another row’s data. A sealed blob’s is blob:<scope>:<digest hex>; a sealed memory’s is the canonical JSON array ["memory", tenant, id, version, subject, purpose], and a memory row’s content is the sealed JSON payload above, with no plaintext digest beside it.

9. The export file

JSON Lines, UTF-8, one object per line, in this order:

  1. exactly one header
  2. for each run: one run block, then that run’s record lines in seq order
  3. zero or more case blocks
  4. exactly one trailer

Framing lines carry a kind member; record lines do not. That is the dispatch rule, and it is the only one: a line whose top-level kind is one of the framing names is that kind of frame, and a line with no top-level kind is a record belonging to the run block above it. A disclosure package is the same file with its own first line. A record’s own kind is inside its body, one level down, and must not be mistaken for the frame’s.

A framing member this reader does not know bounds the verdict; it does not fail it. This is the opposite answer from the one a record body gets, and the difference is what covers the bytes. A record’s members are inside the hash, so skipping one reaches a verdict over evidence the reader did not see and is refused. A framing line is not hashed and carries no evidence of its own — the claims on it (a checkpoint, a leaf, the trailer’s accounting) are each checked against something else — so a member added by a later writer falsifies nothing already established. What it can do is carry one more claim, so a reader that passed over it reports sound about a file it read part of. The rule is therefore: verify as normal, and say which members you did not account for. Both implementations of this specification do so.

{"kind":"agentplane.export","version":1,
 "checkpoint":{"origin":"…","size":1,"root":"<hex>"},"canon":1}
MemberType
kind"agentplane.export"
versionthe export format’s version, 1
checkpointorigin, size, root — see checkpoint
canonthe canonicalization rule the digests were computed under

Run block

{"kind":"agentplane.export.run","run":"run_<ulid>","index":0,"seal":"<hex>"}
MemberTypePresence
kind"agentplane.export.run"always
runid string, run_ + ULIDalways
indexinteger, the position in the Merkle logomitted for an open run
sealdigest hex, the run’s terminal chain hashomitted for an open run
proofarray of digest hex, leaf-upwardsin a package only, with index

index and seal are present together or not at all. Their absence means the run is not in the log, which is a state and not a gap.

A run sealed after the header’s checkpoint was taken is exported as open. The writer omits its position rather than stamping one the checkpoint does not commit to, because a verifier that rebuilt a tree one leaf larger than the root it compares against would report tampering where there was only time. The next export carries it sealed.

Record line

{"seq":2,"body":{…},"prev_hash":"<hex>","hash":"<hex>","signature":null,"raw":"…"}
MemberTypePresence
seqintegeralways
bodythe parsed record bodyalways
prev_hashdigest hexalways
hashdigest hexalways
signature{"key_id": "…", "signature": "<hex>"} or nullalways present, null when unsigned
rawstringalways

signature is the plane’s workload-key signature over the record, as record signing describes — a statement of who wrote it, not a hardware attestation of where. It is written as an explicit null rather than omitted, so a reader tells unsigned from a field this export forgot.

raw is the exact bytes the hash covers, carried as a JSON string — canonical record bytes are UTF-8 JSON, so they escape and recover byte for byte. It is the member that makes the file checkable: a verifier that re-serialized the parsed body would be holding the file to its own canonicalizer rather than to the bytes the store sealed. body is a courtesy copy for a reader’s eyes, and a verifier holds the two to each other rather than trusting either alone.

Case block

{"kind":"agentplane.export.case",
 "case":{"id":"case_<ulid>","kind":"…","status":"open","correlation":[…],
         "state":{…},"version":0,"opened_at":"<rfc3339>","runs":["run_<ulid>"]},
 "deadlines":[…],"blobs":["<hex>"],
 "hold":{"placed_at":"<rfc3339>","reason":"…",
         "by":{"actor":"…","basis":"authenticated"}}}
MemberType
kind"agentplane.export.case"
casethe case: id, kind, status, correlation, state, version, opened_at, runs
deadlinesthe case’s obligations, each with case, name, resolved_at, calendar_digest, state and optionally warn_at and acknowledged
blobsdigest hex strings
holdnull, or the legal hold on the matter, with exactly these members: placed_at (an RFC 3339 instant), reason, and by — the operator who placed it, as actor and basis (authenticated, asserted or connected)

hold is required. A restore places it again with its original instant, reason and operator; a reader that finds it missing or malformed reports a finding and a restore refuses the file, because a matter restored without its hold is one the next retention pass erases.

case.id is case_<ulid> — the same spelling a record body’s case member carries, which is what makes the cross-layer check a string comparison.

state travels as stored: sealed on a sealed plane. Exporting plaintext would quietly undo erasure.

The case layer is mandatory rather than an optional extension — a reader that tolerated its absence could not tell this plane has no cases from the case layer was dropped from this file, and the second is the finding that matters. A writer always reads the plane’s case store; one holding no cases exports zero case blocks and says "cases":0 in the trailer. Records naming a case in a file with no case block are a finding like any other missing case.

Blob bytes are never in the file. Presence and integrity of bytes are a question about a live store, which an offline file honestly reports as unchecked.

Trailer

{"kind":"agentplane.export.end","runs_requested":1,"runs_exported":1,
 "records":3,"cases":1,"unreadable":[]}
MemberType
kind"agentplane.export.end"
runs_requestedinteger
runs_exportedinteger
recordsinteger
casesinteger
unreadablearray of {"run": "run_<ulid>", "reason": "…"}

unreadable names the runs the export could not read rather than counting them, because the run that fails to read is not a random one.

The trailer’s absence is the signal that matters. An export cut short by a crash, a full disk or a killed pipe ends without one, so a reader tells a prefix from a whole file without comparing counts against a source it does not have.

Disclosure package

A disclosure package is an export of chosen runs — one matter, not the plane — that says so in its first line:

{"kind":"agentplane.disclosure","version":1,
 "checkpoint":{"origin":"…","size":3,"root":"<hex>"},"canon":1,
 "selection":{"cases":["case_<ulid>"],"runs":[]}}
MemberType
kind"agentplane.disclosure"
versionthe export format’s version, 1 — a package moves with the export format
checkpointthe checkpoint every proof in the file is against
canonas in the header
selectioncases and runs, arrays of ids, as asked; at least one is non-empty

The rest of the file is an export’s lines, with three differences:

  • each sealed run block carries proof, the sibling hashes that prove its seal at index in the tree of checkpoint.size leaves;
  • the case layer is the selected cases plus every case a carried record names — not every case the plane holds. Each carried case travels as its whole block: state, deadlines, blob digests, hold reason, and the ids of every run it holds, including runs the package does not carry;
  • runs_requested counts the runs the selection resolved to.

Every run the package carries whose conclusion seals is placed: one sealed after the writer’s checkpoint is written again against a later one, and a writer that cannot place it refuses rather than carry it open.

A case contributes the runs it held when the package was written. Records travel as stored, sealed where the plane seals; a package carries no key material. A reader that rebuilds or re-derives over a whole file — a restore, a replay, a policy check, a grant report — refuses a package by name.

10. Verifying an export

An implementation that does the following has verified the file.

  1. Header. Refuse an unknown version. If canon is not a rule this reader implements, stop here: every digest below is unverifiable rather than wrong, so the report is neither sound nor a finding, and says unverifiable (unknown canon). agentplane verify and tools/verify_export.py both exit 6 for it, and agentplane restore refuses such a file with the same status before opening the store. A size of 0 beside any root other than the empty root is a checkpoint describing a log that cannot exist. The header is the first non-empty line and only it: a header of either kind on a later line is a finding and is ignored, because it would re-choose the checkpoint and the rules for every run after it.

  2. Per record. Recompute SHA-256(prev_hash ‖ raw) and compare with hash before parsing raw. Bytes that do not hash to their claim were edited, whatever they parse as. Bytes that do must be canonical under the header’s canon: parse them as a JSON value and canonicalize it, and bytes that differ — a space, a member out of order, a member written twice — are a finding, which a restore refuses. Only canonical bytes that hash to their claim may make a parse failure a build skew — a newer writer’s shape — rather than damage. Then parse raw and compare the result with body — the two must agree, type for type (1, 1.0 and true differ), or the file’s readable half is saying something its hashed half does not. A line escaping a lone surrogate is not JSON this format admits.

  3. Per run. prev_hash of the first record is 32 zero bytes and its seq is 1; every later record’s prev_hash is its predecessor’s hash; seq is contiguous and ascending; and every record’s own body.run is the run its block claims. That last one is not redundant: without it an export could file run B’s records and B’s leaf under A’s id, and chain, seal and root would all verify B’s bytes. Only the label lied, and the label is what a reader looks a run up by. A RunConcluded record’s chain_head must equal its own prev_hash — the head the conclusion was drawn over is the head it sits on, and a conclusion claiming another was composed against a different history.

  4. Signatures, where present: verify the record line’s signature as record signing describes, against a key set the verifier holds. A record with no signature is unsigned, which is a state; a strict verification refuses it, and stripping signatures must not be a way to pass. A verifier that does not check signatures must say so in its report.

    The format publishes three signatures, and both readers check all three against keys the auditor supplies, in three disjoint sets:

    Signatureagentplane verifytools/verify_export.py
    Record--key KEY_ID=HEX--key KEY_ID=HEX
    Witness cosignature on a note anchor--checkpoint NOTE --witness-key NAME=BASE64, or a --witness fetchthe note as an anchor argument, --witness-key NAME=BASE64
    Grader verdict--grader-verdict FILE --grader-key KEY_ID=HEX--grader-verdict FILE --grader-key KEY_ID=HEX

    A key is resolved by the key_id (or name and key id) the artifact names, never by trying every supplied key. Without a set’s keys, each reader says that signature was not checked. Freshness is judged by the second reader under --max-checkpoint-age SECS, and on the Rust side by agentplane audit, not verify. Checking costs one Ed25519 verification per signed record, cosignature line and sidecar: linear in signed records, milliseconds each in the second reader.

  5. The log. For each sealed run, seal must equal the run’s terminal hash. Then leaf-hash every seal in index order and compute the root as the log describes.

    A block is sealed only when it carries both an integer index and a seal that parses. One carrying either alone, or a seal that does not parse, claims a position nothing can check: a finding, and the run is not sound. A run carried in two blocks is a finding and the run is not sound; one index claimed by two blocks is a finding and the later claimant is not sound. Both hold in a whole export and a package alike.

    Three ways the set can fail to be checkable, and they are different findings:

    • The positions are not the contiguous 0..size the checkpoint commits to — one is duplicated, missing, or at or beyond size. That file describes a different log than the one it names. Do not compute a root over it: a tree built on duplicated positions compares garbage and reports the wrong defect.
    • The file carries fewer runs than size. A partial export’s chains all verify and its set cannot be checked; say so, rather than reporting either a pass or a root mismatch.
    • The positions are contiguous and complete: compute the root and compare.
  6. The root, against a checkpoint from somewhere else. This is the step that decides what the comparison in 5 is worth, and it is the one most easily skipped.

    Comparing the rebuilt root with the checkpoint in the file’s own header proves the file is internally consistent — which is also exactly what an editor who dropped a run and rewrote the header achieves. The rebuild only becomes evidence about deletion when the checkpoint came from outside the file: one an earlier audit printed, one a witness cosigned, one from a ticket. A verifier given no such checkpoint has not checked for deletion and must report that it did not, rather than reporting a pass.

    An outside checkpoint is an origin, a size and a root, and the size decides what it can settle:

    • Of another origin, or larger than the file’s header: the file cannot be part of that history. A finding.
    • At the header’s size: the roots must be equal; one tree of a given size has one root, so unequal roots are two histories. A finding.
    • Smaller than the header, size m: the file carries every leaf from position 0, so it can rebuild the tree of its own first m leaves. Compute that root over positions 0..m and compare. A mismatch — or a position missing below m — is a finding: a run inside the prefix was removed, replaced or moved. A match anchors the prefix only; the report must say that the leaves from m onward are held to the file’s own header.

    And a verifier MUST report, once, how many open runs the file carries — a run block with no index and no seal. The root proves nothing about one, so its chain was checked and records cut from its tail before the export was taken are undetectable from the file. A clean report over a file that is mostly open runs has established much less than the same report over a sealed one, and only the count says which.

    And the limit that survives even a checkpoint from outside: the checkpoint commits to sealed runs only. A run still in flight has no position in the log, so a file carrying none of them rebuilds to exactly the same root at exactly the same size as one carrying all of them. A clean verdict here therefore says nothing about whether the work that was in progress is in the file — which is the work a restore is usually for. That is a property of what a Merkle log over sealed runs can prove, not a defect in the verifier, and the only place it can be answered is the producer’s selection: a writer exporting for recovery has to ask the store for runs that have not concluded, because no outcome index names them.

  7. Cross-layer. A record naming a case the file does not carry is a finding.

  8. The trailer. No trailer means a prefix. A non-empty unreadable means the export is complete as an artifact and incomplete as a history, and the two must not be reported the same way: a run the trailer names there is unchecked, not tampered with. The counts hold the frame to the file:

    • runs_requested is the number of run blocks;
    • runs_exported is the number of run blocks carrying at least one record;
    • records is the number of record lines, and cases the number of case blocks.

    A disagreement is a finding. A run block with no records is one only when the trailer’s unreadable does not name its run — no honest writer produces one, because a run it cannot read is filed there instead — and it is a finding whether or not the block is sealed: without this rule a sealed run could be emptied of every record, with the counts adjusted, and still verify, because an empty block has no terminal hash to compare with its seal.

Verifying a package

A package is verified by steps 1–4, 7 and 8 as written, and by these in place of 5 and 6:

  1. Each leaf by its path. For each sealed run, seal must equal the run’s terminal hash, and proof must prove leaf(seal) at index in the tree of checkpoint.size leaves whose root is checkpoint.root — the inclusion check of RFC 9162 §2.1.3.2 over this log’s hashes. A run whose path is missing or does not prove its leaf is a finding about that run. No count, contiguity or root rebuild applies: the leaves a package leaves out are its purpose, not a deletion.
  2. The header against a checkpoint from somewhere else. At the header’s size the roots must be equal; of another origin it is a finding. At another size the package carries no consistency proof, so the reader reports that checkpoint as not compared. Without one, the paths were proved against the file’s own header, and the report says so.

The report states that the file is a package, how many leaves the log holds, and how many the package proves — nothing about the others is in the file. A cosignature on the outside checkpoint is checked as for a whole export.

Also, in a package:

  • a run whose last RunConcluded names an outcome that seals, under a block carrying no index and seal, is a finding — the writer places every sealed run it carries, so a missing leaf was removed;
  • a case the header’s selection names and the case layer does not carry is a finding.

A package proves the inclusion of what it carries, never its completeness: a run of the matter left out of the file, or a case the selection did not name, is not visible in it. Only the plane’s own register and journal can say what a matter held.

Only the first line decides which rules a file is read under. A file whose first line is an export header is never read under these rules, and a run block carrying proof there is a member this format does not know; a disclosure header on a later line is a finding and is ignored.

A grader’s verdict beside an export

A grader-verdict sidecar binds a verdict from outside the plane to a prefix of one run. It is a file beside an export, never a record, and one JSON object:

MemberTypeMeaning
kindstringalways agentplane.grader-verdict
versioninteger1
runstringthe run judged
last_seqintegerthe last record of the judged prefix
last_hashhexthe chain hash of the record at last_seq
openbooleanfalse claims the run was sealed with last_seq as its last record
contentbase64the verdict, never interpreted
signatureobject, optional{key_id, signature} — Ed25519 over the signing input below

Any other member refuses the file. last_hash commits to records 1..=last_seq and to nothing after them, so the binding is a prefix commitment: an edit anywhere at or before last_seq refuses it, and records appended later do not. The admission the run was judged under — declaration, policy bundle, canon — is inside that prefix and is read from the export, not carried twice.

signing input = SHA-256( domain ‖ 0x00 ‖ SHA-256( canonical(sidecar without signature) ) )
domain        = "io.github.hupe1980.agentplane/grader-verdict/v1"

A reader refuses a sidecar whose run is absent or does not verify, whose last_seq names no record present, whose last_hash differs from that record’s chain hash, or that claims open: false for a run the export does not show sealed at last_seq. open: true is the weaker claim and is never refused on the flag. agentplane verify --grader-verdict also checks the signature against --grader-key, and says not checked without one; tools/verify_export.py does the same under its own --grader-key. A bound sidecar proves which records a verdict names and which key signed it — not what the grader saw, and not that the verdict is right.

11. Conformance vectors

Machine-readable conformance files ship in the repository:

FileWhat it pins
tests/golden/records.jsonlOne canonical record per kind, with its chain digest under prev = 0
tests/golden/export.jsonlA complete sealed export that every build must still verify offline
tests/golden/package.jsonlThat journal’s case disclosed from a log of three runs: one leaf, two siblings
tests/golden/export.signed.jsonlThe same journal with every record signed
tests/golden/checkpoint.cosigned.noteThat export’s checkpoint, cosigned by one witness at a fixed time
tests/golden/export.grader-verdict.jsonA signed grader verdict bound to a prefix of its run
tests/golden/keys.txtThe public half of each fixed test key, in the form each reader’s flag takes

Each is produced by the functions the runtime writes through, and regenerated deliberately with AGENTPLANE_BLESS_GOLDEN=1 cargo test --test trust format:: — a typed command, because a shape change is a hard cut rather than a diff.

They are this build checking itself, which catches drift and cannot catch a shared misunderstanding. tools/verify_export.py is the second reader: it implements this document, reads none of the crate’s Rust, verifies the export and re-derives every record vector from its parsed value. It verifies the signed files’ signatures with its own Ed25519, written from RFC 8032 §5.1, and checks that verifier against RFC 8032’s own test vectors. just verify-golden runs it. The signing keys behind the signed files are fixed test material, never a deployment key.

12. Algorithm agility

Every hash and signature here is fixed by this version of the format. Replacing one is a version bump, never an in-place rewrite: a reader dispatches on the version an artifact carries, and stored bytes are never rehashed, so history stays verifiable under the algorithm that wrote it.

Hashes are agile by version, because a hash belongs to a construction rather than to anybody’s key:

ConstructionGoverned by
Record digests, the chaincanon, on RunAdmitted
The domain-separated digests below, effect keys among themcanon, and each domain’s own version
Sealed envelopesenvelope byte 0
Export framingthe export header’s version
Merkle leaf, node and rootthe checkpoint’s origin

canon names the whole derivation — the canonical-JSON rule and the digest algorithm — because the two only ever move together: a reader that cannot reproduce a digest cannot check a chain, whichever half changed. A run under an unrecognised canon is unverifiable by that reader, never divergent.

A log’s parameters are fixed for the life of its origin, which is what the checkpoint format assumes, so a new Merkle hash is a new origin and the old log stays checkable under the old one.

Signatures are agile three different ways, and the differences are deliberate rather than accidental:

  • By key, for record signatures and checkpoint cosignatures. A record signature names a key_id and not an algorithm, because an artifact that declares its own is a downgrade waiting to be written; a verifier knows which algorithm each key it accepts carries.
  • By a named scheme, for webhook signatures: v1, is HMAC-SHA256, and a sender mid-rotation offers more than one.
  • Not at all, for the Agent Card’s JWS. alg is compared against a constant, never read from the card, so a card declaring another algorithm is refused rather than honoured. Changing it is a release of this crate.

Domain-separated digests

Three digests on records identify something other than bytes a reader holds: the effect a record belongs to, the policy that decided, and the calendar that resolved an instant. Each is domain-separated, and each domain carries its own version — so the enumeration below is what says a domain moved, since nothing dispatches on one.

OnDerivation
effect_key, on every effect recordSHA-256( "agentplane.effect.key.v1" ‖ 0x00 ‖ step ‖ phase ‖ ordinal ‖ attempt ‖ len(kind) ‖ kind ‖ canon(args) ), integers big-endian — step, ordinal and attempt as 4 bytes, phase as 1, len(kind) as 8
RunAdmitted.policy_bundleSHA-256( "agentplane.policy.bundle.v1" ‖ 0x00 ‖ canon(identity) )
DeadlineRegistered.calendar_digestSHA-256( "agentplane.calendar.wallclock.v1" ), for the wall-clock ruleset

Before the format freezes, a derivation changes without a version moving. Every version here stays at 1 until the freeze, and a shape change is a hard cut, so a journal written by a build that derived keys differently carries keys this build does not derive. Nothing refuses such a journal — its records still verify, because a key is a value a record carries, not one a verifier recomputes — but resuming one diverges at its first recorded effect. The remedy is to recreate the run, not to resume it. After the freeze, a derivation that moves takes a new domain version, and this table names it.

identity is a JSON object with rules and evaluator always present and schema, entities and configuration present only when the bundle has them: the first four are hex digests over the bundle’s own components, and evaluator is a string naming the decision semantics — cedar-lang/<language version>;agentplane-adapter/<n>;extensions=… for the Cedar adapter. It names what can change an answer rather than what is linked: an evaluator’s crate version moves on releases that change no decision, and a digest that moved with it would refuse every open run’s resume for nothing.

A deployment’s own calendar names its own domain. There is no registry: a verifier compares these digests across records, it does not re-derive a decision from one, and a digest whose domain it has never seen leaves that run unverifiable by that reader — the same finding an unknown canon produces, and never a divergence.

Content addresses do not migrate. A blob’s address is its digest, so a new algorithm yields new addresses and existing blobs keep theirs — nothing needs re-addressing, because a record names the digest it always named and that run’s canon says what produced it.

Vocabulary

There are 33 record kinds. A verifier does not interpret them; a reader that does must refuse one it has never heard of, for the reason the record body gives.

RunAdmitted, QuotaPassStarted, PlanFrozen, StepStarted, StepFinished, Note, EffectStarted, EffectDone, EffectFailed, EffectReconciled, StepCompensated, QuarantineDecided, GroupOpened, GroupSettled, BudgetRefused, BudgetReadmitted, AuthorityWithheld, AuthorityRestored, IdentityBound, DataSubjectBound, PolicyDenied, RunSuspended, CaseBound, DeadlineRegistered, DeadlineTransition, Released, RunCancelled, RunConcluded, BreakGlass, HaltLifted, HoldReleased, Swept, Observed.

One of them is not this plane’s own work, and a reader must not treat it as such. Observed records what an agent this plane does not execute reported doing — it is that agent’s account, at the asserted rung, where every other kind here is deterministically attached: this runtime announced the effect, dispatched it under authority and recorded the outcome. It shares the chain, the canonical form, the Merkle log and the witness with everything else, and it shares no kind with a dispatched effect, so a reader that takes an Observed record as evidence an effect happened is making a claim the format does not support. Such records live in a run of their own with no RunAdmitted at all, sealed under the outcome observed — the shape a sweep’s run already has.

A label may name data subjects, and never a person. A label’s data_subjects member — omitted when empty — lists {run, index} references to entries of a run’s DataSubjectBound, whose subject is a sealed payload field. The reference is attribution: no gate reads it, and a policy request carries none — not under context.label, and not in a label embedded in context.args, such as task.open’s justification, though the journaled descriptor keeps them.

A content verdict is on the record; matched text never is. Where a declared content rule matched an arriving output, EffectDone.content holds the rules that matched, the raised sensitivity and the refusal — a rule id and a JSON pointer whose matched object keys read *. EffectStarted.content_rules lists the rules a sent value was judged by, matched or not. A refusal at a sink is a PolicyDenied whose action is effect:content, which no policy engine is ever asked. All three are clear: they are the deployment’s declaration, not the caller’s data.

How long a call took is on its outcome. EffectDone.elapsed_ms and EffectFailed.elapsed_ms are the wall time this process measured around the call — clear, absent where nothing was performed live, and read by nothing a run decides.

Whom a hop’s credential named is on the record; the credential never is. EffectStarted.credential, on a call to another party, is {kind, audience} with kind one of subject (with a subject member: the principal id the credential was obtained for), unbound (a credential held for the peer, naming nobody) or plane (the plane’s own credential, for a run admitted as the plane). It is clear: an audience and a principal id already clear on the run’s chain.

Two pairs supersede rather than replace. BudgetReadmitted follows a BudgetRefused and AuthorityRestored follows an AuthorityWithheld; in both cases the earlier record stays in the chain and the later one says it was overturned. An AuthorityWithheld is run-level when a step boundary wrote it, and carries an effect key when a hop about to present a credential did; that hop’s later EffectStarted at the same key supersedes it. A reader reconstructing a run’s state takes the last word — a refusal followed by a continuation is a decision somebody made, not a contradiction, and treating the earlier record as final reports a run as stopped whose own later records show it finishing.

Each kind’s member set is pinned by tests/golden/records.jsonl, one line per kind. That file is the normative statement of the payloads: prose listing them here would be the same facts in two places, and the copy that drifts is always the second one.

What this format does not promise

Store encodings are not specified here. The journal is the record and the stores are indexes derived from it; a store is rebuilt by restore, which reads this format and proves the result by equal Merkle roots at equal size. That is a weaker promise on purpose, and it is written down here rather than left to be inferred from silence. What a store writes beside the data it indexes is its own business: the object store’s erasure tombstone, for one, does carry a version, because it outlives the bytes it describes and a reader that cannot interpret one must say so rather than report an erasure it invented.

A signature names who vouched, not when or that it is true. A verified record signature says a key signed that chain hash; a verified cosignature says a witness saw that checkpoint at the time it signed; a verified grader signature says a grader signed that verdict. Both readers check all three (step 4); neither says whether the signer was right. Truncation after a witness’s latest time is bounded by freshness, not ruled out.