Each audited event lands as one row in an append-only log. Each row binds to the previous row with a SHA-256 hash. Changing a retained row breaks verification from that point forward. Each operator has an independent chain.
STACK ships a verifier that recomputes the chain and reports the first break. Store the chain head outside STACK when you need to detect a complete chain rewrite.
The canonical hash input uses a stable field order. It always includes the fields below. It also binds claim hashes, request and response measurements, and partner attribution when those values are present.
entry_hash = sha256(JSON.stringify({
entry_id,
timestamp,
operator_id,
agent_id,
layer,
action,
outcome,
prev_entry_hash
}))The first row for a new operator references a genesis constant (0 × 64 hex chars). Every subsequent row'sprev_entry_hash equals the previous row'sentry_hash.
The GET /v1/audit/verify-chain endpoint walks your chain in timestamp order and checks two invariants for every row:
The first row in the scanned range seeds the walk; itsprev_entry_hash is accepted as-is because its predecessor may sit outside the window or have been pruned under retention. To verify end-to-end, scan from the earliest retained row.
GET /v1/audit/chain-head returns the latestentry_hash plus the total row count. Record the head at a fixed cadence (daily, hourly - your choice) and store it somewhere STACK cannot reach. If any row is later mutated, a re-verify against your stored head will diverge.
{
"operator_id": "op_acme",
"latest_entry_hash": "ab3f…81d3",
"latest_timestamp": 1747913532614,
"total_entries": 184723,
"observed_at": "2026-04-23T14:32:12.603Z"
}The worker prunes expired rows on a daily cron. Retention windows are tier-scoped:
Pruning breaks prev_entry_hash continuity across the boundary it removes. The verifier handles this by seeding the walk from the oldest retained row. In- range tamper evidence is preserved; rows older than retention are gone and cannot be verified.
Revoking a parent passport cascades to every delegated child viaparent_passport_id. Audit captures both ends of the chain: one passport.revoke entry for the parent, plus onepassport.revoke_cascade entry per child revoked with it. Filter the tail by passport_jti to see the full revoke graph in one place.
GET /v1/audit returns the most recent N entries newest-first, with optional since watermark for incremental polling and optional agent_id /passport_jti filters. The CLI'smonitor --follow, the JS SDK'sstack.audit.list(), and the MCP toolstack_audit_list all use this endpoint.
# CLI — tail one agent
STACK_API_KEY=sk_live_… npx @getstackrun/cli monitor --follow --agent agt_supportGET /v1/audit/export returns rows in JSON, NDJSON, or CSV, capped at 50,000 rows per request. Paginate via offset for larger ranges. JSON and NDJSON include the stored chain fields for offline verification. CSV includes entry_hash but omits prev_entry_hash.
For the request/response shape, see /docs/api/audit.