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:

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

json
{
  "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.

bash
# CLI — tail one agent
STACK_API_KEY=sk_live_… npx @getstackrun/cli monitor --follow --agent agt_support

Export (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.
stack | docs