Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The canonical source is spec/SPEC.md in the repository. Status: v0.1 (document version); the wire schema_version it specifies is 2 — a different number, see §12. The record file’s extension is .aarl (ADR-0014), so examples write session.aarl.

Where this document and the code disagree, the code is the defect report.

Agent Action Record — format specification, schema version 2

Status: v0.1 (2026-08-28) — this document’s own version; it tracks the code shipped by AILAB-619 · Wire schema_version: 2 — the number written inside every record, and a different counter (§12) Cite as: Agent Action Record format specification, document v0.1, schema_version 2 — spec/SPEC.md in botzrDev/aegis. Normative source of truth: the record types in crates/botzr-aegis-core/src/audit.rs, the canonicalizer in crates/botzr-aegis-core/src/jcs.rs, and the writer in crates/botzr-aegis-audit/src/writer.rs. Where this document and the code disagree, the code is the defect report.

This document specifies a file format, not an implementation. A third party should be able to write an emitter or a verifier from it. Everything stated as MUST is checked somewhere in this repository; everything this format does not guarantee is stated as plainly as everything it does. That asymmetry is deliberate — an evidence format that overclaims is worse than no format, because the overclaim is what gets quoted.

The decisions behind this format, with the options that were rejected and why, are in docs/adr/ — ADR-0001 through ADR-0006 are the ones this document implements.

Not specified by this version, deliberately:

  • Key lifecycle — where a private key lives, its permissions, how it is generated. Deliberately out of the format (ADR-0004), not merely deferred: what a Line carries is a key_id and a published public_key, and a verifier needs nothing else. The implementation is specified in crates/botzr-aegis-audit/README.md. Rotation is the one part that is format surface, and it is normative in §8.4.
  • Envelope reader/writer behaviour beyond the boundary in §9. No Envelope code ships in this version.
  • Argument matchers and the Bindings that produce them. That work was canceled, so this is a permanent omission rather than a deferral.

aegis recheck is specified, as of AILAB-622: aegis recheck --policy <YAML> <PATH> re-evaluates every recorded outcome in <PATH> against the Policy Set in <YAML> and prints a would-block diff, exiting 0 when every call is unchanged, 1 when a call is newly blocked, allowed or parked, 2 when the policy or the record could not be read, and 3 when any call is indeterminate — reading the Chain only, opening no Envelope (§9) and executing nothing.

License

This specification is dual licensed — SPDX Apache-2.0 OR MIT — under either the Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or the MIT license (LICENSE-MIT), at your option. Same terms as the reference implementation in this repository.

Apache-2.0 is offered because this document is meant to be implemented by other people: writing an emitter or a verifier from it, and shipping that, should not depend on a handshake about patents. The Apache-2.0 branch carries an explicit patent grant; take that branch if your organisation needs one. Neither branch requires attribution to Aegis in a record you emit — the format is deliberately vendor-neutral (ADR-0008), and nothing here conditions conformance on naming the reference implementation.

Applies to this document and to any release of the workspace cut after 0.3.0. The botzr-aegis-* crates published to crates.io at 0.3.0 carry MIT in their registry metadata and remain MIT as published; see ADR-0011.

The aegis verify command surface

Deliberately unnumbered: this is the command surface the format document pins in full, and it sits ahead of §1 so it cannot be mistaken for a property of the format. Sections 1–12 keep the numbers they have always had.

aegis verify is specified, as of AILAB-621. The verdict model stays in §8, because it is a property of the format. The command that prints it now ships, and its surface is fixed here because CI gates script it:

aegis verify [--key <HEX>]... [--trust-store <PATH>] <PATH>

<PATH> is the record file; any path is accepted. .aarl (§1.1) is the format’s extension, but it is a naming convention and this command deliberately validates nothing about it. --key is repeatable and takes a public key in the same 64-lowercase-hex wire form the open Line publishes — not a key_id fingerprint. --trust-store names a file of those keys, one per line, with blank lines and # comment lines skipped. The union of the two is the trust slice. Supplying neither flag is an unpinned walk (§8.4); supplying either one is a pin, and a --trust-store that yields no keys is a pin that nothing can satisfy — the first open Line is Tampered with an untrusted-key reason. A store that has been truncated or mis-mounted MUST NOT read as “unpinned”, or the CI gate it anchors keeps passing with its anchor gone.

Exit codes are API and there are exactly four:

ExitMeaning
0Verified
1Tampered, or a usage error — an unknown flag, a --key that is not 64 hex, a missing <PATH>
2The record file or the trust store could not be read
3Indeterminate, with the typed reason printed

stdout is deterministic — same bytes, same report, with no timestamp and no path in it. The first line is the verdict and, on success, its trust label; then one key_id line per observed key, one coverage line naming the Session and seq of the highest position a valid signature reached (§8), and one in_flight line per in-progress Call when the reason is an unanchored tail. Empty sections are omitted. A file that cannot be read produces empty stdout, error: … on stderr, and exit 2.


1. Model

A Chain file is a sequence of Lines. Each Line is one JSON object on one line (JSON Lines). Lines are append-only; nothing in this format ever rewrites a Line that has been written.

A Session is a contiguous run of Lines beginning with an open Line. One process that holds one writer is one Session. A file may hold many Sessions appended over time — a fresh Session per process start is the normal case.

A Call is one tool invocation. It contributes an intent Line before execution and exactly one outcome Line on every exit path, including denial, trap, resource exhaustion, panic and abandonment. Calls interleave: a Call’s intent and outcome are not adjacent, because a writer serialises concurrent Calls through one lock and other Calls’ Lines land in between.

An Envelope is an optional, separate, unsigned store of verbatim request bytes, content-addressed by request_digest (§9). It is not part of the Chain and no Envelope implementation ships in this version.

The Chain is the publishable artifact. It is what gets handed to an auditor. Read §10 before assuming that makes it non-sensitive.

1.1 The record file

Storage is JSON Lines: one JSON object per line, \n-separated, no trailing comma, no enclosing array. A trailing newline after the final Line is permitted; empty lines are ignored by a verifier.

The bytes written for each Line are its canonical form (§3). An emitter does not write one serialization and hash another. This removes the step where two implementations could disagree about what was hashed, and it means a reader can recompute a Line’s hash from the bytes on disk without re-canonicalizing — although a verifier SHOULD canonicalize anyway, so that a foreign emitter’s spacing cannot change a Line hash.

