MAQPNADocs

Audit ledger and evidence

Read the audit ledger, verify its hash chain and signed checkpoints, detect tampering, export evidence bundles and stream records to a SIEM or WORM storage.

flowchart LR
  A[Governed call] --> B[Gateway decision]
  B --> C[(Audit ledger<br/>hash-chained JSONL)]
  C --> D[maqpna audit tail<br/>maqpna dev timeline]
  C --> E[maqpna audit verify<br/>chain + checkpoints]
  C --> F[maqpna audit export<br/>evidence bundle]
  C --> G[auditSinks<br/>SIEM]
  C --> H[audit.worm<br/>write-once storage]

Goal#

Read what your agents did, prove that the audit ledger has not been changed, hand an auditor a verifiable evidence bundle, and ship the ledger to your SIEM and to write-once storage (WORM).

The audit ledger is the append-only record of every decision, approval, revocation and result. Each audit record carries the SHA-256 hash of the previous one, so changing or removing a record breaks the chain. A checkpoint is a signed statement of the ledger head; with an Ed25519 checkpoint key that you hold, a third party can verify the ledger offline without trusting MAQPNA.

Verify the audit ledger.cast

Prerequisites#

  • The maqpna CLI.
  • A local MAQPNA from your first governed agent (maqpna dev up), or an installation and an access token with the auditor or admin role.
  • For signed checkpoints: an Ed25519 signing key that you hold (step 4).

Steps#

1. Make a few governed calls#

On a local MAQPNA, make one allowed and one denied call so the ledger has something to show:

maqpna dev up --stub-llm
maqpna dev run -- maqpna call --server echo --tool echo --arg text=hello
maqpna dev run -- maqpna call --server echo --tool delete_resource --arg id=db-1 --arg namespace=kube-system

The local ledger is the file .maqpna/audit.jsonl. In a cluster it is the gateway's auditPath (a volume, or the PostgreSQL table audit_chain with state.backend: postgres).

2. Read the ledger#

Each line is one audit record. This is a real record of a denied call from a local run:

{"seq":3,"time":"2026-10-03T03:47:22.578756Z","session":"dev-c4d65a9e","namespace":"dev","agent":"coder","user":"you@localhost","server":"echo","tool":"delete_resource","argsSha256":"72cfc439561889f817e4d59f78457a8f9c8e2f4e0d68e856f148f0500b6dd0c4","decision":"deny","rule":"baseline-guardrails/never-touch-system-namespaces","reason":"system namespaces are off-limits to agents","approver":"","latencyMs":0,"costUsd":0,"prevHash":"f0e3d5a5b1fb681de7…","hash":"7b7c2025…","schema":2}
Field Meaning
seq Position in the chain, from 1
session, namespace, agent, user Who made the call, and on whose behalf
server, tool The tool (echo.delete_resource), or llm/<route> and the operation for a model call
argsSha256 Hash of the arguments. The arguments themselves are not stored
decision, rule, reason What the gateway did and which policy and rule decided it
approver Who approved or denied a held call
prevHash, hash The hash chain

To follow the ledger of a running gateway, use maqpna audit tail. It reads GET /v1/audit/records with the admin API:

eval "$(maqpna dev env)"          # local MAQPNA: gateway URL and admin token
maqpna audit tail --since 0

Output from a local run:

2026-10-03T03:47:22.425Z #1 dev/dev-10a2bb09 coder echo/echo allow baseline-guardrails default_action
2026-10-03T03:47:22.500Z #2 team-a/dev-d5c7b7ae reviewer echo/get_time allow baseline-guardrails default_action
2026-10-03T03:47:22.578Z #3 dev/dev-c4d65a9e coder echo/delete_resource deny baseline-guardrails/never-touch-system-namespaces system namespaces are off-limits to agents
2026-10-03T03:47:22.856Z #4 dev/dev-ae793f67 coder llm/stub/chat.completions allow - scope:models:stub; model:stub; usage:prompt=8,completion=44

Filter with --session, -n, --agent, --user, --decision or --trace (an OpenTelemetry trace ID), follow with -f, and print one JSON record per line with -f -o json. For one session's story, use the timeline (see sessions):

maqpna dev timeline --session dev-c4d65a9e
TIME      KIND       SERVER/TOOL           DECISION  DETAIL
23:47:22  tool_call  echo/delete_resource  deny      system namespaces are off-limits to agents
dev-c4d65a9e: 1 deny, cost $0.0000 (maqpna dev timeline --last)

3. Verify the hash chain#

Verify a ledger file offline:

maqpna audit verify .maqpna/audit.jsonl
OK: 4 records, hash chain intact

