What the audit log records
Scope: what LQ.AI records to the audit log, retention, and integrity protection. Operators evaluating procurement responses often need this in writing; this is the operational reference for the
audit_logtable.
In short
Section titled “In short”Every action that changes state should write one row to audit_log, in the same transaction as the change it describes — but that only holds for handlers that call the audit helper; the admin provider-key endpoints are a known exception that is not audited. The log never records message content, provider responses, or secrets, and ordinary reads are not logged either, except privileged cross-user reads (for example, an admin viewing another user’s data).
Rows are kept by default rather than auto-expiring. Deleting a user anonymises its audit rows rather than removing them. Append-only behaviour is enforced by the application, not the database, so a row changed directly in the database, outside the app, leaves no signal — and there is no tamper-detection mechanism (such as chained hashes) yet to catch it after the fact.
For a specific matter, two further tables — citation_ledger_entry and inference_routing_log — add a finer-grained, non-audit-log record of what the assistant read and which model calls it made.
What is logged
Section titled “What is logged”Each audit event is a row in the audit_log table (see docs/db-schema.md §audit_log for the schema). Columns:
id— UUID primary key, defaulted bygen_random_uuid()(random UUIDv4; not time-ordered). Do not infer chronology from the primary key: order bytimestamp, addingidas a stable tie-breaker for repeatable display — a tie-breaker gives a deterministic order but does not reconstruct true event order when timestamps tie.timestamp—TIMESTAMPTZ, server-clock; defaultclock_timestamp()records wall-clock insert time. Values may tie or move backwards if the clock is adjusted; they do not guarantee write order.user_id— actor; FK tousers.idwithON DELETE SET NULLso user deletion preserves the row but anonymises the actor.action— verb-form event string (e.g.chat.message_sent,kb.created); the canonical event-type field.resource_type— noun the action was performed on (e.g.chat,project,skill).resource_id— stringified identifier of the affected resource, typically a UUID; nullable for actions with no concrete subject.privilege_marked—BOOLEAN, true when the action affects a project flagged privileged; first-class column (not buried indetails) so operator queries do not require JSONB scans.privilege_basis— short human-readable handle for why the row is privileged (e.g.project:<name>); the DB enforcesprivilege_marked → privilege_basis IS NOT NULLviachk_audit_log_privileged_with_basis.routed_inference_tier—SMALLINT 1..5when the action touched inference routing; null otherwise.routed_provider— the provider the gateway selected for the routed call (e.g.anthropic,openai); null when the action did not route inference.ip_address—INET; the client address attached to the originating request.user_agent— requestUser-Agentheader.request_id— correlation id fromX-Request-ID; cross-references gateway logs and structured app logs.details— JSONB payload for action-specific fields (e.g.{"name": "...", "privileged": true}); queryable but not indexed by default.
Logged events (verified against action= literals emitted by api/app/; the list below is the M1 baseline plus the later additions called out inline and is not exhaustive — later milestones add further families such as the M4 autonomous_session.* events, so run grep -rn 'action="' api/app for the current full set):
- Authentication & session:
user.login,user.login_failed,user.login_mfa_challenged,user.logout,user.session_refreshed,user.session_refresh_failed. - MFA lifecycle:
user.mfa_setup_initiated,user.mfa_enabled,user.mfa_enable_failed,user.mfa_disabled,user.mfa_disable_failed,user.mfa_verify_failed. - Password & credentials:
user.password_changed,user.password_change_failed. - Account lifecycle:
user.role_updated,user.preferences_updated,user.deletion_scheduled,user.deletion_cancelled,user.export_requested. - Project:
project.knowledge_base_attached,project.knowledge_base_detached. Project creation itself is not audited: the create handler commits the row and writes a structured service log line (event=project_created) but does not callaudit_action();project.createappears only as the docstring example inapi/app/audit.py. - Chat:
chat.message_sent. - Skills (user-scoped):
user_skill.created,user_skill.updated,user_skill.deleted. - Files:
file.uploaded,file.deleted. - Knowledge base:
kb.created,kb.updated,kb.deleted,kb.file_attached,kb.file_detached. - Saved prompts:
saved_prompt.create,saved_prompt.update,saved_prompt.delete. - Teams:
team.created,team.updated,team.deleted,team.member_added,team.member_removed,team.member_role_changed. - Admin / organization:
organization_profile.updated,tier_policy.updated. - Autonomous sessions and governed runs:
autonomous_session.<event>rows written by the tool chokepoint, and, for a governed run (ADR 0038, proposed),orchestration.<event>rows written by the run engine (planning, plan saved, approved, rejected, children admitted, effects, tasks and the answer delivered, halted, closed). A row is marked from the matter its session or run is in, including the two rows a halt request writes and therun-nowrow, which the API routes write themselves. A run in no matter is marked from the terms it took on when it was accepted: it is privileged exactly when a matter that holds one of its selected files (by upload, attachment or an attached knowledge base) is, and the row’sprivilege_basisisrun terms: a selected file is held by a privileged matter. Rows written before this marking existed carry none, for a session in a privileged matter as well, and are not backfilled. - Cross-owner reads:
auditor.<event>, written when an admin or theauditorrole reads another owner’s resource, before anything is returned:ledger_viewed,sources_viewed,citations_viewed,session_ledger_viewed,receipts_viewed,receipts_exported, and, for a governed run,run_viewedandrun_trace_viewed.detailsholdsviewed_user_idonly. These rows are not privilege-marked, for any resource.
All writes go through one helper — app.audit.audit_action() in api/app/audit.py — so every row populates privilege_marked / privilege_basis consistently and captures ip_address / user_agent / request_id uniformly when a Request is available. The auditor.* rows go through the closed-enum wrapper app.auditor_audit.auditor_audit(), which calls the same helper.
Autonomous retrieve_chunks ownership refusals write
action='autonomous_session.tool_call' with
details={"tool": "retrieve_chunks", "outcome": "ownership_denied"} (#494).
The existing predicates reject foreign, unknown, archived KBs and deleted files
without distinguishing their existence to the agent. Invalid arguments and
other tool failures do not receive this outcome. It is a refusal signal for
investigation, not proof of prompt injection. No query, document text, raw
arguments, results, or exception message is added to the row.
Each ownership refusal has one closing ownership_denied row after the initial
started row. The analysis loop recognizes the already-audited refusal and
continues with its existing ValueError observation without adding a generic
error row (#656). Ordinary validation and dispatch errors retain their generic
failure audit. Rows use the caller’s transaction: they persist on its commit
and disappear on rollback. An audit-write failure remains an
error and cannot grant access. These rows are not an independent durable
incident log, and #494 does not add alerts or alter orchestration-scope refusals.
ADR 0038 adds two ways to call retrieve_chunks (D4 and D5): a bounded read
of one file’s text, and a keyword search of the knowledge bases an approved
plan lists. Both are refused outside a governed run, before any ownership
predicate is reached, and the chokepoint writes no audit row of its own for
that refusal. The whole-file read is unchanged: a session that is not a
governed run still reaches it on the file predicate.
In a governed run, the orchestration boundary checks the file of a bounded
read against the run’s selection first, and its refusal is a scope refusal.
The read then sits behind the same file predicate as the whole-file read,
and a refusal there writes the same ownership_denied row.
The keyword search does not refuse a knowledge base that has lapsed. One
that the owner archived, or that is no longer attached to the run’s matter, is
left out of the search by the owner’s own act; no row is written for it. One
that has become another owner’s, or no longer exists, is also left out, and the
call writes one ownership_denied row (the same row the other retrieval modes
write: no identifier, no query) and goes on with the rest. The step’s result
gives how many were listed and how many were searched. The model does not name
the knowledge bases in that call: they come from the approved plan.
What is NOT logged
Section titled “What is NOT logged”- Plaintext message content.
chat.message_sentrecords the chat and message ids indetails, not the message body. Inference-routing has its own table (inference_routing_log) with provider, model, token counts and latency — also without message content, per PRD §4. - Provider API responses. Same reasoning; the gateway records routing metadata only.
- Cryptographic material.
JWT_SECRET, master keys, the field-level encryption keys, and provider API keys are never logged — see encrypted-keys.md for the key-handling contract. - Read traffic. M1 audits state-changing actions (PRD §5.3). Read endpoints are not audited unless they touch privileged data via the inference path, in which case the routing decision lands in
inference_routing_log. The exception is a cross-owner read by an admin or theauditorrole, which writes anauditor.<event>row before anything is returned (for governed runs,auditor.run_viewedandauditor.run_trace_viewed). - What a cross-owner read returns differs by resource. The chat ledger, message sources, citations, the autonomous-session ledger and the chat receipts give a privileged reader the evidence layer: cited passages and their checks, sources, routing and audit events, and message ids, roles and token counts, never a message’s text (the auditor-role design, 2026-07-02). A governed run’s read (ADR 0038 ruling 10) returns the run as its owner sees it: the request and the thread excerpt the plan was made from, the plan, each task’s findings and notes, the answer and, on the trace, each call’s prompt and arguments and what each model wrote (not the document text a read returned). That is wider than the other reads. It is built as ruled, it is not yet in the auditor-role definition, and ADR 0038 is not ratified.
Retention
Section titled “Retention”- Default retention: audit rows are never automatically deleted at M1. Operators with regulatory retention requirements (e.g. SOC 2 expects ≥1 year; some jurisdictions require longer) can rely on the default-retain posture.
- User deletion behaviour: when a user is deleted, the FK
audit_log.user_idisON DELETE SET NULL, so the audit row persists but the actor reference is anonymised. The state-change history remains queryable byresource_type/resource_id/details. The user-data export worker (api/app/workers/user_export.py) includes the rows where the user is the actor in their export underaudit_log.json, but export and deletion are separate jobs:POST /users/me/exportqueues an export,POST /users/me/deleteschedules deletion after the grace period, and the deletion worker (api/app/workers/user_deletion.py) neither triggers nor waits for an export — it also deletes any stored export bundles for that user. There is no guarantee that a deletion is preceded by a completed export; a user who wants their audit rows must request and download the export before the scheduled deletion runs. - Operator-controlled archival: operators can
pg_dump --table=audit_logto long-term storage on a schedule of their choosing. No first-class export workflow in M1; we may add one if operator demand surfaces. - Manual purge: operators with privacy-driven purge requirements (e.g. GDPR right-to-erasure) can DELETE specific rows by
user_iddirectly. A future enhancement may add aredact_user(user_id)CLI command that NULLs the actor and PII-bearingdetailsfields per user (tracked as a deferred enhancement; file via operator request — see PRD §9).
Integrity protection
Section titled “Integrity protection”- Application-layer: the api process is the sole writer.
audit_action()flushes the audit row inside the caller’s transaction but does not commit; the handler commits both the state change and the audit row in one boundary. An audited event is therefore either both present in the audit log and reflected in the underlying tables, or neither — there is no audit-row-without-state-change failure mode. The converse holds only for handlers that callaudit_action(): a state change made by a handler that does not call it leaves no audit row at all, so coverage is per handler, not a property of the helper. The known case is the provider-key proxy —POST/PATCH/DELETE /api/v1/admin/provider-keysinapi/app/api/admin.py— which forwards the change to the gateway; the gateway persists it togateway.yamloutside any api database transaction, and the handler never callsaudit_action(), unlike the tool-provider proxy beside it (/api/v1/admin/tool-providers), which does. Before relying on the log for complete coverage of a given kind of change, check that its handler callsaudit_action(). - Append-only at the application layer: no application code path issues
UPDATEorDELETEagainstaudit_log. The database does not enforce append-only directly; operators with stricter requirements can add a trigger that rejects updates and deletes (the schema comment indocs/db-schema.mdflags this). - Database-layer: Postgres WAL provides crash-consistency. Operators with stricter durability requirements run Postgres with
synchronous_commit=on(the default in our chart). - Tamper detection (not in M1): chained hashes (each row commits a hash over
(prev_hash, current_row)) would let operators detect after-the-fact tampering. Not in M1; tracked as a deferred enhancement (file via operator request — see PRD §9). Operators needing this today can use Postgres logical replication to a write-once destination.
This is the structural answer to a question a closed-source SaaS product cannot give you: you can evidence, to your client or your regulator, every occasion on which their material was sent to a model provider — not as an assertion, but as a query you run yourself against your own database.
Matter-scoped evidence for a specific model call or citation
Section titled “Matter-scoped evidence for a specific model call or citation”The workflows above answer “what happened to a given user or privileged resource.” A narrower
question — “what did the assistant read and verify for this specific matter” — is answered by two
tables that are not part of audit_log itself: citation_ledger_entry, indexed on project_id
(one row per turn per source the assistant actually read, referencing exactly one of a KB-document
citation, a case-law citation, or a tool-retrieved source, and holding no content of its own — a
metadata index only, see docs/db-schema.md); and inference_routing_log, which
records the provider, model, and routed tier for every inference call and carries a chat_id.
-- Every source the assistant read and verified for one matterSELECT chat_id, message_id, source_kind, verification_status, confidence, provider, retrieved_atFROM citation_ledger_entryWHERE project_id = '<matter-uuid>'ORDER BY created_at;
-- Every model call made inside that matter's chatsSELECT r.timestamp, r.routed_provider, r.routed_model, r.routed_inference_tier, r.anonymization_appliedFROM inference_routing_log rJOIN chats c ON c.id = r.chat_idWHERE c.project_id = '<matter-uuid>'ORDER BY r.timestamp;Neither query returns the content of what was read or said — citation_ledger_entry is metadata
only, and inference_routing_log never carries message bodies — but together they produce a
matter-scoped record of every model call and every citation verification an insurer’s inquiry, a
malpractice review, or an internal audit would ask for, without reconstructing it by hand.
Operator workflows
Section titled “Operator workflows”Investigating an incident
Section titled “Investigating an incident”Pattern: pull all events for a given actor in a time window.
SELECT timestamp, action, resource_type, resource_id, privilege_marked, detailsFROM audit_logWHERE user_id = '<uuid>' AND timestamp BETWEEN '<from>' AND '<to>'ORDER BY timestamp DESC;To narrow to privileged-resource activity:
SELECT timestamp, user_id, action, resource_type, resource_id, privilege_basisFROM audit_logWHERE privilege_marked = TRUE AND timestamp BETWEEN '<from>' AND '<to>'ORDER BY timestamp DESC;Both queries are supported by indexes (idx_audit_log_user_timestamp, idx_audit_log_privileged).
Long-term archival
Section titled “Long-term archival”# Weekly archivepg_dump -d lq_ai --table=audit_log --data-only --column-inserts \ > audit-log-$(date +%Y-%m-%d).sqlCompliance attestation
Section titled “Compliance attestation”Operators answering “do you maintain an audit log of administrative actions” can reference this doc plus the audit_log table schema in docs/db-schema.md. The Compliance Alignment Pack at docs/compliance/ (separate cycle) maps specific audit events to specific SOC 2 / ISO 27001 controls.
Cross-references
Section titled “Cross-references”- docs/db-schema.md §audit_log — schema definition.
- docs/PRD.md §5.3 — design intent (cross-cutting audit requirement).
- docs/security/threat-model.md — Repudiation coverage.
- api/app/audit.py — the single audit-write helper.