Ana içerik geç

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_typeEmitted byContains
secret_setvaultSet() upsert + grant/revoke admin opsConfirms a secret arrived under name, owned by bound_zid, with permissions[] snapshot
secret_readSuccessful ZeqContext.read() after permission check passesThe entangled state's record that actor_zid decrypted this secret at this Zeqond
secret_rotatedrotationDaemon re-encryption passA fresh IV was written; old ciphertext is dead; expires_zeqond bumped
secret_deniedPermission gate rejection OR rate-limiter tripAttempted 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:

  1. name — the secret's stable identifier (e.g. STRIPE_SECRET_KEY).
  2. actor_zid — the ZID that performed the action. System reads use "ZEQ-SYS".
  3. transition_id — the entangled state row's UUID, stable across re-orderings.
  4. purpose — the action label (read / auto_rotated / denied / granted / etc.) — same string lives in payload_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:

ZeqondTypeActorReasonPurposeproof (head 8)
2,289,500,001secret_setZEQ-FOUNDATIONcreated8a1f...
2,289,501,287secret_readZEQ-FOUNDATIONread61b3...
2,289,501,290secret_readZEQ-FOUNDATIONread4f02...
… (45 more) …
2,289,587,401secret_rotatedZEQ-SYSauto_rotatedcd91...

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 DELETE rights can truncate it — though the LAG-based verifier above will detect the resulting hole. For tamper-resistance against your own DBA, replicate audit_log to 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/audit returns the same rows in JSON
  • Read also: audit_log schema in app/lib/db/src/schema/auditLog.ts — the framework's universal audit substrate