AUTH4D-172 Audit-log retention by plan and the R2 archive
How long a project keeps its audit log now depends on its plan, through the existing
auditLogRetentionDays entitlement (packages/billing/src/catalog.ts). The provisional periods
are Free 30 days, Indie 90 days and Studio 365 days; the owner decides the final ones. Until
billing is live in production (AUTH4D-135) every production project is on Free.
The last 30 days stay in D1 and are listed by GET /management/tenants/{tenantId}/audit-events
(AUTH4D-19) as before. The scheduled lifecycle job (packages/lifecycle/src/audit-archive.ts)
moves older events that the plan still keeps into the AUDIT_ARCHIVE R2 bucket, and deletes events
and archive objects once they are past the plan’s retention. The routes below search and export
that archive. They are in apps/api/src/modules/audit-archive; schemas are
AuditArchiveSummaryResponseSchema, AuditArchiveEventsResponseSchema and
AuditArchiveEventSchema in packages/contracts/src/audit-archive.ts. Storage is migration
0031_audit_archive.sql plus the bucket.
Management routes
All routes need a management token with audit:read and a live membership of the project (every
project role can read). Unknown projects and other developers’ projects answer 403 forbidden.
The console reaches them through its BFF (/api/management/…). JSON responses are no-store.
| Method | Path | Scope | Result |
|---|---|---|---|
| GET | /management/tenants/{tenantId}/audit-archive |
audit:read |
{ retention, archive } |
| GET | /management/tenants/{tenantId}/audit-archive/events |
audit:read |
{ events, next_cursor }, newest first |
| GET | /management/tenants/{tenantId}/audit-archive/export |
audit:read |
NDJSON download, oldest first, and audited |
Summary
{
"retention": {
"plan_id": "studio",
"retention_days": 365,
"recent_days": 30,
"archive_available": true
},
"archive": {
"events": 1824,
"oldest_event_at": "2026-01-09T08:12:44.120Z",
"newest_event_at": "2026-09-06T23:58:01.003Z"
}
}
recent_days is the D1 window (LIFECYCLE_AUDIT_RETENTION_DAYS, 30 by default). A plan never
shortens it, so retention_days is at least recent_days. archive_available is false when the
deployment has no bucket bound; events then stay in D1 until the plan’s retention ends.
Search
GET /audit-archive/events takes the same filters as the AUTH4D-19 list: event, outcome,
actor_id, target_type, target_id, since, until (ISO 8601), plus limit (1–100, default 25) and cursor. Unknown or repeated parameters, malformed values and since after until return
400 invalid_request. Events have the AUTH4D-19 shape (id, request_id, event, outcome,
actor_id, target_type, target_id, occurred_at, metadata).
One request reads at most 8 archive objects, so a page can hold fewer than limit events (or none)
while next_cursor is still set; keep following the cursor until it is null. The cursor is
opaque and carries a position only. Every read is still bound to the path’s project.
Export
GET /audit-archive/export takes the same filters (no limit or cursor) and streams one JSON
object per line, oldest first, with the AUTH4D-157 export fields (tenant_id first). It stops
after 100,000 events. Each export is recorded before streaming as
management.audit.archive_exported (target the project, metadata the filter names and any
since/until, never filter values such as actor ids), and is limited to 10 per developer per
hour (429 rate_limited, Retry-After: 300).
Content-Type: application/x-ndjson; charset=utf-8
Content-Disposition: attachment; filename="auth4-audit-archive-{tenantId}-{yyyymmddThhmmssZ}.ndjson"
Cache-Control: no-store, max-age=0
Events past the plan’s retention are never returned by search or export, even before the lifecycle job has deleted their object.
Archive layout
Each lifecycle run moves at most one batch per project (up to 200 events, oldest first) into one object:
tenants/{tenantId}/audit/v1/{YYYY-MM-DD of the first event}/{first event id}.ndjson.gz
The object is gzip-compressed NDJSON in the export line format. The key is deterministic, so a run
interrupted after storing the object repeats the same batch under the same key. The
audit_archive_objects table indexes every object by (tenant_id, object_key). Its CHECK ties
each key to its own tenant prefix, and the index row is written before the object, so every object
can be found from D1. Reads also check the prefix and drop any line whose tenant_id is not the
path’s project.
Expiry and deletion
- An object is deleted, from R2 and then from the index, once its newest event is past the plan’s retention. After a downgrade, older objects go on the next lifecycle run.
audit_archive_objectshas no foreign key totenants, so its rows outlive a tenant deletion. The lifecycle stepaudit_archive_deleted_tenantsthen deletes every object under the deleted tenant’s prefix (including any without an index row) and the index rows.- These deletions are retention housekeeping and, like the other lifecycle purges, emit no audit event.
Infrastructure
infra/cloudflare/environments.mjs names one bucket per environment (auth4-audit-archive-staging,
auth4-audit-archive-production); check-config.mjs requires the AUDIT_ARCHIVE binding to match
and refuses a bucket shared between environments. provision.mjs --apply creates the bucket. If
the operator’s login gets 403 on R2, the owner creates it in the dashboard (R2 → Create bucket,
same name, default location) before the next deploy.
Source: docs/api-spec/auth4d-172.md