The file extension is .aarl (ADR-0014) — the AAR acronym followed by the l of JSON Lines. Examples in this document write session.aarl.

The extension is a convention for humans and their tooling, not a wire fact. Nothing in the format is carried by the filename, and nothing in this repository reads one: aegis verify and aegis recheck accept any path. A verifier MUST NOT reject a record file because of its name — the format states no requirement on it, and the file’s contents are the whole of the evidence.


2. Wire types

Wire formMeaning
digest64 lowercase hex characters (SHA-256). Uppercase is rejected, not normalized — one digest must have exactly one spelling, or two canonical forms of the same Line hash differently and a verifier disagrees with the emitter for no visible reason.
public key64 lowercase hex characters — a 32-byte ed25519 public key.
signature128 lowercase hex characters — a 64-byte ed25519 signature.
integerJSON number, non-negative, u64, strictly below 2^53 (§3.2).
stringJSON string.

Digest-valued fields are distinct types, not interchangeable strings: prev_hash, policy_set_hash, request_digest, response_digest, key_id. An implementation SHOULD make transposing them a type error rather than a convention — a Chain that hashes the policy set into prev_hash verifies clean while being wrong, and the shipped Rust types make that swap fail to compile (crates/botzr-aegis-core/src/digest.rs).


3. Canonical form — RFC 8785 JCS over a constrained value space

Every hash and every signature in this format is computed over the RFC 8785 (JCS) canonical JSON form of a Line. Storage stays JSON Lines; JCS defines the hash input (ADR-0003).

3.1 Canonicalization rules

  1. Object members are serialized in ascending order of their key’s UTF-16 code unit sequence, per RFC 8785 §3.2.3. This is not UTF-8 byte order. The two agree across the ASCII key set in this specification and disagree above the Basic Multilingual Plane, where UTF-16 surrogates (0xD800…) sort below U+E000. An implementation MUST implement the UTF-16 rule even if its own key set is ASCII, because a future or foreign emitter’s may not be.
  2. No insignificant whitespace: no spaces after : or ,, no indentation, no newlines inside a Line.
  3. Array order is preserved exactly. Arrays are never sorted.
  4. Strings escape only what JSON requires — ", \, and control characters — using \b, \t, \n, \f, \r where those short forms exist and lowercase \u00xx otherwise. Everything else, including all non-ASCII, is emitted literally as UTF-8. / is not escaped.

3.2 Value space — normative

These are requirements, not style. They are what make JCS safe to use here, so an emitter MUST NOT produce a Line that violates them and a canonicalizer SHOULD refuse one rather than guess:

  • No floating-point values anywhere in a Line. ES6 number formatting is the part of JCS that implementations disagree about, so the format removes the disagreement by removing floats. An integral float (1.0) is a float and is not an escape hatch.
  • Integers are non-negative and strictly below 2^53 (9007199254740991). This bound exists for a JavaScript verifier reading seq as a Number, not for Rust. A JS implementation is the realistic third-party verifier and losing precision above 2^53 would break verification in a way nobody would attribute to the format. A field whose natural domain exceeds this (for example a byte limit near u64::MAX) MUST be projected as a decimal string before it reaches the canonicalizer.
  • Absent fields are omitted, never null. A canonical form cannot leave absent-versus-null to the emitter. A literal null anywhere in a Line is invalid. The one field that is always present but may be empty is decision_axes, which serializes as {} (§5.3).

3.3 Why this is safe, and the boundary that keeps it safe

This constrained value space is only viable because payloads live in the Envelope. request_digest is SHA-256 over raw request bytes (§9), so arbitrary user data never reaches the canonicalizer: every key it sees is one of Aegis’s own, and JCS’s genuinely hard cases — float edge cases, unnormalized Unicode in attacker-chosen keys — all live in user data, of which there is none.

Do not relax that boundary without revisiting this decision. Putting request arguments inline would put attacker-chosen keys and arbitrary numbers into the canonical form, and every constraint in §3.2 would stop being reachable.


4. The chain rule

For each appended Line, in this order, inside the same lock that performs the write:

  1. Assign seq — the next position in this Session — and prev_hash — the hash of the Line written immediately before it in this Session.
  2. Compute signing_input = the canonical form of the Line with the signature member omitted and key_id present.
  3. If the Line is in the signed set (§5.1): signature = ed25519 over signing_input; write both signature and key_id into the Line.
  4. Compute line_hash = SHA-256 over the canonical form of the complete Line, signature included.
  5. Write the canonical form as one JSON Lines row; flush; fsync.
  6. Set the Session tail to line_hash. The next Line’s prev_hash is this value.

Step 1 must happen under the same lock as step 5. Two threads that read the chain head outside that lock receive the same prev_hash and fork the chain. This is the single highest-risk defect in an implementation of this format; the shipped writer holds seq, the tail and the file handle in one mutex-guarded struct precisely so they cannot drift apart, and a concurrency test appends from eight threads and asserts a single unforked chain.

line_hash covers the signature, deliberately. Stripping a signature therefore changes the hash and breaks the next Line’s prev_hash, which a verifier reports as Tampered. If the hash excluded the signature, signature-stripping would leave a clean chain.

seq is per appended Line, per Session — never per Call. Concurrent Calls interleave, so a per-Call counter cannot describe a file’s order.

A seq gap over an intact chain is possible and is not tampering: the writer takes seq before the append and advances the tail only after the write lands, so a failed write leaves a gap with the chain intact. See §8.


5. Lines

Every Line, of every type — including a type this specification does not define — MUST carry:

FieldTypeNotes
schema_versioninteger2 for this specification.
line_typestringSee §5.1.
seqintegerPosition within the Session, from 0.
prev_hashdigestHash of the predecessor Line, or genesis (§7).

A Line missing or mistyping line_type, seq or prev_hash is not a Chain Line; a verifier reports the file as Tampered, not as unrecognised. This is what makes the extension story in §5.2 bounded: a future Line type may add anything, but it may not opt out of its position in the chain.

5.1 Line types and the signed set

line_typeSignedEmitted by v0Role
openyesyesFirst Line of a Session. Carries the public key and prev_session_tail.
intentnoyesPre-execution Line for a Call.
outcomeyesyesThe Agent Action Record — one per Call, on every exit path.
decisionyesno — no callerA human approval verdict (ADR-0005).
closeyesyesLast Line of a Session.
checkpointyesno — reservedDefined so that adding it later is not a breaking change.

