> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Audit Log

> Append-only audit event system with PG trigger and query API

# Audit Log (SD-2a)

The audit subsystem records who did what to which entity, plus the request
context the action was performed under. Rows live in `audit_events` and are
written by two paths:

1. **Successful mutations** — handlers call `audit_log(session, descriptor,
   entity_key, old, new, request, …)` *before* committing the domain change.
   The helper stages a row in `audit_outbox`; the `AuditOutboxPromoter` worker
   moves it into `audit_events` on a 1-second tick. Crash recovery is safe
   because the outbox row commits atomically with the domain mutation.

2. **Denied authorization (401/403)** — the `AuditMiddleware` writes a synthetic
   `entity_type='access_denied'` row directly via a short-lived session. The
   middleware is registered as the OUTERMOST wrap of `HMACAuthMiddleware`, so
   short-circuit responses still flow back through it and `request.state.auth`
   (populated by HMAC pre-raise) provides the actor.

## Schema

`audit_events`:

| Column                                           | Purpose                                    |
| ------------------------------------------------ | ------------------------------------------ |
| `entity_type`, `entity_key`, `action`            | what was changed                           |
| `outcome`                                        | `success` / `denied` / `error` / `changed` |
| `actor_client_id`, `actor_key_id`, `actor_label` | who did it                                 |
| `source_ip`, `user_agent`, `request_id`          | request envelope                           |
| `method`, `path`, `status_code`                  | route call (SD-2a forensic columns)        |
| `strategy_id`, `namespace`, `client_id`          | partitioning fields                        |
| `idempotency_key` (UNIQUE)                       | promoter dedup                             |

## Append-only guarantee

Postgres enforces append-only via the `audit_events_append_only` trigger. `UPDATE`/`DELETE` on `audit_events` raise an
exception; `DELETE` on `audit_outbox` is also blocked.

## Redaction tiers

Defined in `src/qtg/infrastructure/audit/redaction.py`:

* **Full** (`***redacted***`): `hmac_secret`, `api_secret`, `private_key`,
  `seed`, `mnemonic`, `kms_alias`, `access_token`, … (substring match).
* **Partial** (`hong***dong`): names, emails, phones — adaptive slice so a
  3-character Korean name reveals 2 + … + 1 and a long email reveals 4 + … + 3.
* **No redact**: wallet addresses, amounts, asset codes, status strings —
  forensic value outweighs privacy cost.

## Retention (manual procedure)

The system never deletes audit rows automatically. Operators run the manual
procedure when storage requires reclamation:

1. Choose a cutoff timestamp `C` (e.g. 6 months prior).
2. **Postgres**: `audit_events` lives in the network-scoped schema (`mainnet` or
   `testnet`), not `public`, so qualify every statement — a plain `psql` session has
   no `search_path` and will report the relation as missing. Run
   `SET search_path TO mainnet, public;` first, or qualify inline. Drop the trigger
   temporarily — `DROP TRIGGER audit_events_prevent_delete ON <network>.audit_events;`
   — then `DELETE FROM <network>.audit_events WHERE created_at < :C;` Restore it
   immediately after: `CREATE TRIGGER audit_events_prevent_delete BEFORE DELETE ON <network>.audit_events FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();`.
   Run the same pair on `<network>.audit_outbox` for `promoted_at < :C` rows if needed.
3. Capture the row count and operator who performed the cleanup in the
   incident log. Future automation should call a dedicated CLI; until then the
   manual procedure is intentional so no scheduled job can quietly tamper with
   the audit surface.

## Query API

`GET /v3/audit/events` is available to `admin` and `operator` roles. The query
itself emits a meta-audit row (`entity_type='audit_log'`, `action='query'`).

## Pending work

* **Handler-side `audit_log()` calls**: descriptor registration is centralized
  in `src/qtg/infrastructure/audit/bootstrap_descriptors.py`; per-handler
  body invocations land incrementally. The AST lint test
  `tests/qtg/test_audit_log_call_coverage.py` fails when a registered descriptor
  has no matching handler call.
* **Separate DB role** (`qtg_audit_reader`) — deferred to v0.2.0.
