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
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
- Append-only. ORM guards reject any update or delete of an audit row, and on PostgreSQL a trigger rejects every
DELETEand everyUPDATEexcept 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_guardsandharden_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 asprev_hash, androw_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 orGET /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(andGET /api/aiops/admin/audit/exportfor any chain, staff only) streams rows withseq,prev_hashandrow_hash, by period or byseqcursor;scripts/verify-audit-export.pyre-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
| 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
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.
-
Take the last archived
seqand hash from the previous manifest entry (0and no hash for the first archive). -
Export from there with the cursor, 50,000 rows per file, until
X-Truncatedisfalse: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 doneFor 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. -
Verify every file offline:
python3 scripts/verify-audit-export.py audit-$TENANT_ID-after-$LAST_ARCHIVED_SEQ.ndjsonEach file must print
OKand nogap:line. A cursor export with no filter is the chain itself, so the script fails it on anyseqgap, and any file whose row count differs from its trailer's; agap:line means the file was exported with a filter or a period and is not an archive file. The first file'slinks back to:line must show the previous manifest's lastseqand hash; each later file must link back to theanchor:of the file before it. -
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.
-
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 lastrow_hash; theX-Chain-Last-SeqandX-Chain-Head-Hashreturned 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
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
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:
- 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. - Compare each chain's head with the latest manifest. A head below the manifest's last
seqmeans 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
- 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
- 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.