The signed set is open, outcome, decision, close, checkpoint.

intent is hashed into the chain but never signed. The intent Line is appended and fsynced ahead of execution, so anything added to it — including a signature computation — lands on the pre-execution critical path. It is authenticated transitively instead: the next signature commits to prev_hash, and prev_hash chains back through every unsigned Line before it. In the shipped implementation this is a type-system property, not a rule the writer remembers — the intent type implements the chain trait and not the signing trait, so there is no way to hand it to the signing path (crates/botzr-aegis-audit/src/line.rs).

checkpoint is reserved. No emitter in this repository produces one. A verifier MUST handle one: it is a signed Line, so it extends Coverage — and it still caps the verdict at Indeterminate, because this version does not define what a Checkpoint asserts.

decision is defined but not emitted by v0, which is a different statement from checkpoint’s. A checkpoint is unemitted because this version does not define what it asserts. A decision Line is fully defined: ADR-0005 fixes its semantics, the park-and-resume protocol below describes it, §8.1 makes a second decision for one approval_id Tampered, and a working emitter for it exists in the implementation. What does not exist is the approval protocol that would call that emitter — a pending_approval policy verdict is recorded as an outcome Line and returned to the caller as an error, and nothing in this version resumes a parked Call. The gap is a missing caller, not a missing definition, which is why the table says no — no caller here and no — reserved there. A verifier MUST still handle a decision Line: they are legal, and the published vectors in §11.2 and §11.4 both contain one.

There is no park Line type. A park is an outcome Line carrying policy.status = "pending_approval", which has shipped since schema version 1. The human verdict that answers it is a separate decision Line, and a resumed call is a new Call with its own intent and outcome, cross-referenced by approval_id (ADR-0005).

5.2 Unknown line types

An emitter newer than a verifier may write a line_type the verifier does not recognise. Such a Line:

  • still hashes. It is bytes; the chain stays valid across it, and Lines after it verify normally.
  • caps the verdict at Indeterminate, with a reason naming the unrecognised token — “unknown line type foo at session 0 seq 12, newer emitter”.

The cap is the answer for an unknown Line that carries no signature field. Whether a future Line type must be signed is unknowable to this build, so an unsigned one is only unreadable. A signature that is present and does not authenticate the Line is Tampered instead (§8.1, §8.4): forgery is decidable without understanding the Line, and it outranks the cap. Presence is read off the Line’s own fields — an unknown Line whose signature is present while key_id was stripped is still a present-and-invalid signature, not an unsigned Line.

A verifier MUST NOT report Verified over content it does not understand. If it did, a future emitter could smuggle anything past an old auditor. This is the format’s entire extensibility story and it is fixed at this version.

The token itself MUST be preserved through parsing. A verifier that collapses every unknown type to a single “other” can say that something was unreadable but not what, which is the half of the message an operator needs.

5.3 Field reference

Fields marked optional are omitted when absent — never null.

open

FieldTypeNotes
schema_version, line_type, seq, prev_hash—seq is 0; prev_hash is the genesis digest (64 zeros).
prev_session_taildigest, optionalThe previous Session’s final line_hash. Omitted for a fresh file. See §7.
public_keypublic keyThe ed25519 key every signed Line in this Session is signed under.
signature, key_id—The open Line signs itself under the key it publishes.

intent

FieldTypeNotes
schema_version, line_type, seq, prev_hash—
call_idstringPairs this Line with its outcome.
tool_idstring
request_digestdigestSHA-256 over the verbatim request bytes (§9).

No signature, no key_id. An emitter MUST NOT sign an intent Line.

outcome

FieldTypeNotes
schema_version, line_type, seq, prev_hash—
call_idstringMatches the Call’s intent Line.
tool_idstring
request_digestdigestIdentical to the intent Line’s.
policy_set_hashdigestWhich Policy Set governed this Call (§6).
policyobjectTagged by status: allowed · denied {reason} · rate_limited {reason} · pending_approval {approval_id}.
capabilityobjectTagged by status: granted {grant} · denied {reason, denied_capability?}.
executionobjectTagged by status: success · trap {message} · resource_exceeded {kind} · host_denied {reason}.
grant_idstring, optionalThe grant the Call ran under. Omitted when none was minted.
response_digestdigest, optionalSHA-256 over the verbatim response bytes. Omitted when the Call produced none, and when output was rejected by a cap — bytes that never left MUST NOT be recorded as a response.
wall_msinteger, optionalOmitted when the sandbox never ran.
peak_memory_bytesinteger, optionalOmitted when the sandbox never ran.
decision_axesobjectAlways present, possibly {}. See below.
signature, key_id—

capability.granted.grant is a CapabilityGrant: grant_id, tool_id, fs {read_paths, write_paths} (optional), net {http: [{host, ports, methods}]} (optional), max_memory_bytes, max_wall_ms, max_output_bytes.

decision_axes — the inputs the verdict actually turned on. It is nested rather than flattened because the outcome Line already has a top-level capability field holding the capability station outcome, and two different things named capability on one Line is a collision that survives review and breaks an ingest.

FieldTypeNotes
capabilitystring, optionalThe capability axis the Call requested, e.g. fs.read.
rolestring, optionalThe role asserted by the caller.
sessionstring, optionalThe policy session scope — the PolicyRequest scalar, not the audit Session.
matched_rulestring, optionalThe rule that decided it.
approval_refstring, optionalThe approval a resumed Call was allowed under (ADR-0005).
fsobject, optionalDerived filesystem parameter — the grant’s single root: {path_raw, path_canonical}.
netobject, optionalDerived network parameter — the grant’s single host and port: {host, port}.

The object is always emitted because {} and an absent member say different things: {} says this emitter recorded no axes, an absent member says nothing at all. Its members follow omit-never-null.

fs and net are the derived capability parameters of ADR-0006, and what they record is the scope the grant resolved to — for fs, a root the Call was permitted under; for net, the grant’s single host and port. Neither is a resource the Call touched: the emitter derives both from the grant alone, and no Call argument is in scope where they are built.

They are recorded only when the grant names exactly one such scope; a grant naming several roots has not resolved to a path, and the axis is omitted rather than guessed. Recording an arbitrary one of N roots would be evidence that reads as fact and is not.

