Skip to content

Audit trail export

AuditStore is the append-only log behind every governed decision. It records the full input/output of each gate evaluation — tool name, args, session context, the PolicyDecision that came back, the policy version that produced it, and the resulting outcome — in a SQLite table protected by SQL triggers that block updates and deletes. The store is the authoritative answer to "who decided what, on which rules, when?".

What gets recorded

Every record is an AuditRecord:

FieldWhat it carries
idUUID — the record's primary key
session_idThe originating session
tool_nameThe action attempted (approve_refund, read_file, etc.)
argsFull argument dict the agent passed
policy_decision{allow, deny, requires_hitl, risk_tier, reason, ...}
policy_versionSHA-256 (truncated to 16 hex chars) over every .rego file
hitl_action_idSet when the call went to the HITL queue
hitl_decided_byOperator who approved/denied (when applicable)
outcomeOne of seven canonical outcomes — see below
timestampUTC ISO-8601
contextFull SessionContext snapshot (role, scopes, depth, parent token, …)
parent_session_id / delegation_depthLineage for delegated agents

The seven outcomes

OutcomeWhen it's written
allowedPolicy returned allow=True and the tool ran
blockedPolicy returned deny=True
soft_deniedPolicy returned allow=False without an explicit deny (no rule matched)
hitl_queuedPolicy returned requires_hitl=True and the call was parked
hitl_approvedOperator approved the queued call
hitl_deniedOperator denied the queued call
hitl_timeoutThe queued call expired without an operator decision

Every governed call resolves to exactly one of those — there are no holes in the log.

Tamper-resistance

The schema installs two triggers at table creation:

sql
CREATE TRIGGER prevent_audit_update
BEFORE UPDATE ON audit_records
BEGIN SELECT RAISE(ABORT, 'audit_records is append-only'); END;

CREATE TRIGGER prevent_audit_delete
BEFORE DELETE ON audit_records
BEGIN SELECT RAISE(ABORT, 'audit_records is append-only'); END;

Any UPDATE or DELETE against the table aborts with a clear error. This is enforcement at the engine layer, not application code — even direct sqlite3 shell access will raise. The store also runs in WAL journal mode for concurrent readers + a single writer, with indexes on session_id, outcome, and timestamp DESC.

The SQLite file itself isn't cryptographically sealed against an attacker with shell access — that's an operational concern (file permissions, write-once mounts, off-host shipping). Combined with the content-addressed policy_version, the trigger-protected schema gives you the audit guarantee application code cannot retroactively edit the log.

Setup

python
from kitelogik.audit.store import AuditStore

store = AuditStore(db_path="audit.db")   # ":memory:" is rejected
await store.setup()                       # one-time table + trigger creation

The store is async and safe to share across an entire process.

Query

query() is the read path:

python
# Recent activity for one session
records = await store.query(session_id="sess_001", limit=50)

# All denials in the last batch of records
denials = await store.query(outcome="blocked", limit=200)

# All calls to one tool
calls = await store.query(tool_name="approve_refund", limit=100)

Filters are AND-combined; limit defaults to 50 and orders results newest-first.

Export a session

export_session() returns a JSON-serialisable bundle suitable for shipping to a SOC team, a regulator, or a downstream forensics pipeline:

python
bundle = await store.export_session("sess_001")

# bundle = {
#   "session_id":     "sess_001",
#   "record_count":   17,
#   "policy_version": "a3f9e1c2b4d7e6f5",
#   "exported_at":    "2026-04-28T14:00:00Z",
#   "records":        [ ... 17 AuditRecord dicts in chronological order ... ],
#   "integrity_hash": "sha256:..."  # over the records JSON
# }

import json
with open("session_001_export.json", "w") as f:
    json.dump(bundle, f, indent=2, default=str)

The integrity_hash is a SHA-256 over the records' canonical JSON. A downstream auditor can recompute the hash from the exported records; any tampering between export and review changes the digest.

Replay against a new policy

PolicyReplayer re-evaluates historical records against the live gate — useful for "what would this policy change have done to last month's traffic?":

python
from kitelogik.audit.replay import PolicyReplayer

replayer = PolicyReplayer(gate)
results  = await replayer.replay_session(store, session_id="sess_001")

changed = [r for r in results if r.outcome_changed]
print(f"{len(changed)} of {len(results)} decisions would differ")

for r in changed:
    print(f"  {r.tool_name:24} {r.original_outcome:>12} -> {r.replayed_outcome}")

ReplayResult carries both the original and replayed outcome, the risk tiers from each, and the replayed reason — so a compliance team can preview the blast radius of a Rego change before it ships, and an incident response team can identify exactly which historical calls a new rule would have caught.

Vocabulary mismatch

AuditStore.outcome uses seven values (allowed, blocked, soft_denied, hitl_queued, …). ReplayResult.replayed_outcome uses three (allowed, denied, pending_review) — it's a synthesised view of the current policy's decision, not a record of execution. Compare on intent, not on the literal string.

Operational pattern: ship to long-term storage

SQLite is the right home for recent audit traffic. For multi-year retention you want a tiered approach: keep the SQLite file as the hot, queryable surface, then ship completed sessions off-host as exported bundles. A simple cron job is enough:

python
from datetime import datetime, timedelta, timezone
import boto3, json

s3 = boto3.client("s3")
cutoff = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()

# Find sessions whose most recent record is older than the cutoff
old_sessions = {
    r.session_id
    for r in await store.query(limit=10_000)
    if r.timestamp < cutoff
}

for sid in old_sessions:
    bundle = await store.export_session(sid)
    s3.put_object(
        Bucket="kitelogik-audit-archive",
        Key=f"{sid}/{bundle['exported_at']}.json",
        Body=json.dumps(bundle, default=str),
    )

The exported bundle includes the integrity_hash, so any later read from S3 can verify the JSON wasn't altered in transit or at rest.

What you'd build yourself

  • Cross-tenant indexing. The bundled store is single-tenant. A multi-tenant audit with per-tenant query isolation is a backend swap on top of the same row schema.
  • Postgres backend. The default is SQLite — fine to billions of rows with WAL + the bundled indexes. HA / connection pooling means bringing a Postgres-backed AuditStore implementation.
  • Built-in shipping. No bundled exporter for Splunk / Datadog / S3. Wire the loop yourself — the export bundle is plain JSON.
  • Risk tiers & HITL — the queue and the outcomes it produces (hitl_queued, hitl_approved, hitl_denied, hitl_timeout)
  • OpenTelemetry — for live request-path observability, complementary to the durable audit log

Released under the Apache 2.0 License.