The ledger
Every scored event appends one row to ledger_records. Every review decision made through vigil-gate appends one more on the same chain. Nothing updates or deletes a row: the database rejects both.
One record
| Column | Meaning |
|---|---|
seq | Global sequence. The chain is per org: one chain per org_id. |
agent_id, event_id, record_type | What the record is about. record_type is event (written by vigil-api when scoring) or review (written by vigil-gate). |
content | Canonical JSON. For event: the event, the payload as received, source, actor, resolved_by, the monitored flag, the BII, the four signals, the gate, the reason, timestamps. For review: action_id, outcome, reviewer, note, previous_gate, bii_at_trigger, reviewed_at. |
content_hash | sha256(content) |
prev_hash | The previous record's chain_hash. Genesis is 64 zeros. |
chain_hash | sha256(prev_hash + content_hash) |
hmac | HMAC-SHA256(VIGIL_AUDIT_SECRET, chain_hash) |
policy_digest | The digest of the org's active policy when the record was written |
recorded_at | UTC |
The chain rule is L(n) = H(L(n-1) || C(n)). A single changed byte in any record changes every chain_hash after it, so the head hash commits to the whole history.
Two independent seals
The hash chain proves order and integrity: no record was altered, inserted or removed. The HMAC proves origin: the record was written by a process holding the org's audit secret. Verify the chain with no secret at all. Verify the HMACs with the secret your admin gave your auditor.
Append-only at the database
Postgres triggers on ledger_records, behavioral_events and bii_scores raise on any UPDATE or DELETE, for every role. The triggers are created by the first Alembic migration and travel with the schema. There is no history-deletion route. Agents retire; records stay.
Verify online
GET /audit/{agent_id}/verify recomputes the key's org chain and every HMAC with the server's secret:
{ "ok": true, "length": 1412, "head": "e4ac89ab...", "first_bad_seq": null, "agent_records": 88 }
Verify offline, trusting nobody
Export the raw rows and run the standalone verifier. It has no dependency on the VIGIL codebase.
curl -sf -H "Authorization: Bearer $VIGIL_KEY" "$VIGIL_URL/audit/export?since_seq=0&limit=10000" > export.json
python3 - <<'EOF'
import hashlib, json
recs = sorted(json.load(open("export.json")), key=lambda r: r["seq"])
prev = recs[0]["prev_hash"] if recs else "0" * 64 # the export may start mid-chain
for r in recs:
content_hash = hashlib.sha256(r["content"].encode()).hexdigest()
chain_hash = hashlib.sha256((prev + content_hash).encode()).hexdigest()
assert r["prev_hash"] == prev and chain_hash == r["chain_hash"], f"BROKEN at seq {r['seq']}"
prev = chain_hash
print(f"OK {len(recs)} records, head {prev}")
EOF
rm -f export.json
The repo ships the reference verifier, tools/vigil_verify.py, which also checks the HMACs when given the secret:
python tools/vigil_verify.py export.json --secret "$VIGIL_AUDIT_SECRET"
The repo's self-check (python -m tests.qa_self) corrupts a copy of the export first and requires the verifier to catch it, then verifies the real export. A verifier that has never been seen to fail proves nothing.
Export
GET /audit/export?since_seq=0&limit=10000 returns the org's raw rows for offline verification. GET /audit/{agent_id} returns the provenance log for one agent: events joined to their gate outcome and ledger seq, chain_hash and policy_digest.
Not yet built
Daily anchoring of the chain head to an external timestamp is planned and not built. Until then, the export you take today is your anchor: keep the head hash, and any later export must reproduce it.