No shipped consumer reads them. aegis recheck rebuilds its request from capability, role and session only, and the argument matchers of ADR-0006 that would have consumed these parameters were canceled and will not be built. They are evidence a reader interprets, not an input any verdict turns on.

Both path_raw and path_canonical are recorded because a difference between them is itself evidence. The pair exists so that an emitter resolving a caller-supplied path against a root can record both spellings without a breaking change to the shape.

The two are independent fields, and a verifier MUST NOT assume they are equal. The shipped emitter in this version always writes the same string into both, because the capability resolver canonicalizes at mint time and the grant therefore only ever holds the canonical form (crates/botzr-aegis-runtime/src/pipeline.rs, fs_axis). That is a property of this emitter, not a constraint of the format: the test vector in §11.2 deliberately carries divergent values so that an implementation built from this document handles divergent values today rather than hard-coding this emitter’s equality and breaking on one that does not share it.

decision

FieldTypeNotes
schema_version, line_type, seq, prev_hash—
approval_idstringThe park this verdict answers.
verdictobjectTagged by verdict: approved {scope} · denied {reason}.
signature, key_id—

scope is {tool_id, fs?, net?} — the authority the approval granted, not just the fact of approval. The scope rides inside the approved variant so that an approval without a recorded scope is unrepresentable: approval without recorded scope is a blank check in the evidence, and the resumed Call’s grant must be a subset of what is recorded here.

close

FieldTypeNotes
schema_version, line_type, seq, prev_hash—
signature, key_id—

checkpoint

Reserved. This version defines no fields beyond the four every Line carries.


6. policy_set_hash

policy_set_hash names the Policy Set that governed the Call. It is SHA-256 over the canonical bytes of the parsed set — the JCS form of a stable projection of the validated rules — and deliberately not over the YAML text.

Hashing the text would make a reindent, a retyped comment, or a reordered mapping key look like a different ruleset, and an identity that moves for non-reasons trains a reader to ignore it moving. Every field the evaluator actually reads is covered, so a semantic edit always moves the hash. Rule declaration order is covered, because rule order is observable behaviour: the selector keeps the incumbent on a full specificity-and-priority tie.

Every scalar in the projection is a string or a boolean. A legitimate policy document may carry a negative priority or a byte limit near u64::MAX, both of which are outside the §3.2 value space; projecting numbers as decimal strings means no policy file can push canonicalization outside the value space, so hashing a parsed set cannot fail.

There is a separate, older FNV-1a digest over the YAML text in this codebase. It serves the hot-reload audit trail and is self-documented as not a security digest. It MUST NOT be recorded as policy_set_hash.


7. Sessions

  • A Session begins with an open Line at seq 0.
  • seq restarts at 0 in every Session. A file with two Sessions has two Lines numbered seq 5.
  • An open Line’s prev_hash is the genesis digest: 64 zeros. A Session’s first Line has no predecessor within the Session.
  • The back-reference across a Session boundary lives in prev_session_tail on the open Line, and MUST equal the previous Session’s final line_hash. It is omitted only when the file was empty.
  • The tail is not duplicated into prev_hash. One fact gets one spelling, and a verifier already special-cases open because that is where the public key is.
  • A Session ends with a close Line, written when the writer is dropped. See §10 for what that does and does not cover.

Coverage is (session_index, seq), not a bare seq. ADR-0002 defines Coverage as “the highest seq covered by a valid signature”, which reads file-global; seq in fact restarts per Session. Those disagree the moment one file holds two Sessions, where a bare seq names two different Lines. This specification is normative: a position in a Chain file is the pair (session_index, seq), where session_index is the Session’s ordinal within the file counted from the first open Line. The ADR’s wording is under-specified, not wrong.


8. Verification

A verifier walks the file once, in order, holding the same state the writer held: current Session index, expected seq, the running tail hash, and the Session’s published public key.

Coverage is the highest position covered by a valid signature.

The verdict is one of three states, and only three:

VerdictMeaning
VerifiedEvery Line parsed, chained and — where required — verified, and the file ends with a verified close.
IndeterminateNothing contradicts the file, but something could not be decided. Carries a typed reason.
TamperedThe file contradicts itself.

A binary pass/fail is deliberately rejected. Failing on any unverified tail alarms on healthy systems — the common case is not a crash but a live file being appended to right now, and a gate that fires on every in-progress file is noise within a week. Passing with a warning is worse: exit 0 is what every CI gate actually reads, and an evidence tool whose machine-readable answer is “fine” for a truncated file reproduces the very critique it exists to answer.

8.1 Tampered

  • A Line’s prev_hash is not the hash of the Line before it — an edit, a removed Line, or a forked chain.
  • seq repeated or went backwards. Two Lines at one position is what a fork looks like from the file, and no correct writer can produce it.
  • A Line in the signed set carries no signature, or one that does not verify against the key its Session published. A stripped signature on an outcome Line lands here.
  • A Line’s key_id does not match the key its Session published (§8.4).
  • An open Line’s prev_session_tail does not match the previous Session’s final Line. This is how truncating a non-final Session is caught.
  • A Line outside the format: not JSON and not the final Line; not an object; missing or ill-typed line_type / seq / prev_hash; or outside the §3.2 value space, so it has no reproducible hash.
  • An open Line publishes a public key that is not in a trust slice the caller supplied (§8.4). Not Verified (unpinned): the caller stated which keys it accepts and the file answered with another one.
  • A second decision Line for an approval_id already decided in the file. One park, one verdict — without this rule a recorded denial can be followed by an approval for the same park with both Lines validly signed (ADR-0005).

8.2 Indeterminate

  • Unknown line type, with the emitter’s token (§5.2) — capped only when it carries no signature; a signature present and invalid is Tampered (§8.1).
  • Reserved checkpoint — signed, so it extends Coverage, and still capped. The cap does not excuse the signature: a checkpoint that is unsigned or whose signature does not verify is Tampered (§8.4), because it is in the signed set and a forgery must not hide behind the cap.
  • Torn final line — the file’s last Line does not parse. Distinct from “no close record”: it is a torn write, and only the last Line can be one, because a correct writer refuses to append onto a torn tail.
  • Unanchored tail — the final Session has no close and nothing anchors beyond it. The report names the Calls in flight: three intents for workspace reads is a shrug, one for a network POST is where an operator starts looking.
  • Missing line — seq jumped forward while prev_hash still matched. Only a writer can produce this (§4); an attacker cannot, because removing a Line breaks the next Line’s prev_hash and re-signing the remainder needs the key. So this is a durability incident, not a forgery, and calling it Tampered would alarm on a full disk.
  • Empty chain — no Lines. Nothing contradicts the file; there is also nothing in it to verify.

