# Audit trail retention policy

> Written policy, 2026-10-06. The archive retention period is to be agreed with the customer; until it is, every audit row stays online. Enforced truncation is not in this release.

_Reference · Updated 2026-10-19 · Describes release 0.1.164_

Written policy, 2026-10-06. The archive retention period is **to be agreed with the customer**; until it is, every audit row stays online. Enforced truncation is not in this release.

The record archive (DESIGN.md §13.8) does not change this policy: it archives operational records past their window and carries copies of the sealed audit rows that name them, but no audit row is ever removed or moved. Archiving the audit chain itself is phase 2.

## Scope {#scope}

Every row of the `auditevent` table, in its hash chain:

- **One chain per workspace.** The chain id is the workspace's tenant id. Its rows are everything that happened in that workspace, including platform-staff actions on it (support grants, workspace changes, staff exports of its trail).
- **The platform chain** (chain id `platform`). Rows with no workspace: the connector catalog, platform model defaults, platform settings.

What each action records is listed in the in-product chapter *People, access and audit* (`/portal/docs`) and, for staff, *Platform configuration* (`/ops/docs`).

## What the platform guarantees today {#what-the-platform-guarantees-today}

- **Append-only.** ORM guards reject any update or delete of an audit row, and on PostgreSQL a trigger rejects every `DELETE` and every `UPDATE` except the sealer's own — chain fields written once, on a row not yet sealed, with every content column unchanged (`app/aiops/audit_chain.py`, `install_guards` and `harden_postgres`). No owner, staff account, API or console screen can edit or delete a row.
- **Sealed within about a minute.** The scheduler's seal tick gives each new row its `seq`, the previous row's hash as `prev_hash`, and `row_hash = sha256(prev_hash + canonical JSON of the row)`. The canonical row is `{id, tenant_id, actor, action, target, payload, at, seq}`, keys sorted, separators `,` and `:`.
- **Verifiable on demand.** A windowed replay (the newest 500 rows by default, at most 10,000) or a full replay from the first row: per workspace with **Verify chain** on the audit page or `GET /api/aiops/{tenant_id}/audit/verify`, across all chains from the platform console or `GET /api/aiops/admin/audit/verify`. One full replay per chain runs at a time.
- **Watched continuously.** The compliance control LOG-01 replays the newest 500 rows of the workspace chain on every compliance scan and fails on a broken link, or on more than 5,000 rows awaiting their seal (a stopped sealer).
- **Exportable with the evidence.** `GET /api/aiops/{tenant_id}/audit/export` (and `GET /api/aiops/admin/audit/export` for any chain, staff only) streams rows with `seq`, `prev_hash` and `row_hash`, by period or by `seq` cursor; `scripts/verify-audit-export.py` re-verifies an NDJSON export with Python's standard library and no access to the platform. Every export is itself an audit row (`audit.exported`) in the exported chain.

**What it does not guarantee.** The chain head is stored in the same database as the rows. Someone with direct write access to the database who rewrites a row *and* recomputes every later hash *and* the head produces a chain that verifies. The defence is an anchor kept outside the platform: the archive manifest below. The database is also not write-once storage — the write-once copy is the customer's archive.

## Retention {#retention}

| Copy | Period | Status |
|---|---|---|
| Online, in the platform database | Indefinite: the product never deletes an audit row | In force |
| Archive, in customer storage | **To be agreed with the customer** — period: ____, agreed on ____, by ____ | Pending |

Until an archive period is agreed, nothing is removed and the monthly archive below is the recommended practice. Agreeing a period changes only this document.

Shortening the online trail — archive, then truncate behind a sealed checkpoint — is planned, not built. It needs a checkpoint row that seals the head before truncation, a database role allowed to delete plus a trigger change, and a verify that starts from the checkpoint. Until it ships, online storage grows with the trail; the sizing below quantifies it.

## Archive procedure {#archive-procedure}

Monthly, per chain, run by the customer. A workspace chain needs a read-scope token of an account with `report.view` in that workspace. The platform chain needs an admin-scope token of a platform superadmin.

1. Take the last archived `seq` and hash from the previous manifest entry (`0` and no hash for the first archive).
2. Export from there with the cursor, 50,000 rows per file, until `X-Truncated` is `false`:

   ```bash
   after=$LAST_ARCHIVED_SEQ
   while :; do
     curl -s -D headers.txt -H "Authorization: Bearer $AIOPS_TOKEN" \
       "https://aiops.example.com/api/aiops/$TENANT_ID/audit/export?fmt=json&after_seq=$after&limit=50000" \
       -o "audit-$TENANT_ID-after-$after.ndjson"
     after=$(grep -i '^x-next-after-seq:' headers.txt | tr -d '\r' | cut -d' ' -f2)
     grep -qi '^x-truncated: true' headers.txt || break
   done
   ```

   For the platform chain, call `https://aiops.example.com/api/aiops/admin/audit/export?fmt=json&after_seq=$after&limit=50000` (no tenant id). Rows written in the last minute are not sealed yet and are left for next month's export.
