Tamper-evident audit log of every policy decision, write, and forget.

Audit

Every policy decision, write, and forget produces an audit row. The audit log is append-only and SHA-256-chained — POST /v1/audit/verify lets external auditors confirm a row hasn't been tampered with.


GET /v1/audit

GET /v1/audit
  ?actor=user:alice
  &capability=forget.gdpr
  &decision=allow
  &limit=100

| Query param | Notes | |---|---| | actor | Filter by actor ID | | capability | Filter by capability name | | decision | allow or deny | | endpoint | Filter by endpoint path | | since / until | RFC 3339 time bounds |

Response

{
  "items": [
    {
      "id":          "audit_01HX...",
      "ts":          "2026-05-15T10:42:09Z",
      "actor":       "user:alice",
      "token_jti":   "...",
      "tenant_id":   "acme",
      "scope":       "org:acme/dept:eng/user:alice",
      "endpoint":    "/v1/experience",
      "capability":  "scope.write",
      "decision":    "allow",
      "decided_by_tier": "scope",
      "request":     { "body_sha256": "..." },
      "response_status": 202,
      "event_id":    "evt_01HX...",
      "client_ip":   "203.0.113.42",
      "request_id":  "req_01HX...",
      "elapsed_ms":  4,
      "gdpr":        false
    }
  ]
}

GET /v1/audit/

Single audit row.


POST /v1/audit/verify

Confirm an audit row hasn't been tampered with. Pass the row ID and the exact body you have on file; the server recomputes the hash and reports a match.

{
  "audit_id": "audit_01HX...",
  "body": "<canonicalized JSON of the row>"
}

Response

{
  "match":     true,
  "algorithm": "sha256"
}

match: false doesn't mean tampering — it can also mean canonicalization differs (use the body you got from GET /v1/audit/{id} verbatim).