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.

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_objects has no foreign key to tenants, so its rows outlive a tenant deletion. The lifecycle step audit_archive_deleted_tenants then 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