Audit log

Every decision Writ makes — ALLOW, DENY, STEP_UP, REVOKE — lands in your tenant’s audit log as a receipt. The log is the product’s answer to the auditor: instead of dumping CloudTrail and hoping, the compliance owner opens the receipt and sees who, what, which purpose, which policy version, when.

Watch it live

Three ways, same stream:

./writ stream --key writ_KEY # terminal tail, reconnects automatically
// browser / SIEM: server-sent events, resumes with Last-Event-ID
const es = new EventSource("https://j72ckh66ukck2kcbq3oiwxaalm0olxwb.lambda-url.us-east-1.on.aws/v1/receipts/stream?apiKey=writ_KEY");
es.addEventListener("receipt", e => ingest(JSON.parse(e.data)));

Or poll GET /v1/receipts?since=<receiptId> — it returns only newer receipts plus a cursor.

Filter by decision

GET /v1/receipts/stream?decision=DENY streams only one decision — ALLOW, DENY, or STEP_UP (case-insensitive; anything else is a 400). The filter applies to the initial replay and to every frame emitted while the connection is held.

The DENY-only stream shows every write Writ blocked before it happened, in real time. The dashboard’s Denied panel is built on it — verb, target, and reason for each stopped write, with a running count.

Streaming to your SIEM

Security teams don’t want another dashboard — they want the event stream. GET /v1/receipts/stream is that feed: every gated write your tenant’s agents attempt, as server-sent events, in near real time. This is the answer to “can we pipe this into our SIEM?”

Connecting. Authenticate with Authorization: Bearer <key>, or pass ?apiKey= — browsers’ EventSource can’t set headers, which is why the query param exists. The server holds the connection up to timeout seconds (default 10, max 12), emitting event: receipt frames plus : ping heartbeats, then closes it. Reconnect and resume with since (or Last-Event-ID, which EventSource sends automatically). Omit since on first connect to replay the most recent replay receipts (default 20) and then go live.

import json, sseclient, requests # pip install sseclient-py requests
url = "https://j72ckh66ukck2kcbq3oiwxaalm0olxwb.lambda-url.us-east-1.on.aws/v1/receipts/stream"
headers = {"Authorization": "Bearer writ_KEY", "Accept": "text/event-stream"}
last_id = None
while True: # reconnect loop — the server closes the hold every ~10s by design
params = {"since": last_id} if last_id else {"replay": 50}
resp = requests.get(url, headers=headers, params=params, stream=True, timeout=30)
for event in sseclient.SSEClient(resp).events():
if event.event == "receipt":
last_id = event.id
ingest(json.loads(event.data)) # your Splunk/Datadog/warehouse sink

What each event carries. Every frame’s data is a complete receipt: receiptId, tenantId, decision (ALLOW, DENY, STEP_UP, REVOKE), sponsorId, agentId, verb, target, purpose, reason, policyVersion, createdAt, plus prevHash/chainHash for the tamper-evident chain. No joins needed downstream — enrich or index it as is. Use receiptId as the dedup key; the resume cursor guarantees at-least-once delivery across reconnects, never gaps.

Filtering. Append ?decision=DENY to stream only stopped writes — the feed your SOC actually wants to watch. ALLOW and STEP_UP work too; anything else is a 400. The filter applies to the replay and the live hold alike.

Tenant isolation. One tenant’s stream never carries another tenant’s receipts. A receipt ID from another tenant is indistinguishable from a missing one everywhere in the API.

Polling fallback. Where SSE is unavailable, poll GET /v1/receipts?since=<cursor> — it returns only newer receipts plus a cursor for the next poll.

Tamper evidence

Every receipt is hash-chained: each one stores prevHash (the previous receipt’s chainHash, or GENESIS for the first) and chainHash = HMAC-SHA256(canonical receipt fields + prevHash), keyed by the deploy’s WRIT_CHAIN_SECRET. The tenant’s chain head advances with a conditional write, so concurrent checks serialize into one linear chain instead of forking.

Anyone with raw table access who alters a stored receipt breaks the chain — and the break is detectable:

./writ verify-chain --key writ_KEY # verify the chain, newest first
curl -s -H "Authorization: Bearer <redacted>" \
"$WRIT_BASE/v1/receipts/verify?limit=200"

GET /v1/receipts/verify returns {"ok": true, "checked": N, "tip": "<hash>", "tipReceipt": "<id>", "chainedFrom": "<id>", "legacyCount": M, "complete": true} when the chain is intact, or {"ok": false, "brokenAt": "<receiptId>", "reason": "..."} at the first receipt that fails.

Receipts written before chaining was enabled have no chain fields; verification stops at them and reports them as legacyCount.

Deploy notes

Set WRIT_CHAIN_SECRET to a dedicated secret on your deploy (it falls back to WRIT_TOKEN_SECRET). Whoever holds the table but not the deploy secret cannot forge a valid link.

This is the control that supports NIST SP 800-53 AU-9 (Protection of Audit Information): audit records are protected against unauthorized modification and deletion, and tampering is detectable on demand. It also feeds AU-3 (content of audit records) — every receipt records who, what, when, and why — and AU-12 (audit generation). Writ is not itself certified; your assessor determines compliance for your deployment, but the chain gives them the tamper-evidence mechanism AU-9 asks for.

Billing note

The audit log is also the meter: every receipt is a billable event under commit + volume pricing, and because you hold the same tamper-evident log, you can verify usage yourself. See Pricing.