Ask a running gateway to verify its own ledger:

maqpna audit verify --gateway "$MAQPNA_GATEWAY_URL"
OK: http://127.0.0.1:8080: 4 records, hash chain intact (head seq 4, hash 613bb5787165c625f89a025e31fed31a2fbdb88eac9938e230e512ea299e5a38)

With the PostgreSQL state backend, verify the shared chain directly. --postgres takes a file that holds the DSN; --schema (default maqpna) and --chain (default audit) select the chain:

maqpna audit verify --postgres /run/secrets/maqpna-state-dsn

In the console, Audit → Verify chain runs the same check on the gateway's ledger and shows the head sequence and hash:

The console Audit page after Verify chain: chain intact, 8 records checked, the head sequence and head hash

Light theme.

4. Sign checkpoints with a key you hold#

A hash chain proves that records were not changed after the fact, but whoever holds the file could rewrite the whole chain. Signed checkpoints close that gap: the gateway signs the ledger head with an Ed25519 key, publishes the public key (JWKS) at /v1/audit/jwks, and anyone can check the signatures offline.

Generate a key:

maqpna keygen -out keys --name checkpoint
private key: keys/checkpoint.key
public key:  keys/checkpoint.pub
kid:         9FHvKLplpeVjzWvxxFWPh1ehLcgi_pTY8-7Q7coiPjE

In a cluster, put the key in a Secret with the key audit-signing.pem and enable signing in your Helm values:

audit:
  checkpointSigning:
    enabled: true
    secretName: maqpna-audit-signing
    intervalSeconds: 60   # sign at least every 60 s
    every: 0              # also sign after this many records (0 = gateway default 1000)

The gateway setting behind it is auditCheckpointKey, a key URI: file:///abs/path (PKCS#8 PEM, mode 0600 or 0400), pkcs11:… or kms://<provider>/<key-id>. The release binaries include only the file backend; pkcs11: and kms:// URIs are parsed but need a backend built into your gateway image (see sovereignty and confidential tiers).

Fetch a copy of the ledger with its checkpoints and public keys, then verify it offline:

maqpna audit fetch ledger --out ledger.jsonl
maqpna audit verify ledger.jsonl --jwks ledger.jsonl.jwks.json --checkpoints ledger.jsonl.checkpoints.jws
wrote ledger.jsonl (7 records, head 81dff220de43203a25b8270b39c4ad65bc6a1eafb00e46d521a4a8b87c8328c8)
wrote ledger.jsonl.checkpoints.jws and ledger.jsonl.jwks.json; verify with: maqpna audit verify ledger.jsonl --jwks ledger.jsonl.jwks.json
OK: 7 records, hash chain intact
OK: 2 Ed25519-signed checkpoints verified (last seq 7, kid 9FHvKLplpeVjzWvxxFWPh1ehLcgi_pTY8-7Q7coiPjE, 2026-10-03T03:48:49Z)

maqpna audit fetch jwks --out jwks.json and maqpna audit fetch checkpoints --out FILE fetch the parts separately. Give the auditor the JWKS through a channel you trust, not only next to the ledger.

The chart's older HMAC checkpoints (audit.hmacCheckpoints, key in MAQPNA_AUDIT_HMAC_KEY) are checked by audit verify when that variable is set (--checkpoint-key-env names a different one). HMAC checkpoints need the secret key to verify, so prefer Ed25519 for third parties.

5. Detect tampering#

Change one decision in a copy of the ledger and verify it:

sed 's/"decision":"deny"/"decision":"allow"/' .maqpna/audit.jsonl > tampered.jsonl
maqpna audit verify tampered.jsonl; echo "exit status $?"
TAMPERED: chain breaks at seq 3 after 2 valid records: audit: hash chain broken: seq 3 hash mismatch (content modified)
exit status 3

Removing a record is caught the same way:

TAMPERED: chain breaks at seq 5 after 4 valid records: audit: hash chain broken: seq 6, expected 5

audit verify exits 0 when the chain is intact, 3 when it is broken, and 1 on other errors, so you can run it in CI or a cron job.

6. Export an evidence bundle#

An evidence bundle is a verifiable package of audit records for one session, namespace, agent, user or time range:

# offline, from a ledger file
maqpna audit export .maqpna/audit.jsonl --session dev-c4d65a9e --out evidence.json

# from a running gateway (adds the signed checkpoints and JWKS when configured)
maqpna audit export --gateway "$MAQPNA_GATEWAY_URL" --session dev-c4d65a9e --out evidence.json

