Audit log
STACK's Layer 4. Every decision from every layer lands as one row in an append-only log. Each row binds to the previous row via a SHA-256 hash so that silent mutation of an older row invalidates every row after it. Each operator has its own independent chain; no operator can observe or collide with another tenant's entries.
Tamper-evidence is a property of the chain, not a claim. STACK ships a verifier that re-walks your chain and reports the first break. External observers can anchor the head hash and replay later to prove the log was not retroactively altered.
Row shape
- entry_id - opaque primary key, generated at write time
- timestamp - unix milliseconds of the write
- trace_id / span_id / parent_span_id - OpenTelemetry-style tracing IDs
- operator_id / agent_id / passport_jti - tenant + subject context
- layer - one of vault | proxy | dropoff | skills | system
- action - dotted action name (passport.issue, credential.proxy, skill.invoke, …)
- outcome - success | failure | pending | blocked
- duration_ms - operation latency (nullable)
- payload_hash - SHA-256 of the operation payload when relevant (nullable)
- error_code / error_message - populated on failure
- prev_entry_hash - entry_hash of the previous row for this operator
- entry_hash - SHA-256 of the canonical JSON of the row contents, including prev_entry_hash
Per-operator hash chain
The canonical hash input is a fixed-field object serialized with stable key order:
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.
Verification
The GET /v1/audit/verify-chain endpoint walks your chain in timestamp order and checks two invariants for every row:
- entry_hash matches a freshly-computed hash of the row's fields - detects content mutation
- prev_entry_hash equals the prior row's entry_hash - detects insertion, deletion, or re-ordering
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.
Chain head (external anchor)
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"
}Retention
The worker prunes expired rows on a daily cron. Retention windows are tier-scoped:
- free - 7 days
- developer - 30 days
- studio - 90 days
- enterprise - 365 days (custom windows via operators.audit_retention_days_override)
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.
Cascade revokes
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 that died with it. Filter the tail by passport_jti to see the full revoke graph in one place.
Tail (live monitoring)
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_supportExport (date-range)
GET /v1/audit/export returns rows in JSON, NDJSON, or CSV, capped at 50,000 rows per request. Paginate via offset for larger ranges. Every export includes the fullentry_hash andprev_entry_hash so an external verifier can replay the chain offline.
For the request/response shape, see /docs/api/audit.
What tamper-evidence does and does not do
- Detects any silent change to a row after it was written - including re-ordering or deletion in the middle of the chain.
- Does NOT prevent STACK (with full DB access) from rewriting the chain + recomputing hashes. That is what the externally-stored chain head is for: a mismatch between the current head and the anchored head proves rewriting happened.
- Does NOT encrypt row contents. If you need payload confidentiality beyond payload_hash, encrypt before you send data that ends up in audit.