Ledger record format#
The audit ledger is an append-only list of JSON records, one per line (JSONL), each linked to the previous one by a SHA-256 hash. The format is defined in pkg/audit and is the same for the file ledger and for PostgreSQL (audit_chain.line holds the exact line). This page is precise enough to write an independent verifier.
Record fields#
| Field | Type | Meaning |
|---|---|---|
seq |
integer | Position in the chain, starting at 1, no gaps |
time |
RFC 3339 UTC | When the record was written (nanosecond precision) |
session, namespace, agent |
string | The session that made the call |
user |
string | The person the agent acts for (from the token's act chain) |
server, tool |
string | MCP server and tool; llm/<route> and the operation for model calls; a2a/<agent> for agent-to-agent (A2A) calls; admin, vault, maqpna-session for non-call records |
argsSha256 |
hex | SHA-256 of the original arguments (or the raw model request body). Raw arguments are never stored |
decision |
string | allow, deny, require_approval, observe, admin or usage |
rule |
string | <policy>/<rule>, or the deciding source (revocation/<id>, budget/<scope>-<window>, pin/<server>/<tool>, fork/parent, session/result) |
reason |
string | The reason token or text (approval_pending:apr_…, dlp:iban response_withheld, usage:prompt=812,completion=95) |
approver |
string | Who approved, for approved and approval-denied calls |
latencyMs |
number | Gateway-measured latency |
costUsd |
number | Metered cost (amount in the configured currency; the suffix is legacy) |
prevHash |
hex | hash of the previous record; 64 zeros for the first |
hash |
hex | SHA-256 of this record's canonical form |
schema |
integer | 2 for records with ext; absent in schema 1 records |
ext |
object of strings | Extension facts (below) |
Reserved ext keys#
| Group | Keys |
|---|---|
| Core | traceId, spanId, dryRun, dlp, taint, toolDefSha256, approverIss, approvers, revocation, principalVerified, parentSession, forkRoot, tenant, platformSeq, memoryStore, memorySha256 |
| A2A | a2aHop, a2aCall, a2aCaller, a2aTarget, a2aMethod, a2aBinding, a2aCardKid, actChain, txn, svid |
| Upstream | upstream, upstreamAuth, upstreamPerUser |
| Gateway | phase (intent), guard, guardBlocked, modelEndpoint, modelFailover, approverGroups, pinStatus, taintAdded, taintCleared, dryRunPolicy, dlpAction, dlpProfile, dlpBlocked, argsRedactedSha256, userConfirm* |
ext values are strings and never contain argument values, prompts or matched secrets: DLP records detector IDs and counts (iban:2,email:1).
The hash#
flowchart LR
R["Record without hash"] --> C["Canonical JSON<br/>sorted keys, no whitespace,<br/>no HTML escaping, every member present"]
C --> H["SHA-256, hex"]
H --> REC["record.hash"]
PREV["previous record.hash<br/>(genesis: 64 zeros)"] --> R
REC --> NEXT["next record.prevHash"]
- Take the record and drop
hash. - Encode it as canonical JSON: object keys sorted by byte order, no insignificant whitespace, no HTML escaping, times as RFC 3339 UTC with nanoseconds, numbers in shortest round-trip form, and every member present (empty strings included).
- For
schema2 and above,schemaandextare part of the canonical form (extis{}when empty). Records withoutschemahash exactly as schema 1, so a ledger may mix both. hash= lowercase hex SHA-256 of those bytes.- Verification walks the chain:
seqmust be the expected number,prevHashmust equal the previoushash, and the recomputed hash must equalhash. The first failure isfirstBadSeq.
The ledger's canonical JSON is its own encoder in pkg/audit, not RFC 8785 (JCS); JCS (pkg/jcs) is used for A2A card signatures and usage reports.
Golden vectors#
Record seq=1, time=2026-09-30T12:00:00Z, session=s1, namespace=team-a, agent=coder, user=alice@acme.eu, server=echo, tool=echo, decision=allow, rule=p/r, latencyMs=1.5, costUsd=0.01, prevHash=<genesis> (pkg/audit/schema_test.go). The v1 canonical form is:
{"agent":"coder","approver":"","argsSha256":"","costUsd":0.01,"decision":"allow","latencyMs":1.5,"namespace":"team-a","prevHash":"0000000000000000000000000000000000000000000000000000000000000000","reason":"","rule":"p/r","seq":1,"server":"echo","session":"s1","time":"2026-09-30T12:00:00Z","tool":"echo","user":"alice@acme.eu"}
| Variant | SHA-256 |
|---|---|
| v1 | 295f5fbdf367de4eddf7ab8c75cc4dffbdbb2e4d4b02467c334e955722b5238d |
v2, ext={"dlp":"iban","traceId":"4bf92f3577b34da6a3ce929d0e0e4736"} |
d2c3f7afb609b54b11809e4cfd18cc99cb0437323541376804f9e5f9767221be |
v2, empty ext |
b72baddbb70e9fc9f8015dc2c504d29810d044de886e104e9ca87080be414c1f |
Checkpoints#
A hash chain alone does not stop someone who rewrites the whole file and recomputes every hash. Checkpoints anchor the head with a key the forger does not hold.
| Kind | Enabled by | File | Format |
|---|---|---|---|
| HMAC | env MAQPNA_AUDIT_HMAC_KEY (chart audit.hmacCheckpoints, default on) |
<ledger>.checkpoints |
JSONL {seq, hash, time, mac}, mac = hex HMAC-SHA256(key, maqpna-audit-checkpoint/v1\n<seq>\n<hash>\n<time RFC3339Nano>), every auditCheckpointEvery records (default 1000) |
| Ed25519 JWS | auditCheckpointKey (a key URI) |
<ledger>.checkpoints.jws |
One compact JWS per line, header {alg: EdDSA, kid, typ: maqpna-audit-checkpoint+jws}, payload {seq, hash, time, kid}, written when the head advanced and 1000 records or auditCheckpointIntervalSeconds (60) passed, and on shutdown and evidence export |
Signed checkpoints are verifiable by anyone with the public keys from GET /v1/audit/jwks (no secret needed): maqpna audit verify LEDGER --jwks jwks.json.
Intent records (closed mode)#
With auditFailurePolicy: closed, an allowed call gets two records: an intent record (ext.phase=intent) written and made durable before the call is forwarded, and the usual completion record afterwards. If the intent append fails, the call is refused (audit_unavailable). Intent records are not shown as timeline steps.
Evidence bundle#
maqpna audit export and GET /v1/audit/export produce a bundle with schema maqpna.io/audit-evidence/v1 and the members generatedAt, frameworks, filter, range, recordCount, recordSchemas, ledgerRecordCount, chainHead {seq, hash}, verified, verifyError?, firstBadSeq?, decisionsByAction, toolsUsed, approvals, totalCostUsd, records, and with signed checkpoints signedCheckpoints and checkpointJwks.
What you see#
One schema 2 record, as stored (values illustrative):
{"seq":1042,"time":"2026-10-02T12:03:11.204518Z","session":"fix-4821","namespace":"team-a","agent":"coder","user":"alice@acme.eu","server":"kubernetes","tool":"delete_pod","argsSha256":"9f2c41d0…","decision":"allow","rule":"prod-guard/k8s-delete","reason":"destructive","approver":"bob@acme.eu","latencyMs":412.7,"costUsd":0.0004,"prevHash":"ab31f0c2…","hash":"77e0a9d4…","schema":2,"ext":{"approverIss":"https://idp.acme.eu/realms/ops","approvers":"bob@acme.eu","traceId":"4bf92f3577b34da6a3ce929d0e0e4736"}}
$ maqpna audit verify audit.jsonl
OK: 1042 records, hash chain intact
See maqpna audit verify and maqpna audit export.