Export & verify an audit bundle
After a run seals, pangolin orch audit <run-id> assembles a verifiable evidence
bundle: the per-item dispatch manifests, the hash-chained lifecycle log, the
per-item outcomes, and a verification report that recomputes the Merkle root
and checks it against the live anchor. This guide produces the bundle, reads the
result, and explains which guarantee tier the report can honestly assert.
audit is read-only and CLI-only — it never mutates run state, and it is
deliberately not exposed on MCP (auditing is an operator action, not an
AI-loop action).
1. Run pangolin orch audit <run-id>
Section titled “1. Run pangolin orch audit <run-id>”With your pangolin.config orch context wired (transport + anchor + storage),
point audit at a run that has sealed:
pangolin orch audit <run-id>By default the full bundle is printed to stdout as pretty JSON. To write it to a
file instead, pass --out:
pangolin orch audit <run-id> --out bundle.jsonThe bundle is the §6.5 compliance artifact. Its top-level shape (from
AuditBundle in packages/pangolin-core/src/audit.ts — the orchestrator
re-exports it for back-compat):
{ "runId": "...", "manifests": [ /* per-item DispatchManifest — refs only, never secret values */ ], "auditLog": { "entries": [ /* AuditEntryRow[] — hash-chained lifecycle events */ ], "root": { /* AnchoredRoot — the anchored (optionally signed) Merkle root */ } }, "items": [ { "id": "...", "status": "...", "attempts": 1, "actor": "...", "resultRef": "...", "manifestRef": "..." } ], "report": { "runId": "...", "intact": true, "anchorId": "local", "guarantee": "detect", "claim": "tamper-detecting", "checks": { "chain": { "ok": true }, "root": { "ok": true }, "signature": { "ok": "n/a" }, "anchor": { "ok": true } } }}2. Read the verification result
Section titled “2. Read the verification result”The report object is the headline. The CLI computes it by re-running
verify(): it recomputes each entry’s chain hash, recomputes the Merkle root
from the entry hashes, fetches the anchored root from the live anchor (not a
copy embedded in the bundle), and compares.
The report fields (VerificationReport, contracts/audit.ts:33):
| Field | Meaning |
|---|---|
| runId | The run this report covers. |
| intact | true only when the chain, the recomputed root, the anchored root, and any signature all agree. |
| anchorId | The anchor in force — "local" for LocalAnchor, "s3:<bucket>" for S3ObjectLockAnchor. |
| guarantee | The anchor’s tier: "detect", "external-immutable", or "witnessed". |
| claim | What verification can prove: "tamper-detecting" or "tamper-evident". |
| failure | Present only when intact is false: the first failing check — one of chain, anchor-missing, root-mismatch, signature. |
| checks | Collect-all per-check results: chain, root, signature, anchor, each { ok: true \| false \| 'n/a'; detail? }. Every check is evaluated (no early return), so a failing bundle reports the state of all four. This is what pangolin verify renders as a checklist. |
report.intact === true means every check passed: the lifecycle log has not
been altered, and the recomputed root matches what the anchor holds.
report.intact === false means a check failed, and report.failure names
which one:
chain— an entry’s hash or itsprevHashlink does not recompute; the lifecycle log was edited or reordered.anchor-missing— no anchored root was found for this run; verification cannot compare against an external truth.root-mismatch— the recomputed Merkle root differs from the anchored root.signature— a signature was present and a verifier was supplied, but the signature did not verify.
The CLI sets a non-zero exit code when the bundle does not verify
(if (!bundle.report.intact) process.exitCode = 1; in
packages/pangolin-cli/src/cmd-orch.ts), so audit is safe to gate a CI step or
an incident-review script on.
3. Interpret the guarantee tier
Section titled “3. Interpret the guarantee tier”report.claim is the honest, anchor-scoped statement of what verification
proves. It is derived in verify.ts from the anchor’s guarantee — the
tamper-evident claim is licensed only at external-immutable or higher:
| Anchor | guarantee | report claim | What it proves | What it does NOT prove |
|---|---|---|---|---|
| LocalAnchor (default) | detect | tamper-detecting | Catches accidental or clumsy mutation of the log. | The anchored root lives in the same store as the log — it is not evidence against an attacker who controls that store. |
| S3ObjectLockAnchor | external-immutable | tamper-evident | The signed root lives in S3 Object Lock (compliance mode) — a separate trust domain that survives a DB-side tamper attempt. | It records what ran (environment + inputs by hash); it does not reproduce the agent’s output, and it is not a compliance certification. |
What a clean run prints. The acceptance demo (examples/offload-fanout) runs
on the default LocalAnchor and prints the report fields:
=== Audit bundle === intact: true claim: tamper-detecting anchorId: local guarantee: detectWhat tamper detected looks like. If the log is altered, intact flips to
false, claim is forced down to tamper-detecting, and failure names the
failing check — for an edited log entry:
=== Audit bundle === intact: false claim: tamper-detecting anchorId: s3:my-audit-bucket guarantee: external-immutable failure: root-mismatchThe process exits non-zero in this case.
4. Hand the bundle to a third party — pangolin verify
Section titled “4. Hand the bundle to a third party — pangolin verify”The bundle written by --out is a self-contained JSON file. It carries the
dispatch manifests (refs only — secretRefs are references, never secret
values), the full hash-chained auditLog (entries plus the anchored root), the
per-item outcomes, and the report from the run that produced it.
An independent party re-verifies it with the top-level pangolin verify command:
pangolin verify bundle.jsonverify rebuilds an in-memory audit store from the bundle’s entries, re-runs the
same verify() logic, and prints a human-readable checklist + hash-chained
ledger. On a clean bundle:
pangolin verify · run_a3f9c2 ✓ TAMPER-EVIDENT ────────────────────────────────────────────────────────── ✓ chain 10 entries, hash-linked, no gaps ✓ root merkle c4f1a9… = anchored root ✓ signature ed25519 / pangolin-prod valid ✓ anchor s3:my-audit-bucket (external-immutable) …If any entry was altered after sealing, the verdict flips to ✗ TAMPERED, the
failing check is marked (and the offending ledger row flagged), and the command
exits non-zero — so verify is safe to gate a CI step or an incident-review
script on. Pass --json to emit the raw VerificationReport instead, or --full
to print every ledger row.
The trust boundaries, drawn out — the entire difference between the two tiers is which trust domain holds the anchored root the verifier compares against:
flowchart LR
subgraph OP["Operator's trust domain — operator can write here"]
SRV["serve — seals the epoch"]
DB[("run-state SQLite<br/>audit log entries")]
LROOT[("LocalAnchor root<br/>guarantee: detect")]
SRV --> DB
SRV --> LROOT
end
subgraph EXT["External trust domain — operator cannot rewrite"]
S3ROOT[("S3 Object Lock root<br/>COMPLIANCE mode WORM<br/>guarantee: external-immutable")]
end
SRV -->|"anchor the signed root"| S3ROOT
BUNDLE["bundle.json — entries + embedded root copy<br/>(data UNTRUSTED until verified)"]
DB -->|"pangolin orch audit --out"| BUNDLE
subgraph AUD["Auditor's trust domain"]
VER["pangolin verify<br/>replays the chain · recomputes the root"]
end
BUNDLE -->|"handed over"| VER
VER -->|"fetches the LIVE anchored root —<br/>never the bundle's embedded copy"| S3ROOT
VER -.->|"local tier: the root sits in the operator's<br/>own store → claim stays tamper-detecting"| LROOT
For programmatic use, the same check is the verifyBundle(bundle, { anchor })
library entry point. It lives canonically in @quarry-systems/pangolin-core (the
single source of truth for the verify core), is also surfaced by
@quarry-systems/pangolin-verify (the standalone verifier — see below), and is
re-exported from @quarry-systems/pangolin-orchestrator for back-compat. An auditor
can re-verify a handed-over bundle inside their own tooling from whichever package
they already have.
5. Verify without the orchestrator — @quarry-systems/pangolin-verify
Section titled “5. Verify without the orchestrator — @quarry-systems/pangolin-verify”An auditor handed a bundle usually does not want to install the orchestrator
engine just to check it. @quarry-systems/pangolin-verify is a standalone package
with zero orchestrator dependency — it depends only on pangolin-core — and
ships a bin you can run with no install:
npx @quarry-systems/pangolin-verify <bundle.json> [--anchor <verify-context.json>] [--json] [--full]It runs in one of two modes, and the mode sets the ceiling on the tamper claim it can earn:
- Offline (default — no
--anchor). It recomputes the chain and the Merkle root and compares against the root embedded in the bundle. With no external anchor to consult, the strongest claim it can reach istamper-detecting— it proves the bundle is internally consistent, but not that the embedded root matches an immutable external copy. - Anchor-checked (
--anchor <verify-context.json>). The verify-context points the verifier at the real WORM anchored root (e.g. the S3 Object Lock object). The verifier fetches that external root and compares — exactly the check the embedded copy cannot do alone — so a passing run can reach thetamper-evidentceiling.
The report it prints (and emits raw under --json) carries the same fields as
pangolin verify, including the timeTier field (asserted vs tsa-attested —
see Audit & guarantee tiers → Trusted time).
--full prints every ledger row.