MAQPNADocs

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"]
  1. Take the record and drop hash.
  2. 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).
  3. For schema 2 and above, schema and ext are part of the canonical form (ext is {} when empty). Records without schema hash exactly as schema 1, so a ledger may mix both.
  4. hash = lowercase hex SHA-256 of those bytes.
  5. Verification walks the chain: seq must be the expected number, prevHash must equal the previous hash, and the recomputed hash must equal hash. The first failure is firstBadSeq.

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.