STACK
MENU
DOCS / CONCEPTS / AUDIT LOG

Audit log

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.

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 hash fields, including prev_entry_hash

Per-operator hash chain

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.

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 a broken retained sequence

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
  • pro - 12 months
  • business - 24 months
  • 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 revoked 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. 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.

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