Audit ledger flow#
The audit ledger is the append-only, hash-chained record of every decision the gateway makes: allowed, denied and held calls, approvals, revocations, taint changes, session results and sandbox usage. Each record carries the SHA-256 hash of the record before it, so changing, removing or reordering one breaks the chain. Signed checkpoints catch a forger who rewrites the whole chain consistently. Anyone holding the ledger and the public key can verify it offline, without trusting MAQPNA.
This page follows a record from the moment a decision is made to the moment an auditor verifies it.
flowchart LR
D["Decision in the gateway"] --> A["Append: seal record<br/>seq, prevHash, hash"]
A --> F[("File ledger audit.jsonl<br/>fsync")]
A --> P[("Postgres audit_chain<br/>advisory lock")]
P --> MR["Verified local mirror<br/>on every replica"]
F --> CP["Checkpoints<br/>HMAC and Ed25519 JWS"]
MR --> CP
F --> SI["SIEM sinks<br/>syslog, HTTP, JSONL"]
MR --> SI
F --> WO["WORM shipping<br/>S3 Object Lock"]
MR --> WO
F --> TL["Tenant ledgers<br/>one chain per tenant"]
CP --> V["maqpna audit verify"]
F --> EX["Evidence export<br/>maqpna.io/audit-evidence/v1"]
1. Append#
- The gateway builds a record:
seq, time, session, namespace, agent, user, server, tool, argsSha256, decision, rule, reason, approver, latencyMs, costUsd, plusschema: 2and anextmap of string values. Raw arguments are never stored, only their SHA-256 hash (argsSha256). - It sets
seqto the head'sseq + 1andprevHashto the head'shash(64 zeros for the first record). - It computes
hash= hex SHA-256 of the record's canonical JSON withouthash: keys sorted by byte order, no whitespace, no HTML escaping, times in UTC RFC 3339 with nanoseconds, every member present (for schema 2 alsoschemaandext,{}when empty). The encoding is defined inpkg/audit/audit.go, so the hash can be reproduced in any language. See ledger record format. - File backend: the record is appended to
auditPath(JSONL) and fsynced. Concurrent appends share one fsync (group commit). On open, a torn final line is truncated and the tail hash is checked. - Postgres backend: the append takes the chain's advisory lock (
pg_advisory_xact_lock), reads the head, seals the record and inserts it intoaudit_chain(one multi-row INSERT per batch). Appends from all replicas are serialised into one chain identical to a file ledger's. A trigger refusesUPDATEandDELETEonaudit_chain. Each replica keeps a byte-identical local mirror atauditPath, verifying every line against its predecessor before writing it; a mirror that diverges from the database stops the replica (ErrMirrorDiverged) until an operator inspects it and setsauditAcceptDivergedMirror.
Audit-before-result (closed mode)#
With auditFailurePolicy: closed (the production and sovereign profiles), the gateway writes a durable intent record (ext.phase=intent) before forwarding an allowed call, and refuses the call if that fails (audit_unavailable). It holds a non-streamed result until the completion record is durable, and withholds the result if that append fails. /readyz reports 503 while the ledger fails, so the replica is drained. With open (the default) an append failure is logged and counted in maqpna_gateway_audit_errors_total, and the call proceeds.
2. Checkpoints#
| Kind | Enabled by | Stored in | Cadence |
|---|---|---|---|
| HMAC-SHA256 | MAQPNA_AUDIT_HMAC_KEY |
<auditPath>.checkpoints ({seq, hash, time, mac} per line) |
Every auditCheckpointEvery records (default 1000) |
| Ed25519 JWS | auditCheckpointKey (a pkg/keys URI) |
<auditPath>.checkpoints.jws |
When the head advanced and 60 seconds (auditCheckpointIntervalSeconds) or 1000 records passed; also on shutdown and on evidence export |
A signed checkpoint is a compact JWS (alg: EdDSA, typ: maqpna-audit-checkpoint+jws, kid = RFC 7638 thumbprint) over {seq, hash, time, kid} of the chain head. The public keys are served unauthenticated at GET /v1/audit/jwks, including retired keys, so third parties verify without any secret. The key is customer-held; MAQPNA never holds it.
3. Verify#
sequenceDiagram
autonumber
participant Au as Auditor
participant CLI as maqpna audit verify
participant Led as Ledger file or audit_chain
participant J as JWKS (audit/jwks)
Au->>CLI: maqpna audit verify audit.jsonl --jwks jwks.json
CLI->>Led: read every record in order
CLI->>CLI: seq = previous + 1, prevHash = previous hash,<br/>recomputed hash = stored hash
CLI->>J: load public keys
CLI->>CLI: each JWS checkpoint: signature valid,<br/>hash equals the ledger's hash at that seq
CLI-->>Au: OK or TAMPERED (exit 3)
- For each record, the verifier checks that
seqis the expected next number,prevHashequals the previous record'shash, and the recomputed hash equals the storedhash. The first failure names itsseq. - With
MAQPNA_AUDIT_HMAC_KEYset, it also checks the HMAC checkpoints. A missing checkpoint file is reported asTAMPERED, unless Ed25519 checkpoints are verified with--jwks. - With
--jwks(or--checkpoint-jwksfrom an evidence bundle), it verifies every signed checkpoint and that it matches the ledger at itsseq. This catches a consistent rewrite of the whole chain. --gateway URLasks a running gateway to verify its view (GET /v1/audit/verify, auditor or admin role).--postgres DSN-FILE [--schema maqpna] [--chain audit]readsaudit_chaindirectly and also checks that theseq,hashandprev_hashcolumns agree with the records;--chain tenant/<name>verifies a tenant ledger.
4. Export evidence#
maqpna audit export FILE --session S (offline) or maqpna audit export --gateway URL --session S builds an evidence bundle with schema maqpna.io/audit-evidence/v1: generatedAt, range, recordCount, chainHead, verified, decisionsByAction, toolsUsed, approvals, totalCostUsd, records, recordSchemas and, with signed checkpoints, signedCheckpoints and checkpointJwks. Filters: session, namespace, agent, user, since, until. maqpna evidence register exports the DORA Art. 28 third-party register (maqpna.io/third-party-register/v1) built from the ledger.
5. Ship to SIEM#
Each auditSinks[] entry streams records to a SIEM:
| Type | Transport | Default format |
|---|---|---|
syslog |
TCP (tcp://) or TLS (tls://, optional mTLS), RFC 5424 with octet counting |
rfc5424-json |
http |
POST of batches | ocsf |
jsonl |
Local file, fsynced | json |
Formats are json (the record as stored), rfc5424-json, cef and ocsf (OCSF 1.3.0 API Activity, class 6003). Sinks read the ledger file, never the request path, so a slow SIEM only grows maqpna_gateway_audit_sink_lag{sink}. Delivery is at least once: the cursor {seq, offset} is persisted only after a batch is accepted, and receivers can de-duplicate on hash. With Postgres state, one replica ships per sink (leader lease siem/<sink>) and the cursor is shared, so a new leader resumes where the old one stopped. Every connection goes through the residency dialer; a refused connection sends nothing and does not advance the cursor.
6. Ship to WORM storage#
With audit.sink: worm in Helm (env MAQPNA_AUDIT_SINK=worm and MAQPNA_AUDIT_WORM_*), ledger segments are uploaded to an S3-compatible store (MinIO, Ceph RGW or S3) every 5 minutes by default, with x-amz-object-lock-mode: COMPLIANCE, a retain-until date of auditRetentionDays, Content-MD5 and x-amz-checksum-sha256. Requests are signed with SigV4 (standard library only) and dialled through the residency dialer; an endpoint outside the sovereignty policy is refused at start. Object keys are byte ranges of the ledger, and with Postgres state one replica ships (lease worm/audit) from a shared byte offset. Each tenant ledger ships under its own prefix (tenants/<name>/audit by default).
7. Tenant ledgers#
In a multi-tenant installation every platform record of a tenant namespace is copied into that tenant's own hash chain (<tenantLedgerDir>/<tenant>/audit.jsonl, or chain tenant/<name> in Postgres) with ext.tenant and ext.platformSeq. One replica feeds them (lease tenant-ledgers) about once a second, from a shared cursor, without duplicates or gaps. A tenant verifies its own chain with the same maqpna audit verify.
What you see#
Output formats from cmd/maqpna/audit.go, admin_audit.go and evidence.go (values illustrative):
$ maqpna audit verify /var/lib/maqpna/audit/audit.jsonl --jwks jwks.json
OK: 48213 records, hash chain intact
OK: 49 Ed25519-signed checkpoints verified (last seq 48000, kid 6sQ9h1..., 2026-10-02T14:05:09Z)
$ maqpna audit verify audit.jsonl
TAMPERED: chain breaks at seq 1042 after 1041 valid records: audit: hash chain broken: seq 1042 hash mismatch (content modified)
$ maqpna audit verify --gateway https://gw.example.eu
OK: https://gw.example.eu: 48213 records, hash chain intact (head seq 48213, hash 77e0...)
TAMPERED exits with code 3. maqpna audit sinks lists each sink with NAME TYPE FORMAT CURSOR LAG DELIVERED ERRORS LAST ERROR, and exits 3 with --max-lag N when a sink falls behind. See maqpna audit verify, maqpna audit export, maqpna audit sinks and maqpna evidence register.
Failure modes#
| Failure | Effect |
|---|---|
| Disk full or database unreachable | Open mode: append fails, counted, call proceeds. Closed mode: calls refused (audit_unavailable), /readyz 503, alert MaqpnaAuditLedgerUnavailable |
| Mirror diverged from Postgres | The replica refuses to start until inspected; the old mirror is kept as <auditPath>.diverged-<unix> |
| SIEM down | Lag grows, nothing is lost; alert MaqpnaAuditSinkLag |
| Retention below 183 days | Warning at start, maqpna_gateway_audit_retention_below_ai_act 1, condition RetentionBelowAIAct=True on the sovereignty policy |
RFC 3161 timestamping of checkpoints is Planned.