Audit & Verification
A Server exposes two separate audit surfaces. They answer different questions, and an auditor treats them differently. Read this page as two runbooks under one cover.
| Surface | What it holds | Who reads it | Question it answers |
|---|---|---|---|
| system_audit_log | Operational events - orgs created, records written, keys rotated, admin reads | Your SIEM / SOC, continuously | "What is happening on this Server?" |
| audit_vault | The signed, hash-chained records themselves | An auditor, offline | "Is this record authentic and unaltered?" |
The first surface is operational telemetry: it tells you the Server is behaving. The second is the proof: it stands on its own cryptography, so an auditor can verify the chain without trusting - or even reaching - the Server that produced it. Stream the first into your SIEM. Hand the second to an auditor. Do not substitute one for the other.
Surface 1 - Stream operational events to your SIEM
Poll GET /v1/siem/stream on an interval and forward the result to your SIEM. The endpoint merges
the event stream and the operational system_audit_log into one time-ordered feed. It requires a
key with the audit:read scope (see the API reference for scope details).
Parameters: since (ISO-8601, required), limit (default 100), format (ocsf default, or
raw).
curl -s -H "Authorization: Bearer $AGLEDGER_KEY" \
"$AGLEDGER_URL/v1/siem/stream?since=2026-05-01T00:00:00Z&limit=5&format=raw"
{"type":"admin.org_bootstrapped","payload":{"name":"Default","reason":"single-org-install","actorId":"00000000-0000-0000-0000-000000000000","actorRole":"platform","targetType":"org","targetId":"019ead17-fbd4-7381-ac3f-5ee1474830f1"},"timestamp":"2026-06-09T15:54:50.707Z","id":"019ead17-fbd4-7d84-8941-ff140a540fc7"}
{"type":"schema.registered","payload":{"type":"notarize-generic-v1","orgId":"019ead17-fbd4-7381-ac3f-5ee1474830f1","version":1,"category":"general","publisher":"local","compatibilityMode":"backward","fieldMappingCount":0,"actorId":"00000000-0000-0000-0000-000000000000","actorRole":"platform","targetType":"schema_subject","targetId":"019ead17-fbf0-72b2-8817-44467a2ede3d"},"timestamp":"2026-06-09T15:54:50.734Z","id":"019ead17-fc09-7dcd-b152-b238559c6853"}
Set format=ocsf to emit OCSF 1.4.0 events your SIEM maps natively (Splunk, Elastic, Sentinel):
curl -s -H "Authorization: Bearer $AGLEDGER_KEY" \
"$AGLEDGER_URL/v1/siem/stream?since=2026-05-01T00:00:00Z&limit=1&format=ocsf"
{"metadata":{"product":{"name":"AGLedger","vendor_name":"AGLedger","version":"1.0.0"},"version":"1.4.0","log_name":"audit","uid":"da5289ae-0c0f-48c8-a686-fcad6d701758"},"time":"2026-06-09T15:54:50.707Z","severity_id":1,"class_uid":3004,"category_uid":3,"type_uid":300403,"activity_id":3,"status_id":1,"message":"admin.org_bootstrapped","actor":{"user":{"uid":"00000000-0000-0000-0000-000000000000","type":"platform"}},"entity":{"uid":"019ead17-fbd4-7381-ac3f-5ee1474830f1","type":"org","data":{"name":"Default","reason":"single-org-install"}}}
To run a continuous poller, advance since to the time of the last event you forwarded on each
pass and keep limit modest. The feed is operational telemetry - it is not the tamper-evident
proof. A record that appears here has not been independently verified by appearing here; that is
the job of Surface 2.
Surface 2 - Verify the chain offline (the real audit)
The audit of record is performed off the Server, against only the published public keys. The verifier has no database, no network, and no AGLedger engine in its dependency tree - so it remains trustworthy even if the Server that produced the chain is later compromised. This is the default posture for a serious audit, and it is fully air-gapped.
Three steps: publish the keys, produce a dump, verify it.
Step 1 - Publish the verification keys
The public signing keys are served unauthenticated and always on. An auditor needs only these.
curl -s "$AGLEDGER_URL/v1/verification-keys"
{"data":[{"keyId":"c4dd3e20388b594d","algorithm":"Ed25519","publicKey":"MCowBQYDK2VwAyEAo95XH8DQ6ZYqhC761LqlCq0b9wxYgHPyHs67OkQ9Frw=","publicKeyRaw":"o95XH8DQ6ZYqhC761LqlCq0b9wxYgHPyHs67OkQ9Frw=","status":"active","activatedAt":"2026-06-09","retiredAt":null}],"envelope":"COSE_Sign1","payloadFormat":"application/vnd.in-toto+cbor","canonicalization":"RFC8949-CDE","coseAlgorithm":-8,"signatureAlgorithm":"Ed25519"}
The same key set is also published at GET /.well-known/agledger-vault-keys.json. Retired keys
stay in the set with a retiredAt date so records signed before a rotation still verify - the key
registry is part of the dump in Step 2, so the auditor never has to ask which key signed what.
Step 2 - Produce a dump
scripts/vault-dump.sh - from the agledger-ai/install
repository - is the one component that touches Postgres. It runs the dump tool that already ships
inside the Server image (no source checkout, Node.js, or pnpm on the host), writing a self-contained
set of NDJSON files the verifier consumes. Run it against a live install, then hand the directory to
the auditor.
./scripts/vault-dump.sh ./dump
{
"outDir": "/dump",
"orgId": null,
"counts": {
"audit_vault": 9,
"vault_checkpoints": 0,
"vault_signing_keys": 1,
"org_admin_reads": 0,
"org_admin_reads_checkpoints": 0
}
}
(For a Helm / non-Compose install, run the same shipped tool directly and copy the directory out:
kubectl exec deploy/agledger -- /nodejs/bin/node dist/scripts/dump-vault.js /tmp/dump, then
kubectl cp <pod>:/tmp/dump ./dump.)
Pass --org <id> to scope the dump to a single org. The output directory holds five files:
audit_vault.ndjson the per-record hash chains
vault_checkpoints.ndjson periodic signed checkpoints over the chains
vault_signing_keys.ndjson the public-key registry, with rotation history
org_admin_reads.ndjson the cross-party admin-read log
org_admin_reads_checkpoints.ndjson signed tree heads over the read log
The dump is database-independent. Keep a copy alongside your database backup - it is the artifact an auditor verifies, and it does not depend on a live Server to be meaningful. (See the backup runbook for where this fits in a backup schedule.)
Step 3 - Verify with stock libraries
The verification needs no AGLedger software. Each row of audit_vault.ndjson carries its canonical
cose_sign1 envelope (RFC 9052 COSE_Sign1 over an in-toto Statement, signed Ed25519);
vault_signing_keys.ndjson carries the public-key registry. Decode each envelope with any stock
COSE library - go-cose, coset (Rust), or pycose - and verify its Ed25519 signature against the
key resolved by signing_key_id. The Sig_structure is constructed per RFC 9052 §4.4; the
signatureInputTemplate field at /v1/verification-keys documents it exactly.
This example uses Python with cbor2 and cryptography - neither of them ours - to walk the whole
dump. Save it as verify-dump.py:
import json, base64, sys, cbor2
from cryptography.hazmat.primitives.serialization import load_der_public_key
from cryptography.exceptions import InvalidSignature
keys = {k["key_id"]: k["public_key"]
for k in map(json.loads, open(sys.argv[1] + "/vault_signing_keys.ndjson"))}
ok = fail = 0
for row in map(json.loads, open(sys.argv[1] + "/audit_vault.ndjson")):
cose = base64.b64decode(row["cose_sign1"])
protected, _unprotected, payload, signature = cbor2.loads(cose).value
pub = load_der_public_key(base64.b64decode(keys[row["signing_key_id"]]))
sig_structure = cbor2.dumps(["Signature1", protected, b"", payload]) # RFC 9052 §4.4
try:
pub.verify(signature, sig_structure); ok += 1
except InvalidSignature:
fail += 1; print("FAIL pos", row["chain_position"], row["record_id"])
print(f"[{'PASS' if not fail else 'FAIL'}] stock-library offline verification")
print(f" audit_vault entries : {ok + fail}")
print(f" signatures verified : {ok}")
print(f" failures : {fail}")
print(f" signing keys : {len(keys)}")
sys.exit(1 if fail else 0)
python3 verify-dump.py ./dump
[PASS] stock-library offline verification
audit_vault entries : 9
signatures verified : 9
failures : 0
signing keys : 1
The audit_vault row count includes the schema-registration chain alongside your record chains.
The script exits non-zero on any signature failure, so it drops straight into a CI gate. A few
stricter COSE libraries refuse AGLedger's vendor-private header labels by default - for pycose,
decode with Sign1Message.from_cose_obj(..., allow_unknown_attributes=True); go-cose and coset
accept them as-is. The full library-quirk notes live under "Offline cryptographic verification" in
GET /llms-full.txt.
A packaged zero-dependency verifier,
@agledger/verify, reproduces this end to end (chains plus SCITT checkpoints) as a single command, and the@agledger/cliverifysubcommand consumes the same dump format. Both are published on npm. The stock-library path above remains the dependency-free way to verify a chain when you do not want to install our tooling.
When verification fails
A failure is the verifier doing its job. Tamper with one byte of a signed envelope and the stock-library script above rejects it at that position:
FAIL pos 1 019ead18-c3f9-7b4d-8edf-2dcd8b99fbc7
[FAIL] stock-library offline verification
audit_vault entries : 9
signatures verified : 8
failures : 1
A signature mismatch like that is one of a small set of integrity classes a full verifier reports.
The stock-library check above proves the signatures (catching CHAIN_SIGNATURE_INVALID and
CHAIN_SIGNATURE_MISSING_KEY); the in-database scripts/vault-verify.sh and the packaged
@agledger/verify add the hash, link, and position classes. The complete set on the per-record
chain:
| Code | Means |
|---|---|
| CHAIN_GENESIS_INVALID | The first entry of a chain does not link to genesis |
| CHAIN_POSITION_GAP | A chain position is missing - an entry was removed |
| CHAIN_LINK_BROKEN | An entry's previous_hash does not match the prior entry |
| CHAIN_HASH_MISMATCH | The stored hash does not match sha256(cose_sign1) |
| CHAIN_SIGNATURE_INVALID | The Ed25519 signature does not verify |
| CHAIN_SIGNATURE_MISSING_KEY | The signing key is not in the published registry |
| CHAIN_COSE_DECODE_FAILED | The signed envelope is not decodable |
| CHAIN_COSE_HEADER_MISMATCH | Chain mechanics in the protected header disagree with the row |
| CHAIN_PAYLOAD_BINDING_MISMATCH | The denormalized row diverges from the signed payload |
| CHAIN_OIDC_ACTOR_MISMATCH | The recorded actor identity disagrees with the signed claim |
Checkpoint and admin-read chains report their own classes (CHECKPOINT_*, TENANT_READ_*,
TENANT_CHECKPOINT_*) on the same model.
Note what does not fail verification: editing the convenience JSON in audit_vault.ndjson without
touching the signed envelope. The verifier trusts the signed cose_sign1 artifact as the source of
truth, not the denormalized columns - so a privileged-database edit of the readable payload is
caught as CHAIN_PAYLOAD_BINDING_MISMATCH, not silently accepted.
On-box reads versus the off-box handoff
An operator can read the chain on the Server through the admin vault endpoints (see the
API reference for /v1/admin/vault/*). Every cross-party admin read is itself notarized
into the org_admin_reads chain - reading the chain is an accountable act, and it appears in the
dump above.
An auditor does the opposite: they take the dump off the Server and verify it on their own machine with only the public keys. On-box reads are for operations. The off-box handoff is the audit. Keep the two roles distinct.
Optional - the SCITT transparency checkpoint
If you operate the SCITT transparency surface (POST /v1/scitt/entries), GET /v1/scitt/checkpoint
returns the current signed tree head for an org's transparency log - a stable anchor an external
monitor can pin and re-check for consistency over time. The checkpoint is org-scoped: use an
org-scoped key (a platform-scope key is refused with 403 ORG_REQUIRED).
curl -s -H "Authorization: Bearer $AGLEDGER_KEY" "$AGLEDGER_URL/v1/scitt/checkpoint"
{"treeSize":0,"rootHex":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","logId":"019ead17-fbd4-7381-ac3f-5ee1474830f1","iat":1781020588,"kid":"c4dd3e20388b594d","signature":"a130648224123432149bf933086a9a25cab63ee7f8a8e6789e52056a5ae7f4f5f1b1447dc3d3ae8e81bdb0d2ddd31f90a93a3d35c83a851d51011ea82502c90f"}
A treeSize of 0 with the empty-tree root above is a fresh log with no SCITT entries registered
yet. This surface is independent of the audit_vault chain in Surface 2: the offline verifier is
the proof of the record chain; the SCITT checkpoint is the anchor for the separate SCRAPI
transparency log.
*Validated against API v1.1.0 on 2026-06-10 (Developer Edition, Docker Compose: scripts/vault-dump.sh
- stock-library COSE/Ed25519 offline verification of the dump, SIEM stream in both
rawandocsfformats, and the SCITT checkpoint - all re-run live). The integrity-class table and the tamper-demo output are illustrative and version-stable.*