The bundle (schema maqpna.io/audit-evidence/v1) has these top-level fields: schema, generatedAt, frameworks, filter, range, recordCount, recordSchemas, ledgerRecordCount, chainHead, verified, decisionsByAction, toolsUsed, approvals, totalCostUsd and records. Exports from a gateway with checkpoint signing also carry signedCheckpoints and checkpointJwks. Filter with --session, --namespace, --agent, --user (gateway only), --since and --until (RFC 3339).

7. Stream records to a SIEM#

The gateway ships ledger records to SIEM sinks (auditSinks) with a durable cursor, at least once. Sink types are syslog (tcp:// or tls://), http and jsonl; formats are json, rfc5424-json, cef and ocsf. In Helm values:

audit:
  sinks:
    - name: siem
      type: syslog
      address: tls://siem.acme.eu:6514
      format: rfc5424-json
      tlsSecret: maqpna-siem-tls        # ca.crt (and tls.crt/tls.key with mtls: true)
    - name: ocsf
      type: http
      url: https://ingest.acme.eu/ocsf
      format: ocsf
      headersSecret: maqpna-siem-headers  # key "headers": "Authorization: Splunk <token>"

Every sink connection goes through the residency-checking dialer. With sovereignty.enabled, sinks must use TLS and match sovereignty.allowedEgressHosts.

Check the sinks:

maqpna audit sinks

Output from a local run with one jsonl sink:

ledger head seq 7
NAME       TYPE   FORMAT  CURSOR  LAG  DELIVERED  ERRORS  LAST ERROR
siem-file  jsonl  json    7       0    7          0

--max-lag N exits 3 when a sink lags by more than N records or reports an error; use it in monitoring. To backfill a sink from a ledger file, run the sink offline with the gateway's config:

maqpna audit stream .maqpna/audit.jsonl --config .maqpna/gateway.json --sink siem-file --from-seq 6
OK: sent 2 records from seq 6 to sink siem-file

8. Keep a write-once copy (WORM)#

The gateway can ship sealed ledger segments to an S3-compatible bucket with Object Lock in COMPLIANCE mode (MinIO, Ceph RGW or S3), so no one can delete or rewrite them during the retention period:

audit:
  sink: worm
  worm:
    endpoint: https://minio.audit.svc:9000   # in-country
    bucket: maqpna-audit                      # created with Object Lock enabled
    region: eu-central-1
    objectLockDays: 365
    credentialsSecret: maqpna-audit-s3        # keys access-key and secret-key

Without WORM or gateway.auditStorage.persistent: true, the ledger lives on an emptyDir and is lost when the pod is rescheduled.

9. Build the third-party register#

For DORA Art. 28, list every MCP server, model route and agent-to-agent (A2A) peer that agents used, built from the ledger:

maqpna evidence register
maqpna evidence register --format csv --since 2026-01-01T00:00:00Z --out register.csv

Output from a local run:

KIND         NAME  HOST       JURISDICTION  RESIDENCY OK  CALLS  DENIED  COST USD  FIRST USE                    LAST USE
mcp-server   echo  127.0.0.1                              6      1       0         2026-10-03T03:47:22.425777Z  2026-10-03T03:48:45.514104Z
model-route  stub  127.0.0.1                              1      0       0         2026-10-03T03:47:22.856093Z  2026-10-03T03:47:22.856093Z

--include-unused also lists configured providers that were never called.

Verify#

  • maqpna audit verify FILE prints OK: N records, hash chain intact and exits 0.
  • With checkpoint signing, maqpna audit verify FILE --jwks JWKS also prints OK: N Ed25519-signed checkpoints verified.
  • maqpna audit sinks --max-lag 100 exits 0.

Troubleshooting#

Symptom Cause Fix
TAMPERED: chain breaks at seq N A record was changed, removed or reordered, or the file was cut short Compare with the WORM copy or a backup. Never rewrite the chain; see backup, restore and DR
GET /v1/audit/jwks: HTTP 404: signed audit checkpoints are not configured (auditCheckpointKey) No checkpoint key on the gateway Set audit.checkpointSigning (step 4)
✗ expected exactly one FILE from audit export Neither a file nor --gateway given Pass the ledger file, or --gateway URL (the environment variable alone does not select gateway mode)
HTTP 403 from audit tail Your token has no auditor or admin role, or a namespace-scoped auditor did not set -n Run maqpna whoami, then add -n NAMESPACE
A sink shows ERRORS and its lag grows The SIEM is unreachable, or the residency dialer refused its host Check LAST ERROR, the TLS Secret and sovereignty.allowedEgressHosts

Next steps#