Skip to content

B-15 · Audit export (immutable trail)

SOP: SOP_AI_Bookkeeping_Automation.md §8 (Data Retention and Audit Trail)Actors: Senior Accountant or Platform Admin (Action.MANAGE_CLIENTS). Pre-state: Any client with bookkeeping activity. Post-state: Downloadable CSV with every audit event for the client, scoped to the requested period.

Per SOP §8, audit logs must be immutable and exportable in CSV format on demand. The retention default is 7 years (configurable per client jurisdiction).

0. Prerequisites

  • A client with at least one ingested document, batch, or reconciliation run.

1. Steps

1.1 Open the audit page

Web: /dashboard/audit?clientId=<id> (or /dashboard/clients/<id>/audit).

Filters:

  • Period (start / end date — defaults to the engagement window for annual clients, last 30 days otherwise).
  • Event type (e.g. bookkeeping.journal_entry.*, bookkeeping.reconciliation.*, client.*).
  • Actor (system / staff email / contact email).

1.2 Browse

The page paginates through audit_events rows ordered by created_at desc. Per row:

  • Timestamp (ISO + relative).
  • Actor (SYSTEM for worker handlers, otherwise the staff or contact email).
  • Event type (e.g. bookkeeping.unreconciled.responded).
  • Subject (the aggregate id — batch / entry / run / item).
  • Payload (JSON — opens in a side drawer).

1.3 Export to CSV

Click Export CSV.

http
POST /admin/audit/export

{
  "clientId": "<id>",
  "from": "2025-04-01",
  "to": "2026-03-31",
  "eventTypePrefixes": ["bookkeeping.", "client."],
  "format": "CSV"
}

Server:

  1. Validates the requester has Action.MANAGE_CLIENTS.
  2. Bounds the request: window ≤ 5 years, row cap 100,000 (anything larger comes back as truncated: true and the caller is expected to narrow the window or filter).
  3. Queries audit_events for the client + window with optional eventType prefix filters (Prisma startsWith).
  4. Buffers the result as CSV and PutObjects it to S3 at <clientCode>/audit-exports/audit-<from>-<to>-<ts>.csv.
  5. Returns a presigned GetObject URL with a 1-hour TTL.
  6. Writes a meta audit.exported event into the same audit log (yes — exporting itself is audited).

CSV columns (header row included):

timestamp,actor_type,actor_id,event_type,subject_kind,subject_id,payload_json,client_id,cycle_id

Response body shape:

json
{
  "storageKey": "ZENITH/audit-exports/audit-2025-04-01-2026-03-31-1714050000000.csv",
  "downloadUrl": "https://...",
  "expiresAt": "2026-04-25T11:00:00.000Z",
  "rowCount": 12345,
  "truncated": false,
  "windowDays": 365
}

1.4 Verify retention

http
GET /admin/clients/<clientId>/retention

Returns:

json
{
  "auditEventsRetentionYears": 7,
  "documentsRetentionYears": 7,
  "oldestEventAt": "2025-04-04T09:01:13Z",
  "earliestPurgeEligibleAt": "2032-04-04T09:01:13Z"
}

The retention-purge cron (03:00 UTC on the 1st of each month) deletes neither the audit events nor the source documents until the retention horizon — both stay immutable until then. After the horizon, the cron archives them to cold storage and removes them from the live tables (still recoverable from S3 cold storage for legal hold).

2. Verification

Database

sql
SELECT event_type, COUNT(*) FROM audit_events
  WHERE client_id = '<id>' AND created_at >= '2025-04-01' AND created_at < '2026-04-01'
  GROUP BY 1 ORDER BY 2 DESC LIMIT 20;

S3 / MinIO

<clientCode>/audit-exports/audit-<from>-<to>.csv exists with the expected row count.

Audit log

audit.exported   actor=<staff> from=2025-04-01 to=2026-03-31 rowCount=12345

3. Negative & edge cases

  • Export window too large (default cap: 5 years per export) → 400 "Audit export window exceeds 5-year cap. Split into smaller exports."
  • Invalid event type prefix — silently dropped from the filter; the export still produces a row count.
  • No Action.MANAGE_CLIENTS permission → 403.
  • Concurrent export of the same window → the second request wins; both produce identical CSVs but only the latest stays under the canonical filename. Each export's audit event lets you reconstruct who exported when.
  • Audit event row tampering — the audit table has no UPDATE / DELETE grants in production; even superuser writes are blocked at the RLS layer. Run EXPLAIN UPDATE on it to verify locally.

Done

End of the bookkeeping playbook set. Loop back to README.md for the index, or compare this flow's coverage against the SOP's Process Flow Summary (§10) to ensure no step is missing.

Internal use only — BreezyCorp