D85 — Audit/DLP append-only store growth: WORM segment export now, native time-partitioning as the deferred scale lever (RESOLVED, extends D31/D46/D58)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
The append-only compliance stores grow without bound by design: audit_events/audit_checkpoints
(D46 — UPDATE/DELETE raise via trigger, never pruned in-app) and the dlp logs
(data_access_logs, tenant_data_access_logs, dlp_egress_events — same D31 posture, no
Prunable). Note the audit partition column is a logical hash-chain discriminator
(central / tenant:<id>), not native Postgres partitioning — every one of these stores is an
ordinary heap table (relkind = 'r'). Three growth strategies were compared:
- (a) Native Postgres range partitioning (by time) + retention windows. The best long-term
operational lever (partition pruning for the read filters;
DETACH/DROPinstead of DELETE-storms). But it is a REBUILD of live tables — a partitioned parent requires every PK/unique to include the partition key, soaudit_events'PRIMARY KEY (id)and the structuralUNIQUE (partition, sequence)backbone would weaken to per-time-slice uniqueness (the advisory- lock appender stays correct —pg_advisory_xact_lock(hashtext(partition))serialises sequence assignment independent of storage — but the DB-level 23505 backstop coarsens), the append-only triggers must be re-verified against partition DDL, and retention windows contradict the D31 posture until a slice is BOTH past the 7-year floor AND provably exported. No current need: verify-chain already streams (O(1) memory,lazyById), the search filters are index-covered, and volumes are nowhere near partitioning territory. - (b) Periodic WORM export to Object-Lock object storage. Solves the actual compliance risk NOW — the database stops being the sole custodian of the trail — with zero schema risk to the chain machinery. An append-only layout + bucket-level Object-Lock retention gives regulator-grade immutability off-platform, and each exported artifact can carry the chain material to be independently verifiable.
- (c) External append-only catalogue (S3 Tables/Iceberg, or Cloudflare R2 + catalogue). Adds an analytics query engine the compliance requirement doesn't ask for (the need is retention + verifiability, not cross-store SQL), plus real cloud infra and a second consistency domain. Rejected for now; the (b) segment layout is Iceberg-ingestable later if analytics materialises.
Decision: (b) now, (a) deferred until table size is an operational problem, (c) rejected.
Shipped as audit:export-worm / Modules\Audit\Services\ExportWormSegments: an incremental,
idempotent export of closed fixed-size chain segments per partition
(<prefix>/<partition>/segments/<from>-<to>.json, zero-padded; an existing object is NEVER
rewritten; the watermark is derived from the listed object names, so the bucket is the state). Each
segment document embeds the full verification material — every row's previous_hash/hash, the
exact AppendAuditEvent::chainHash() spec, the covering signed checkpoints, and an HMAC segment
signature under an app.key-derived key (the D58 checkpoint idiom, label
audit-worm-segment-signing-v1) — so a segment verifies independently of the database. Config-gated
- disabled by default (
audit.worm_export.*); when enabled the provider schedules it dailyonOneServer(). Locally it targets thes3-wormdisk → the--with-lockMinIO bucket (stables-audit-worm, bootstrapped byminio-init, the D82 precedent); in production the disk points at an S3 bucket whose compliance-mode DEFAULT retention (≥ the D31 7-year floor) is deploy config — deliberate, because the flysystem S3 adapter does not forward per-objectObjectLockMode/ObjectLockRetainUntilDateparams (AwsS3V3Adapter::AVAILABLE_OPTIONS), and a bucket default needs no per-request headers. The in-DB posture is UNCHANGED: nothing is pruned; the export is an additional custodian, not a migration. Extending the segment exporter to the dlp logs (which have no hash chain — an export would carry content only) and the eventual (a) rebuild are tracked indocs/tracking/audit-trail.md+docs/tracking/dlp-v2-scope.md.
Trust model + residual risk (review hardening). The name-derived watermark makes bucket object
NAMES attacker-writable state: a principal with bucket WRITE access can pre-create a far-future
segment name to poison the resume point, or pre-write the next segment path with altered content —
either would permanently suppress real exports if names were trusted blind. Mitigations shipped:
(1) verify-before-trust in the export — the max-to watermark object and any about-to-be-skipped
existing object are READ and verified (parseable, format, segment_signature HMAC, closing_hash ==
the DB chain hash at its to, AND a full recompute of the segment's INTERIOR chain: every row's
previous_hash linkage + chainHash() recomputation walking from opening_previous_hash through to
closing_hash, plus the event count vs the coordinates — load-bearing because the HMAC covers only
partition|from|to|closing_hash, so a content rewrite or stripping of events that preserves the
closing tuple would otherwise pass) before the export advances; a mismatch ABORTS that partition's
export with a loud anomaly (command exits FAILURE). An IO READ failure (get() null under the disk's
throw => false) is a DISTINCT retry-first anomaly (not a tamper verdict), and a failed segment
WRITE (put() === false) is an anomaly too — never a silent success; (2) audit:verify-worm
(scheduled daily onOneServer() alongside the export) reconciles the whole archive against the DB —
contiguous from..to coverage per partition up to the watermark (interior-gap detection, which the
max-based watermark cannot see) plus the same full per-object verification (HMAC + closing-hash +
the interior chain recompute) plus cross-segment linkage (each segment's opening_previous_hash
must chain onto the prior segment's closing_hash) — O(segment_size) per object, the daily sweep's
deliberate cost; (3) dedicated least-privilege writer credentials — the s3-worm disk reads
AUDIT_WORM_ACCESS_KEY_ID/
AUDIT_WORM_SECRET_ACCESS_KEY (falling back to the general AWS_* for local dev); production uses a
dedicated identity with s3:PutObject + s3:GetObject + s3:ListBucket on the WORM bucket only
(get/list are required by the watermark + verify-before-skip reads), NO s3:DeleteObject, NO
s3:BypassGovernanceRetention, distinct from any app-request-path credential; wider bucket READ is
auditor-scoped (the archive is the whole cross-tenant trail). RESIDUAL: a principal holding bucket
write access can still land garbage objects (denial-of-export noise) and, absent Object-Lock, damage
unexported names — the bucket's compliance-mode default retention plus the daily reconciliation are
the floor under that. Poison-wedge under Object-Lock (accepted posture): WITH Object-Lock a
far-future junk object landed via the writer credential becomes the partition's (undeletable)
watermark candidate and WEDGES that partition's export — every run re-derives the same poisoned
watermark, fails verification, and aborts loudly until the object is cleared. The fail-loud abort is
the chosen posture (silently skipping unverifiable bucket state would be worse). Operational runbook:
audit:verify-worm pinpoints the offending key(s); clearing them requires bucket-owner intervention
(compliance-mode retention cannot be bypassed by any credential — expect a lifecycle exception /
support path on the bucket owner's side, or waiting out retention on a governance-mode bucket);
exports for the partition resume automatically once the poison object is gone, since the bucket is
the only state. The segment document also embeds a signature_spec block (mirroring
hash_spec) so it is fully self-describing; NOTE an APP_KEY rotation is a signature-versioning
event — segment HMACs (and D58 checkpoint signatures, which share the derived-key idiom) minted
under the old key stop verifying under the new one, while the hash_spec chain recomputation stays
valid regardless (re-anchor/re-sign explicitly on rotation). Production-rollout gate (accepted
2026-07-04): failure alerting. Enabling the export in production is gated on a concrete failure
alert paging on any non-zero exit of the scheduled audit:export-worm/audit:verify-worm runs —
the commands exit non-zero on every anomaly (and on an explicitly targeted --partition matching
nothing) precisely so a scheduler can page; an unwatched non-zero exit silently defeats the
second-custodian purpose (rollout checklist: docs/tracking/audit-trail.md).