Skip to content

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).

With your pangolin.config orch context wired (transport + anchor + storage), point audit at a run that has sealed:

Terminal window
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:

Terminal window
pangolin orch audit <run-id> --out bundle.json

The 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 } }
}
}

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 its prevHash link 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.

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: detect

What 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-mismatch

The 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:

Terminal window
pangolin verify bundle.json

verify 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:

Terminal window
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 is tamper-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 the tamper-evident ceiling.

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.