3. Verify every file offline:

   ```bash
   python3 scripts/verify-audit-export.py audit-$TENANT_ID-after-$LAST_ARCHIVED_SEQ.ndjson
   ```

   Each file must print `OK` and no `gap:` line. A cursor export with no filter is the chain itself, so the script fails it on any `seq` gap, and any file whose row count differs from its trailer's; a `gap:` line means the file was exported with a filter or a period and is not an archive file. The first file's `links back to:` line must show the previous manifest's last `seq` and hash; each later file must link back to the `anchor:` of the file before it.
4. Store the files in write-once storage for the agreed period — for example an S3 bucket with Object Lock in compliance mode, or an immutable index in the customer's SIEM.
5. Append a manifest entry and store the manifest the same way: chain id; file names with the sha256 of each file; first and last `seq`; the last `row_hash`; the `X-Chain-Last-Seq` and `X-Chain-Head-Hash` returned with the last file; export time; who ran it. **The manifest is the external anchor.**

**Boundary guarantee.** The first row after an archive (`seq` = last archived + 1) carries `prev_hash` equal to the last archived `row_hash`. That holds between consecutive archive files, and between the archive and the online trail — today because nothing is deleted, later because the truncation checkpoint will seal exactly that boundary.

**Checking the anchor.** At any time, export the archived boundary row again (`after_seq=<manifest last seq − 1>&limit=1`) and compare its `row_hash` with the manifest. A difference means the online trail was rewritten after it was archived; the archive is the record.

## Sizing {#sizing}

Measured on the demo deployment on 2026-10-06 with read-only queries (53 days of history). The demo runs automated fault-injection labs alongside its workspaces, so these rates are an upper-end example, not a forecast for a production workspace.

| Chain | Rows | Rate, last 30 days | NDJSON bytes per row (newest 10,000) | NDJSON per year at that rate |
|---|---|---|---|---|
| Busiest workspace | 223,532 | 470 a day (bursts averaged 4,239 a day over the 53 days) | 470 | ≈ 81 MB |
| Second workspace | 16,739 (20 days) | 558 a day (899 a day over the last 7) | 527 | ≈ 107 MB |
| Third workspace | 12,023 | 301 a day | 565 | ≈ 62 MB |
| Platform chain | 9,339 | 170 a day | 360 | ≈ 22 MB |
| Whole deployment | 292,700 | ≈ 2,400 a day | ≈ 470 | ≈ 0.4 GB |

- **Database.** 292,700 rows took 200 MB in PostgreSQL, about 0.7 KB per row with its indexes, measured before this release added two composite indexes (roughly 0.1 KB per row more). At about 2,400 rows a day the online trail grows by about 0.6–0.7 GB a year.
- **Largest single row**: 15 KB of NDJSON. Payloads are clipped at write, so rows stay small.
- **Export cost.** A 50,000-row page is about 30 MB of NDJSON and streamed in 2–3 seconds against a local PostgreSQL 16, with about 20 MB held in the control plane while streaming. A full replay of 60,000 rows took about 2.5 seconds.

**Dry run, 2026-10-06.** The newest 10,000 rows of the three busiest demo workspaces and the whole platform chain (9,339 rows), in the export's row format, verified with `scripts/verify-audit-export.py`: all `OK`, each ending on its chain head. It ran inside the demo cluster against the database, in memory, because the cursor export was not yet deployed there; repeat it through the export API on the first monthly archive.

## After a database restore {#after-a-database-restore}

A restore (point-in-time or snapshot) rolls the trail back to the restore point: rows written after it are gone, and what remains is an earlier, consistent chain that still verifies. After any restore:

1. Run a full replay of every chain: the platform console's **Verify chains** with **Full replay**, or `GET /api/aiops/admin/audit/verify?full=true`.
2. Compare each chain's head with the latest manifest. A head below the manifest's last `seq` means archived rows are no longer online — expected after restoring to an earlier point; the archive is the record for that span, and the next export continues from the restored head.

Automated database backups are kept for 7 days; see `availability.md` in this folder.

## Not in this release {#not-in-this-release}

- Enforced retention: archive, then truncate behind a sealed checkpoint.
- An external anchor maintained by the platform itself, such as publishing chain heads to customer write-once storage. Today the customer's archive manifest is the anchor.
- A built-in SIEM forwarder. Exports are pulled, not pushed.

## References {#references}

- In product: **Settings · Account → Audit log** (Verify chain, Export) and the chapter *People, access and audit*; the platform console's **Audit log** and chapter *Platform configuration*.
- Code: `app/aiops/audit_chain.py` (sealing, verify, guards, trigger), `app/api/aiops/audit.py` (`export_chain`), `scripts/verify-audit-export.py`.
- Tests: `tests/unit/test_audit_chain.py`, `tests/unit/test_verify_audit_export.py`, `tests/integration/test_audit_export_postgres.py`.


## Next steps

- Next: [Support and escalation](/help/ops-support-and-escalation.md)
- [All Install and operate the platform pages](/help/install-and-operate-the-platform/llms.txt)
- [Documentation home](/help/)