Anchors are what resolve a tail: a close Line, a later Session’s open, and — reserved — a checkpoint. The verdict MUST NOT be computed from “is there a close record” as a boolean; Coverage-plus-Anchor generalises across the crash case, the multi-Session case and the live-file case, and a close-record boolean does not.

The verdict is deterministic: same bytes, same verdict, always. This is a property, not a test case.

8.3 What the unverified tail can contain

Every outcome Line is signed, so the unverified tail after the last signature can hold only intent Lines plus at most one torn final Line. An outcome Line in the tail is a stripped signature — Tampered, not a crash.

8.4 Key model and rotation

A per-host ed25519 keypair signs Lines. The Session open Line carries the public key; every signed Line carries key_id, the SHA-256 fingerprint of that 32-byte public key.

Signature verification MUST be ed25519 strict verification, rejecting small-order and malleable signatures. A record format wants one signature to have one verdict everywhere; the permissive rule lets the same bytes verify in one implementation and fail in a batch verifier.

key_id is inside the signed input — a signature is computed over the canonical form with signature omitted and key_id present — so a signature cannot be replayed under a different key’s fingerprint. A verifier MUST read key_id off the Line rather than substituting the fingerprint of the key it holds; otherwise a Line naming a key it was not signed under would quietly verify.

Rotation rule (normative). A file MAY span multiple key_ids. A new key_id is legal only when introduced by a Session open Line that carries the matching public key. A key_id change within a Session is Tampered.

This is what makes rotation expressible without making key substitution free: rotating means starting a Session, which is a signed, chained, anchored event that back-references the previous Session’s tail.

Two success labels, not one. Verifying a Session’s signatures against the key that same Session published proves internal consistency and nothing about provenance: an attacker who rewrites a whole Session signs it with their own key, publishes that key in the open Line, and the walk comes out clean. A verifier therefore MUST print the fingerprint and MUST distinguish Verified (pinned to <fp>) — a key was supplied out of band and matched — from Verified (unpinned), stated as internal consistency only. A key_id that fails to match a supplied trust store is Tampered. The labels are specified here because they are a property of what the format can prove; the CLI that prints them is AILAB-621.

What the shipped walker enforces, and the one gap that remains — stated rather than papered over:

  1. The rotation rule is enforced across the whole signed set. A foreign key_id on an open, outcome, decision or close Line is Tampered with a key-mismatch reason, and a Session-opening rotation is accepted, both under test. checkpoint is in the signed set and is held to it: a checkpoint whose signature does not verify is Tampered, not capped — discarding that result would let a forged Checkpoint hide behind the Indeterminate cap. An unknown-type Line is judged on what it carries: a signature that is present and does not verify is Tampered, because forgery is decidable without understanding the Line, while an unsigned one only caps, because whether a future Line type must be signed is unknowable to this build. Neither is ever reported as Verified.
  2. Pinning ships. The walker takes an optional slice of public keys the caller anchored out of band, and aegis verify fills it from --key and --trust-store (see The aegis verify command surface, above), so both success labels are reachable. Every open Line’s key must be in a supplied slice, not merely one of them — rotation across Sessions stays legal, while rotating into a key the caller never anchored is Tampered with an untrusted-key reason. Pinning is identity checking, not a second signature path: signatures verify against the key each Session published either way, and the slice only decides whether that key is one the caller accepts. A supplied slice therefore cannot make a broken chain verify; it upgrades the label from Verified (unpinned) to Verified (pinned to <fp>), or to Verified (pinned) when the file legally rotates across several anchored keys.
  3. Remaining gap: a signature on an intent Line is ignored rather than rejected. It is still covered by line_hash, so it cannot be altered undetected, but an emitter that signs intent Lines is out of spec and the shipped walker does not say so.

9. The Envelope boundary

Per ADR-0006 the Envelope is purely forensic, not a recheck prerequisite. Argument matchers target derived capability parameters, and those parameters are decision axes carried in the Chain (§5.3), so aegis recheck works chain-only. The Envelope exists for human investigation after an incident, and as the seam a future execution journal grows into.

The boundary, normatively:

  • An Envelope entry holds the verbatim request bytes of a Call.
  • It is content-addressed by request_digest — the same digest the intent and outcome Lines carry.
  • It is stored outside the Chain, in a separate artifact that need not travel with the Chain and often should not.
  • It is never signed.
  • It is authenticated transitively: a reader hashes the bytes on load and compares against the request_digest on the signed Line. The Line’s signature covers that digest, so a matching hash means the signature covers the payload too.

“Verbatim” is load-bearing and is stated here explicitly. request_digest is SHA-256 over the raw request bytes exactly as they arrived. An Envelope writer that pretty-prints the JSON, re-encodes it, sorts its keys, or normalizes its whitespace produces bytes that no longer hash to the recorded digest — and every entry it wrote silently stops authenticating. The trap is invisible until someone runs a formatter, at which point the break is retroactive and looks like tampering. An implementation MUST hash and store the bytes it received, before any processing, and MUST NOT expose an API that accepts a caller-supplied digest.

The same rule governs response_digest: hash what was produced, never a re-encoding of it.

No Envelope writer or reader ships in this version. This section specifies the boundary so that building one later cannot break the Chain format.


10. Threat model and non-guarantees

State these to anyone who is handed a Chain file. Each one is a limit of the evidence, not a bug to be worked around.

Drop does not run on SIGKILL. The close Line is written when the writer is dropped, which covers clean exit and panic unwinding — both under test. It does not cover SIGKILL, a power loss, or a host that vanishes. That gap is exactly what produces Indeterminate, and it is documented rather than engineered around: a Session with no close and nothing anchoring beyond it is honestly undecidable, and the format says so instead of guessing.

Truncating a non-final Session is detectable from the Chain alone. Drop Lines off the end of Session N and its tail changes, so Session N+1’s signed open no longer agrees with it — Tampered, from the file alone, with no external witness. Only the final Session’s tail is undecidable, and only when nothing anchors beyond it.

