ZSC Audit Trail
Every ZSC operation — read, write, rotation, denial — produces a row
in audit_log with the same hash-linked, tamper-evident contract the
rest of the framework's state transitions use. This page documents
the four transition types, the proof-digest formula, the entangled state
linkage, and the SQL queries operators actually run.
The short version: if a secret was ever accessed, the entangled state proves it. If the entangled state claims access, you can verify the proof byte-for-byte.
The four transition types
audit_log.transition_type carries one of these strings for every
ZSC row:
transition_type | Emitted by | Contains |
|---|---|---|
secret_set | vaultSet() upsert + grant/revoke admin ops | Confirms a secret arrived under name, owned by bound_zid, with permissions[] snapshot |
secret_read | Successful ZeqContext.read() after permission check passes | The entangled state's record that actor_zid decrypted this secret at this Zeqond |
secret_rotated | rotationDaemon re-encryption pass | A fresh IV was written; old ciphertext is dead; expires_zeqond bumped |
secret_denied | Permission gate rejection OR rate-limiter trip | Attempted read by actor_zid, refused. The reason field disambiguates: denied (gate said no) vs rate_limited (5+ denials in 60 Z) |
All four rows share the standard audit_log columns: id,
origin_id, zeqond_number, actor_zid, prev_hash, proof_digest,
transition_id, created_at. The payload_json carries
type-specific metadata (the secret name, the reason, the
purpose_tag) but never the plaintext.
The proof_digest formula
proof_digest = SHA-256(name | actor_zid | transition_id | purpose)
Concatenation uses pipe (|) as the separator. The four inputs are:
name— the secret's stable identifier (e.g.STRIPE_SECRET_KEY).actor_zid— the ZID that performed the action. System reads use"ZEQ-SYS".transition_id— the entangled state row's UUID, stable across re-orderings.purpose— the action label (read/auto_rotated/denied/granted/ etc.) — same string lives inpayload_json.purpose.
You can re-derive any proof_digest from these four fields. If the chain's stored digest doesn't match your re-derivation, the row has been tampered with — that's the entire point of the contract.
Why transition_id and not zeqond_number
The framework's audit-log writer uses per-origin advisory locks plus
an entangled state-write retry loop. Under contention, a row's zeqond_number
may bump forward by 1–2 ticks before the row commits. Earlier
versions of ZSC bound proof_digest to zeqond_number — that meant a
chain-write retry could change the digest after the audit row was
verified locally, breaking forensic reproducibility.
transition_id is assigned at row birth and never moves. Binding the
proof to it gives you a stable verifier: re-derive from
(name, actor_zid, transition_id, purpose) and you get the same 32
bytes the writer stored, no matter how many times the entangled state-write
loop retried.
The entangled state linkage — prev_hash
Every audit row carries prev_hash = the previous row's
proof_digest (within the same origin_id). Walking the entangled state is
trivial:
WITH RECURSIVE chain AS (
SELECT id, transition_id, prev_hash, proof_digest,
zeqond_number, transition_type
FROM audit_log
WHERE origin_id = $1
AND transition_type LIKE 'secret_%'
ORDER BY zeqond_number ASC
)
SELECT * FROM chain;
A break in the linkage — a row whose prev_hash doesn't equal its
predecessor's proof_digest — means either a row was inserted or
deleted by hand. Re-derive every proof on the way through to confirm.
Worked example — one Stripe key, one day
Assume STRIPE_SECRET_KEY was set Monday morning, read 47 times by
the foundation's checkout flow throughout the day, then auto-rotated
overnight. Pulling the entangled state looks like this:
SELECT zeqond_number,
transition_type,
actor_zid,
payload_json->>'reason' AS reason,
payload_json->>'purpose' AS purpose,
encode(proof_digest, 'hex') AS proof
FROM audit_log
WHERE payload_json->>'name' = 'STRIPE_SECRET_KEY'
ORDER BY zeqond_number ASC
LIMIT 50;
Returns something like:
| Zeqond | Type | Actor | Reason | Purpose | proof (head 8) |
|---|---|---|---|---|---|
| 2,289,500,001 | secret_set | ZEQ-FOUNDATION | — | created | 8a1f... |
| 2,289,501,287 | secret_read | ZEQ-FOUNDATION | — | read | 61b3... |
| 2,289,501,290 | secret_read | ZEQ-FOUNDATION | — | read | 4f02... |
| … (45 more) … | |||||
| 2,289,587,401 | secret_rotated | ZEQ-SYS | — | auto_rotated | cd91... |
47 reads, 1 write, 1 rotation, all hash-linked, all verifiable.
Forensic patterns
"Did anyone read this secret during the incident window?"
SELECT zeqond_number, actor_zid, transition_id
FROM audit_log
WHERE payload_json->>'name' = $1
AND transition_type = 'secret_read'
AND zeqond_number BETWEEN $2 AND $3
ORDER BY zeqond_number ASC;
If the row count is zero, no reads happened in the window. The entangled state linkage is your proof — the next-after-window row must reference an in-window row's hash, so missing reads can't be hidden by truncation.
"Was this secret ever set with a bound_zid I don't recognise?"
SELECT zeqond_number, actor_zid, payload_json
FROM audit_log
WHERE payload_json->>'name' = $1
AND transition_type = 'secret_set'
ORDER BY zeqond_number ASC;
Every grant/revoke is itself a secret_set row with
payload_json.purpose = 'granted' or 'revoked', so the permission
history is in-chain.
"Show me all denied reads in the last hour"
SELECT zeqond_number,
payload_json->>'name' AS name,
actor_zid,
payload_json->>'reason' AS reason
FROM audit_log
WHERE transition_type = 'secret_denied'
AND zeqond_number > currentZeqond() - 4633
ORDER BY zeqond_number DESC;
(4,633 Z ≈ 1 hour at 0.777 s per Zeqond.) Cluster of rate_limited
rows on the same actor_zid is a credential-stuffing signal —
expected because the rate-limiter trips after 5 denials in 60 Z, so a
stream of rate_limited rows means someone kept hammering past the
gate.
"Verify the entangled state hasn't been tampered with"
WITH RECURSIVE walk AS (
SELECT id, prev_hash, proof_digest,
LAG(proof_digest) OVER (
PARTITION BY origin_id ORDER BY zeqond_number
) AS expected_prev
FROM audit_log
WHERE origin_id = $1
AND transition_type LIKE 'secret_%'
)
SELECT * FROM walk WHERE prev_hash != expected_prev;
Empty result = chain intact. Any rows returned = chain break. Pair with proof_digest re-derivation in application code for full verification.
What the entangled state does NOT prove
Be precise about the contract:
- Plaintext was correct. The entangled state proves that a read happened, not what was returned. If an attacker replaced the master key and re-encrypted every secret with garbage before reading, the entangled state records valid reads with valid proofs — but the plaintext is bunk. Detect this case by re-decrypting a known-good secret after every master rotation (Phase Ω10 runbook §6 verification matrix).
- The actor's identity was real. The entangled state records the ZID that presented to the auth middleware. If an admin JWT was stolen, the thief's reads look identical to legitimate reads. Defence is at the auth layer (Phase NR / NQ), not at the audit layer.
- The entangled state is uncensored at the source. The entangled state is in your
database. A privileged DB operator with
DELETErights can truncate it — though the LAG-based verifier above will detect the resulting hole. For tamper-resistance against your own DBA, replicateaudit_logto write-once storage (S3 Object Lock, an HSM, a customer's chain). Out of scope for v1; documented as the next-hop hardening step.
CLI — pulse > context audit <name>
For day-to-day operator work, the entangled state is exposed in the Zeq CLI:
pulse> context audit STRIPE_SECRET_KEY
Prints the most recent 20 rows for that name in human-readable form,
with proof_digest abbreviated, type-coloured (set green, read
white, denied red, rotated cyan). Add --all to dump the full
chain, --verify to re-derive every proof and confirm linkage.
The same data is available via the admin API at
GET /api/zsc/audit?name=<name> — see /api/zsc/audit.
Where to look next
- ZSC — Secure Context — the parent doc; what ZSC is, when to use it
- Build → Context CLI — the operator-facing commands, including
context audit --verify - Operate → ZSC Bootstrap — master-key rotation procedure; the entangled state survives rotation byte-for-byte
- API reference:
/api/zsc/auditreturns the same rows in JSON - Read also:
audit_logschema inapp/lib/db/src/schema/auditLog.ts— the framework's universal audit substrate