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_idand a publishedpublic_key, and a verifier needs nothing else. The implementation is specified incrates/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:
| Exit | Meaning |
|---|---|
0 | Verified |
1 | Tampered, or a usage error — an unknown flag, a --key that is not 64 hex, a missing <PATH> |
2 | The record file or the trust store could not be read |
3 | Indeterminate, 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 form | Meaning |
|---|---|
| digest | 64 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 key | 64 lowercase hex characters — a 32-byte ed25519 public key. |
| signature | 128 lowercase hex characters — a 64-byte ed25519 signature. |
| integer | JSON number, non-negative, u64, strictly below 2^53 (§3.2). |
| string | JSON 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
- 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.
- No insignificant whitespace: no spaces after
:or,, no indentation, no newlines inside a Line. - Array order is preserved exactly. Arrays are never sorted.
- Strings escape only what JSON requires —
",\, and control characters — using\b,\t,\n,\f,\rwhere those short forms exist and lowercase\u00xxotherwise. 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 readingseqas aNumber, 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 nearu64::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
nullanywhere in a Line is invalid. The one field that is always present but may be empty isdecision_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:
- Assign
seq— the next position in this Session — andprev_hash— the hash of the Line written immediately before it in this Session. - Compute
signing_input= the canonical form of the Line with thesignaturemember omitted andkey_idpresent. - If the Line is in the signed set (§5.1):
signature= ed25519 oversigning_input; write bothsignatureandkey_idinto the Line. - Compute
line_hash= SHA-256 over the canonical form of the complete Line, signature included. - Write the canonical form as one JSON Lines row; flush; fsync.
- Set the Session tail to
line_hash. The next Line’sprev_hashis 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:
| Field | Type | Notes |
|---|---|---|
schema_version | integer | 2 for this specification. |
line_type | string | See §5.1. |
seq | integer | Position within the Session, from 0. |
prev_hash | digest | Hash 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_type | Signed | Emitted by v0 | Role |
|---|---|---|---|
open | yes | yes | First Line of a Session. Carries the public key and prev_session_tail. |
intent | no | yes | Pre-execution Line for a Call. |
outcome | yes | yes | The Agent Action Record — one per Call, on every exit path. |
decision | yes | no — no caller | A human approval verdict (ADR-0005). |
close | yes | yes | Last Line of a Session. |
checkpoint | yes | no — reserved | Defined 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 typefooat 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
| Field | Type | Notes |
|---|---|---|
schema_version, line_type, seq, prev_hash | — | seq is 0; prev_hash is the genesis digest (64 zeros). |
prev_session_tail | digest, optional | The previous Session’s final line_hash. Omitted for a fresh file. See §7. |
public_key | public key | The 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
| Field | Type | Notes |
|---|---|---|
schema_version, line_type, seq, prev_hash | — | |
call_id | string | Pairs this Line with its outcome. |
tool_id | string | |
request_digest | digest | SHA-256 over the verbatim request bytes (§9). |
No signature, no key_id. An emitter MUST NOT sign an intent Line.
outcome
| Field | Type | Notes |
|---|---|---|
schema_version, line_type, seq, prev_hash | — | |
call_id | string | Matches the Call’s intent Line. |
tool_id | string | |
request_digest | digest | Identical to the intent Line’s. |
policy_set_hash | digest | Which Policy Set governed this Call (§6). |
policy | object | Tagged by status: allowed · denied {reason} · rate_limited {reason} · pending_approval {approval_id}. |
capability | object | Tagged by status: granted {grant} · denied {reason, denied_capability?}. |
execution | object | Tagged by status: success · trap {message} · resource_exceeded {kind} · host_denied {reason}. |
grant_id | string, optional | The grant the Call ran under. Omitted when none was minted. |
response_digest | digest, optional | SHA-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_ms | integer, optional | Omitted when the sandbox never ran. |
peak_memory_bytes | integer, optional | Omitted when the sandbox never ran. |
decision_axes | object | Always 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.
| Field | Type | Notes |
|---|---|---|
capability | string, optional | The capability axis the Call requested, e.g. fs.read. |
role | string, optional | The role asserted by the caller. |
session | string, optional | The policy session scope — the PolicyRequest scalar, not the audit Session. |
matched_rule | string, optional | The rule that decided it. |
approval_ref | string, optional | The approval a resumed Call was allowed under (ADR-0005). |
fs | object, optional | Derived filesystem parameter — the grant’s single root: {path_raw, path_canonical}. |
net | object, optional | Derived 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
| Field | Type | Notes |
|---|---|---|
schema_version, line_type, seq, prev_hash | — | |
approval_id | string | The park this verdict answers. |
verdict | object | Tagged 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
| Field | Type | Notes |
|---|---|---|
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
openLine atseq0. seqrestarts at 0 in every Session. A file with two Sessions has two Lines numberedseq5.- An
openLine’sprev_hashis 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_tailon theopenLine, and MUST equal the previous Session’s finalline_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-casesopenbecause that is where the public key is. - A Session ends with a
closeLine, 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:
| Verdict | Meaning |
|---|---|
Verified | Every Line parsed, chained and — where required — verified, and the file ends with a verified close. |
Indeterminate | Nothing contradicts the file, but something could not be decided. Carries a typed reason. |
Tampered | The 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_hashis not the hash of the Line before it — an edit, a removed Line, or a forked chain. seqrepeated 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
outcomeLine lands here. - A Line’s
key_iddoes not match the key its Session published (§8.4). - An
openLine’sprev_session_taildoes 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
openLine publishes a public key that is not in a trust slice the caller supplied (§8.4). NotVerified (unpinned): the caller stated which keys it accepts and the file answered with another one. - A second
decisionLine for anapproval_idalready 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: acheckpointthat is unsigned or whose signature does not verify isTampered(§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
closeand 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 —
seqjumped forward whileprev_hashstill matched. Only a writer can produce this (§4); an attacker cannot, because removing a Line breaks the next Line’sprev_hashand re-signing the remainder needs the key. So this is a durability incident, not a forgery, and calling itTamperedwould 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:
- The rotation rule is enforced across the whole signed set. A foreign
key_idon anopen,outcome,decisionorcloseLine isTamperedwith a key-mismatch reason, and a Session-opening rotation is accepted, both under test.checkpointis in the signed set and is held to it: acheckpointwhose signature does not verify isTampered, not capped — discarding that result would let a forged Checkpoint hide behind theIndeterminatecap. An unknown-type Line is judged on what it carries: a signature that is present and does not verify isTampered, 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 asVerified. - Pinning ships. The walker takes an optional slice of public keys the caller
anchored out of band, and
aegis verifyfills it from--keyand--trust-store(see Theaegis verifycommand surface, above), so both success labels are reachable. EveryopenLine’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 isTamperedwith 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 fromVerified (unpinned)toVerified (pinned to <fp>), or toVerified (pinned)when the file legally rotates across several anchored keys. - Remaining gap: a signature on an
intentLine is ignored rather than rejected. It is still covered byline_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 theintentandoutcomeLines 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_digeston 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↔outcomeis 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↔decisionis a soft cross-reference that may span Sessions and files. A human approving after a restart is normal. Apending_approvaloutcome with nodecisionis legal and informational; adecisionfor an absentapproval_idis legal and informational. TwodecisionLines for oneapproval_idis 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:
| Quantity | Value |
|---|---|
key_id = SHA-256 of the 32 public-key bytes | 77a2c2f5952039243c043b69e7e812a2deb69e3271adb3013b8f24d3b8ea40f6 |
line_hash = SHA-256 of the canonical bytes above | ace4118a6a4ec1bf47503431aaa769ebb74de9c61174bf64470561a99abf4066 |
Line 1’s prev_hash | the 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_idis identical on every machine. A Line it signs proves that some Aegis build wrote it, never which — a verifier can only reportVerified (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 class | Verdict and reason | How it is damaged |
|---|---|---|---|
prev_hash_mismatch.aarl | A Line’s prev_hash is not the hash of the Line before it | Tampered — chain broken at (session 0, seq 3) | The close Line’s prev_hash is overwritten with 64 fs |
seq_out_of_order.aarl | seq repeated or went backwards | Tampered — seq out of order in session 0: expected 4, found 1 | The unsigned intent Line is replayed at the end, keeping seq 1, with its hash link repaired |
bad_signature.aarl | A Line in the signed set carries no signature | Tampered — bad signature at (session 0, seq 2): line carries no signature | The close is dropped and the outcome Line it exposes loses its signature member |
key_id_mismatch.aarl | A Line’s key_id does not match the key its Session published | Tampered — 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.aarl | An open Line’s prev_session_tail does not match the previous Session’s final Line | Tampered — session boundary broken at session 1 | Two Sessions in one file, with the first Session’s close removed |
malformed_line.aarl | A Line outside the format | Tampered — malformed line 3 | The bytes not json at all are inserted before the final Line |
untrusted_key.aarl | An open Line publishes a key that is not in a trust slice the caller supplied | Verified with no trust slice; Tampered — untrusted key at (session 0, seq 0) against one | Not damaged at all. See below |
duplicate_decision.aarl | A second decision Line for an approval_id already decided in the file | Tampered — duplicate decision at (session 0, seq 2) for apr-vector-8 | Two 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:
| Quantity | Value |
|---|---|
What the close Line claims as prev_hash | ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff |
| What the Line before it actually hashes to | 9a2c3c9f6c9f58effd1636bbb31c30fad2fcbcbd25ea5d2679c835740370cb9c |
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 class | Verdict and reason | How it is damaged |
|---|---|---|---|
unknown_line_type.aarl | Unknown line type | Indeterminate — 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.aarl | Reserved checkpoint | Indeterminate — 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.aarl | Torn final Line | Indeterminate — final Line 5 does not parse | A closed Session with a Line cut off mid-write appended after it, and no trailing newline — a torn write ends mid-Line |
unanchored_tail.aarl | Unanchored tail | Indeterminate — session 0 has no close record; 1 Call in flight, call-unanchored-tail | A Session’s outcome and close are both dropped, leaving open and intent |
missing_line.aarl | Missing Line | Indeterminate — session 0 skips seq 2..3 with the chain intact | The outcome append was refused by the Sink, so seq 2 was consumed and never written; the Session then closed at seq 3 |
empty_chain.aarl | Empty chain | Indeterminate — no Lines to verify | Not 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
Verifiedover aline_typeit does not recognise (§5.2). - MUST treat a Line missing
line_type,seqorprev_hashas 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).