ADR-0002’s opening sentence says “truncation is therefore not detectable from the Chain alone”. That sentence contradicts that ADR’s own first Consequence, which correctly states the per-Session property above, and it understates what shipped. This specification states the shipped property; the ADR header is a known editorial defect.

Content beyond the last signature is unverifiable by construction. No format that signs some Lines can say anything about bytes appended after the last one it signed. That is what Coverage measures and what Anchors resolve.

A self-embedded key proves consistency, not provenance. See §8.4. Without an out-of-band key, Verified means “some Aegis build wrote this and the file names which key”, never “this key is yours”.

Derived paths appear in the Chain, and the Chain is the publishable artifact. A derived fs path naming a client directory under someone’s home directory is smaller than a full argument tree, but it is not non-sensitive. Sensitivity moved when the Envelope split off; it did not vanish. Anyone publishing a Chain must read its decision_axes first. tool_id, call_id, matched_rule, policy reasons and trap messages are free text written by the host and are subject to the same reading.

Unmatched intent is a consistency cross-check, not a tamper signal. An intent with no outcome inside a closed Session is a structural error worth reporting, but interior deletion is already caught by prev_hash. What the invariant adds is catching a buggy emitter, and catching a rewriting attacker who holds the signing key but drops an outcome sloppily. Do not claim more.

Two linkage kinds with different strengths:

  • intent ↔ outcome is a hard invariant, Session-scoped, guaranteed by the writer’s drop guard plus the borrow that makes the writer outlive every Call it issued. A violation inside a closed Session is a structural error.
  • approval_id ↔ decision is a soft cross-reference that may span Sessions and files. A human approving after a restart is normal. A pending_approval outcome with no decision is legal and informational; a decision for an absent approval_id is legal and informational. Two decision Lines for one approval_id is a structural violation, the same class as a chain break, because a correct emitter cannot produce it.

A seq gap is a durability incident, not a forgery. See §8.2.


11. Test vectors

Every vector below publishes the canonicalized intermediate form alongside the final hash. This is required, not a courtesy: an implementer who canonicalizes wrong and only sees “hash mismatch” has nowhere to look, and the canonical string is where the divergence is visible.

All vectors are live in the tree and are checked by CI.

11.1 Canonicalization

Source: crates/botzr-aegis-core/src/jcs.rs, test published_test_vector_canonical_form_and_hash.

Input (member order deliberately scrambled, nested object included):

{
  "tool_id": "echo",
  "seq": 7,
  "decision_axes": {
    "role": "ops",
    "capability": "fs.read",
    "fs": { "path_raw": "~/notes.md", "path_canonical": "/home/a/notes.md" }
  },
  "line_type": "outcome",
  "prev_hash": "0000000000000000000000000000000000000000000000000000000000000000"
}

Canonical form (one line, no whitespace, wrapped here only for reading):

{"decision_axes":{"capability":"fs.read","fs":{"path_canonical":"/home/a/notes.md",
"path_raw":"~/notes.md"},"role":"ops"},"line_type":"outcome","prev_hash":
"0000000000000000000000000000000000000000000000000000000000000000","seq":7,
"tool_id":"echo"}

SHA-256 of those canonical bytes:

9017de5e13e0a12b261e1960d0d0bc9220c6b7ef501b7dd189141e6327377664

Nothing else is mixed in — the Line hash is exactly SHA-256 over the canonical bytes.

11.2 A Session, end to end

Source: the committed goldens in crates/botzr-aegis-audit/tests/golden/, emitted through the real writer into one Session and signed with a fixed-seed development key. Each file in that directory is the canonical form of one Line, exactly as written to disk.

The Session is twelve Lines: open, intent, eight outcome Lines covering policy deny, rate limit, pending approval, capability deny, trap, resource exceeded, host panic and abandonment, then decision, then close.

Line 0 — session_open.json (canonical form, verbatim):

{"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"open","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","public_key":"3de537a06e04b2ffe1fb0558ea16d3c0f042ed99f7e392698aa5120f568d4e2c","schema_version":2,"seq":0,"signature":"f27b7f566bce4e0e8126a30dd951411a5ea82333b7b41740ee047f0382788b3239d382aa9506cf3ef7f34137cf9cd7f57388018c67a46b2c3285af8fcc2a7901"}

Derived values a third-party implementation must reproduce:

QuantityValue
key_id = SHA-256 of the 32 public-key bytes77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6
line_hash = SHA-256 of the canonical bytes aboveace4118a6a4ec1bf47503431aaa769ebb74de9c61174bf64470561a99abf4066
Line 1’s prev_hashthe same ace4118a…

Signing input for line 0 — the canonical form with signature omitted and key_id present. This is the exact byte string the ed25519 signature covers:

{"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"open","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","public_key":"3de537a06e04b2ffe1fb0558ea16d3c0f042ed99f7e392698aa5120f568d4e2c","schema_version":2,"seq":0}

Note that key_id is inside the signed bytes while signature is not, and that line_hash is taken over the form that does include signature — the two differ by exactly one member, and getting that backwards is the most likely implementation error in this specification.

Line 1 — intent.json, showing an unsigned Line inside the chain:

{"call_id":"call-golden-0","line_type":"intent","prev_hash":"ace4118a6a4ec1bf47503431aaa769ebb74de9c61174bf64470561a99abf4066","request_digest":"fb0288872031fc4818c03a7253bd3a78de192d05e6bccd09ceabeda65b4d7c6f","schema_version":2,"seq":1,"tool_id":"smoke"}

line_hash of line 1 is 5d923c33bede5bef42ee41556fed00fa0660ab30feb57355b3316a2263ffa7f9, which is line 2’s prev_hash — the unsigned Line is a full chain link, and the next signature covers it transitively.

Line 2 — policy_deny.json, the populated decision_axes shape:

{"call_id":"call-golden-1","capability":{"reason":"policy blocked before capability","status":"denied"},"decision_axes":{"capability":"fs.read","fs":{"path_canonical":"/fixtures/notes.md","path_raw":"./notes.md"},"matched_rule":"block-smoke","role":"ops","session":"sess-golden"},"execution":{"reason":"not executed","status":"host_denied"},"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"outcome","policy":{"reason":"blocked in test","status":"denied"},"policy_set_hash":"89a056813bdf93f95c1881a78793b1a86f5b6bab829c1ba9d20bb4add2aae921","prev_hash":"5d923c33bede5bef42ee41556fed00fa0660ab30feb57355b3316a2263ffa7f9","request_digest":"2baf1f40105d9501fe319a8ec463fdf4325a2a5df445adf3f572f626253678c9","schema_version":2,"seq":2,"signature":"470a273226ed3d32516e40fe136c03b684998ed17fa65ac0ed387c8074edf5aaf75b85f263ce2bdfa93fc4c083e0685e1a1a2d3ca42459f943c4dc3be698830c","tool_id":"smoke"}

This is the vector that matters for ADR-0001’s claim that a recorded deny can explain itself: role, capability, session and matched_rule are all on the Line, so the verdict can be reconstructed from the record alone without the request payload.

Note that its fs axis carries path_raw ./notes.md and path_canonical /fixtures/notes.md — two different strings, on purpose. The shipped emitter writes the same string into both today (§5.3); the vector diverges so that an implementation built from this document is exercised on the general case rather than on today’s coincidence. Do not read it as a sample of runtime output.

The remaining Lines — including session_close.json, and decision.json with an approved scope — are in the same directory and chain in the order listed above.

The signing key in these vectors is public and worthless. It is a fixed seed compiled into the test crate so that goldens are reproducible and its key_id is identical on every machine. A Line it signs proves that some Aegis build wrote it, never which — a verifier can only report Verified (unpinned) over these files. Never use it for anything.

11.3 Reproducing the vectors

Signatures are deterministic (ed25519), the seed is fixed, and the records are constants, so the whole Session is byte-reproducible:

cargo test -p botzr-aegis-audit --test golden

every_committed_golden_line_verifies_and_chains checks the committed files, not a freshly written Session, so a golden edited by hand fails there rather than passing as “expected output”.

11.4 Tamper vectors

Source: the committed files in crates/botzr-aegis-audit/tests/tampered/, one per Tampered class in §8.1, each read back and checked by crates/botzr-aegis-audit/tests/tampered.rs.

These files have a different shape from §11.2’s. There, one file is the canonical form of one Line. Here, one file is one whole Chain — seven of the eight classes are only meaningful in the context of the Lines around them, and the session-boundary class needs two Sessions in one file. The extension is .aarl (ADR-0014) for the same reason: these are whole record files, where a single Line is not one.

Every vector is signed by the same fixed-seed development key §11.2 publishes, so key_id 77a2c2f5… and public_key 3de537a0… are the values to expect throughout. The warning under §11.2 applies unchanged: that key is public and worthless.

File§8.1 classVerdict and reasonHow it is damaged
prev_hash_mismatch.aarlA Line’s prev_hash is not the hash of the Line before itTampered — chain broken at (session 0, seq 3)The close Line’s prev_hash is overwritten with 64 fs
seq_out_of_order.aarlseq repeated or went backwardsTampered — seq out of order in session 0: expected 4, found 1The unsigned intent Line is replayed at the end, keeping seq 1, with its hash link repaired
bad_signature.aarlA Line in the signed set carries no signatureTampered — bad signature at (session 0, seq 2): line carries no signatureThe close is dropped and the outcome Line it exposes loses its signature member
key_id_mismatch.aarlA Line’s key_id does not match the key its Session publishedTampered — bad signature at (session 0, seq 3): signed under dbc29825…, not 77a2c2f5…The close Line’s key_id is swapped for another key’s fingerprint; its signature is left untouched
session_boundary_broken.aarlAn open Line’s prev_session_tail does not match the previous Session’s final LineTampered — session boundary broken at session 1Two Sessions in one file, with the first Session’s close removed
malformed_line.aarlA Line outside the formatTampered — malformed line 3The bytes not json at all are inserted before the final Line
untrusted_key.aarlAn open Line publishes a key that is not in a trust slice the caller suppliedVerified with no trust slice; Tampered — untrusted key at (session 0, seq 0) against oneNot damaged at all. See below
duplicate_decision.aarlA second decision Line for an approval_id already decided in the fileTampered — duplicate decision at (session 0, seq 2) for apr-vector-8Two decision Lines are emitted for apr-vector-8, both validly signed

untrusted_key.aarl is a valid Chain, and that is the point. Its bytes contradict nothing; every signature verifies and every link holds. The finding exists only relative to a trust slice the caller supplied from outside the file, so the vector is the file plus that input. Verified with no trust slice it is Verified (unpinned); verified against any slice not containing 3de537a0… it is Tampered. The suite uses the key from a seed of thirty-two 0x09 bytes, whose fingerprint is dbc29825…. An implementation that reports Verified (unpinned) in the second case has ignored the key it was gated on, which is why this class is published rather than left implicit.

Two derived values for prev_hash_mismatch.aarl, since a chain break is the one class where an implementer needs both halves to confirm they are looking at the same contradiction:

QuantityValue
What the close Line claims as prev_hashffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
What the Line before it actually hashes to9a2c3c9f6c9f58effd1636bbb31c30fad2fcbcbd25ea5d2679c835740370cb9c

bad_signature.aarl in full, as the smallest vector — three Lines, and the damage is an absence rather than a substitution, so it reads clearly:

{"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"open","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","public_key":"3de537a06e04b2ffe1fb0558ea16d3c0f042ed99f7e392698aa5120f568d4e2c","schema_version":2,"seq":0,"signature":"f27b7f566bce4e0e8126a30dd951411a5ea82333b7b41740ee047f0382788b3239d382aa9506cf3ef7f34137cf9cd7f57388018c67a46b2c3285af8fcc2a7901"}
{"call_id":"call-vector-3","line_type":"intent","prev_hash":"ace4118a6a4ec1bf47503431aaa769ebb74de9c61174bf64470561a99abf4066","request_digest":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","schema_version":2,"seq":1,"tool_id":"echo"}
{"call_id":"call-vector-3","capability":{"reason":"not evaluated","status":"denied"},"decision_axes":{},"execution":{"status":"success"},"key_id":"77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6","line_type":"outcome","policy":{"status":"allowed"},"policy_set_hash":"d19fc5aa391e8220c634b40bef26894b83ae1493f8a8628e8ab667a6fd7e0e67","prev_hash":"de66ac5fac6432fca53cebba4fb926d54dd31aac03a6c4f721198ee601d1fdb2","request_digest":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","schema_version":2,"seq":2,"tool_id":"echo"}

The third Line is an outcome with no signature member, sitting in the unverified tail. §8.3 is what makes that decidable: the tail may hold intent Lines and at most one torn final Line, never an outcome. Note that it kept its key_id: a verifier that decides which Lines are signed by looking for a key_id will not notice that this one’s signature is gone.

Reproducing them. Same properties as §11.3 — fixed seed, deterministic signatures, constant records — so every vector is byte-reproducible:

cargo test -p botzr-aegis-audit --test tampered

every_committed_tamper_vector_is_byte_reproducible compares each committed file against a freshly built one, so a vector edited by hand fails there rather than passing as its own expected output.

11.5 Indeterminate vectors

Source: the committed files in crates/botzr-aegis-audit/tests/indeterminate/, one per Indeterminate class in §8.2, each read back and checked by crates/botzr-aegis-audit/tests/indeterminate.rs.

Same file shape as §11.4 — one file is one whole Chain, with the .aarl extension (ADR-0014) — and signed by the same fixed-seed development key §11.2 publishes, so key_id 77a2c2f5… and public_key 3de537a0… are the values to expect here too. empty_chain.aarl is the exception: it has no Lines to sign.

These are published because §8’s central claim is that a binary pass/fail verdict is wrong, and Indeterminate is the verdict a naive implementation collapses into one of the other two. A verifier with only Verified and Tampered passes every vector in §11.2 and §11.4 while being exactly the two-state verifier §8 argues against. It fails here, on all six.

File§8.2 classVerdict and reasonHow it is damaged
unknown_line_type.aarlUnknown line typeIndeterminate — unknown line type attestation at (session 0, seq 4)A closed Session with one extra final Line whose line_type is a token this version does not define, carrying a correct seq and prev_hash and no signature member
reserved_checkpoint.aarlReserved checkpointIndeterminate — reserved checkpoint at (session 0, seq 4)Not damaged. A closed Session with one extra final checkpoint Line whose signature verifies. See below
torn_final_line.aarlTorn final LineIndeterminate — final Line 5 does not parseA closed Session with a Line cut off mid-write appended after it, and no trailing newline — a torn write ends mid-Line
unanchored_tail.aarlUnanchored tailIndeterminate — session 0 has no close record; 1 Call in flight, call-unanchored-tailA Session’s outcome and close are both dropped, leaving open and intent
missing_line.aarlMissing LineIndeterminate — session 0 skips seq 2..3 with the chain intactThe outcome append was refused by the Sink, so seq 2 was consumed and never written; the Session then closed at seq 3
empty_chain.aarlEmpty chainIndeterminate — no Lines to verifyNot damaged. A zero-byte file

Two of these files are not damaged, and that is the point of publishing them. empty_chain.aarl is absent rather than corrupt: nothing in it contradicts anything, which is precisely why an implementation is most likely to collapse it into Verified. reserved_checkpoint.aarl is a well-formed, correctly signed Line this version cannot interpret — checkpoint is reserved and unemitted (§5.1), and a verifier does not pass content it cannot read.

The Tampered siblings of the first two classes are rules, not files. An unknown line type whose signature is present and does not authenticate the Line is Tampered (§5.2, §8.1), and so is a checkpoint whose signature does not verify (§8.4): forgery is decidable without understanding a Line, and it outranks the cap. Neither sibling has a published vector — those two cases are stated as rules and must be read there. This is why unknown_line_type.aarl carries no signature member at all, and why reserved_checkpoint.aarl’s signature verifies. A vector on the wrong side of either rule would document the opposite class.

One Line in this set was not produced by an emitter, and the specification should say which. reserved_checkpoint.aarl’s final Line is assembled directly and signed with the same primitives the writer signs with, because there is no checkpoint emitter to call and adding one to produce a fixture would be inventing format surface to satisfy a test. Its signing input follows §11.2’s rule unchanged — the canonical form with signature omitted and key_id present — which is what makes it checkable rather than self-consistent. unknown_line_type.aarl’s final Line is likewise assembled directly, and carries no signature at all, so nothing about signing is being restated there. Every other Line in every vector above came out of the real writer.

Reproducing them. Same properties as §11.3 — fixed seed, deterministic signatures, constant records — so every vector is byte-reproducible:

cargo test -p botzr-aegis-audit --test indeterminate

every_committed_indeterminate_vector_is_byte_reproducible compares each committed file against a freshly built one, so a vector edited by hand fails there rather than passing as its own expected output.


12. Versioning and compatibility

schema_version is 2. Version 1 is not compatible: phase became line_type, input_digest became request_digest, the chain and signature fields are new, two line types became six, and hashing is now specified.

Within version 2, an emitter MAY add fields and MAY add line types. A verifier:

  • MUST ignore members it does not recognise, and MUST still hash the whole Line.
  • MUST NOT report Verified over a line_type it does not recognise (§5.2).
  • MUST treat a Line missing line_type, seq or prev_hash as a format violation, not as an unknown extension (§5).

A change to the canonicalization rules, to the signing input, or to what line_hash covers is a version bump, not an addition — each one silently changes every hash in every file.

This document has a version of its own, and it is not the wire version. schema_version is the only version on the wire: it is the number inside a record, and it is the one a consumer keys off. No implementation should ever need to know which revision of this document it was written from in order to parse a file.

The document version — v0.1, on the status line at the top — moves whenever this document changes materially, and that includes clarifications which break no compatibility and therefore do not move schema_version. The two consequently run at different rates, and they are linked in one direction only: a schema_version bump always implies a document version bump, because a document that describes the wire format cannot describe a new one without changing. The converse does not hold, so a reader who infers a format change from a document version has inferred wrongly.

Cite both when it matters. “Conforms to schema_version 2” states what an implementation reads; “written against document v0.1” states which description of it the author had.

12.1 Downstream consumers

The Layer 2 governance ingest in governance/ targets this version (AILAB-624) and rejects version 1. It consumes intent and outcome lines; open, close, decision, checkpoint and any line type a newer emitter adds are skipped and counted per §5.2, never treated as corruption. It parses signatures without checking them — it is a consumer, not a verifier. See governance/DECISIONS.md D25.

Test packages that exercise this format: aegis-deny-suite, aegis-stress-suite, aegis-api-surface, aegis-adversarial-demo, aegis-stage2-demo.

Runtime crates that define and produce it: botzr-aegis-core (types, digests, canonicalization), botzr-aegis-audit (writer, signing, verdict), botzr-aegis-policy (policy_set_hash), botzr-aegis-runtime (decision axes).