Every compliance vendor’s page will tell you the retention period is six years. Almost none of them show you a schema.
That gap is the whole problem. “Retain audit logs for six years” is easy to satisfy badly — write everything to a collection, watch the bill grow, and discover during your first security review that you cannot actually answer a question with it.
Here is the design I use on a HIPAA platform in production, and the reasoning behind each decision.
What the log has to be able to answer
Start from the questions, not the fields. A defensible audit trail answers, quickly:
- Who accessed this patient’s record, and when?
- What did this user access over this period?
- Which records were touched by this exported dataset?
- Did anyone access records outside their assigned patients?
- What happened in the fifteen minutes around this incident?
Note that all five are queries, and four of them are queries across time. A log you can write to but not interrogate is documentation theatre. The schema below is shaped by those access patterns rather than by what is convenient to emit.
The entry
interface AuditEntry {
ts: Timestamp; // server-assigned, never client
actorId: string; // who
actorRole: string; // role AT TIME OF ACCESS, not current role
tenantId: string;
subjectId: string; // whose PHI — the patient
resource: string; // 'observation' | 'image' | 'report' | ...
resourceId: string;
action: string; // 'read' | 'create' | 'update' | 'delete' | 'export'
outcome: 'allow' | 'deny';
channel: string; // 'web' | 'api' | 'batch' | 'model'
requestId: string; // correlates with application logs
ip?: string;
reason?: string; // break-glass justification, where applicable
}
Three fields there are load-bearing in ways that are not obvious:
actorRole is captured at write time, not resolved at read time. People change roles. If you store only actorId and join to a users collection later, an investigation eighteen months from now reconstructs the current role, not the role that authorised the access. That is the difference between an audit trail and a plausible story.
outcome records denials. Most teams log successful access and drop the rest. Denied attempts are the more interesting signal — a user repeatedly bouncing off records outside their assignment is exactly the pattern you built this to detect, and you cannot detect it if you only log successes.
channel distinguishes machine access. When a model summarises a record, that is a disclosure and it belongs in the trail. Teams instrument human reads and forget the automated ones, then cannot answer what touched a record during a batch job.
Why the composite index is the design
Firestore charges and performs by document read, so the query shape determines everything. The two dominant queries are by subject over time and by actor over time:
// Who touched this patient's records, most recent first
db.collection(`tenants/${tid}/audit`)
.where('subjectId', '==', patientId)
.orderBy('ts', 'desc')
.limit(200);
// What did this user do in this window
db.collection(`tenants/${tid}/audit`)
.where('actorId', '==', userId)
.where('ts', '>=', from).where('ts', '<=', to)
.orderBy('ts', 'desc');
Both need composite indexes — (subjectId, ts) and (actorId, ts) — declared up front. Discovering you need one during an incident, when the console tells you to go build an index over a large collection, is a bad afternoon.
Note the path is tenant-scoped. Same reasoning as scoping PHI by path rather than by field: a query cannot accidentally span tenants if the tenant is structurally part of the address.
The cost problem, and the tiering that solves it
Naively, every PHI read becomes an audit document. A moderately active clinical product generates a lot of reads, and Firestore’s pricing is not designed for high-volume append-only workloads held for six years. Storage compounds, and so does the index.
The fix is a hot/cold split:
- Hot window in Firestore, ninety days. This covers investigations, support questions, and the overwhelming majority of real queries. Indexed, fast, queryable from the application.
- Cold archive in object storage. A scheduled job exports entries older than the window to newline-delimited JSON or Parquet in Cloud Storage, partitioned by date, then deletes them from Firestore. Storage cost falls by orders of magnitude, lifecycle rules move it to colder classes as it ages, and it is still queryable — slowly, via BigQuery over the export — on the rare occasions you need year-old data.
Ninety days is a default, not a rule. Set it from your actual investigation patterns; if your security team routinely looks back six months, use six months.
Two things to get right in the export:
Verify before deleting. Export, confirm the object exists with the expected record count, then delete from Firestore. An archive job that deletes on the assumption the write succeeded will eventually eat a month of audit data, and you will find out during a review.
Make the archive immutable. Object versioning plus a retention lock. An audit trail that can be quietly edited is not evidence, and the whole point of retention is that it survives someone wanting it not to.
Write it server-side, and decide the failure mode deliberately
Audit writes belong in the same server-side path that authorises the access — a Cloud Function or middleware, never the client. A client-written audit entry is worth nothing; the party you might be auditing controls it.
Then the decision most teams make by accident: what happens when the audit write fails?
Two options. Fail the read — no log, no disclosure. Or serve the data and accept an unlogged access.
For PHI, failing closed is the defensible default. The implication is that your audit path now needs the reliability engineering of a critical path: retries, a durable queue if you want to decouple the write, and alerting when it degrades. That is real work, which is exactly why it should be a decision you made rather than a default you inherited.
Whichever way you go, write it down and be able to explain it. “We hadn’t considered it” is a much worse answer in a review than “we serve the read and alert on log failure, here is the runbook.”
What this does not cover
- This is not a compliance program. An audit trail is one control. BAA coverage, encryption posture, access management, de-identification, breach procedures — all separate.
- Retention is not only HIPAA’s six years. State law and your own contracts may require longer. Design the archive so extending the period is a lifecycle-policy change, not a migration.
- Logging is not monitoring. A trail nobody queries detects nothing. At minimum, alert on denied-access clusters and on any access to records outside a user’s assignment.
- Not legal advice. I implement to requirements; your counsel and compliance officer set them.
The short version
Design from the queries you will have to answer, not from the fields that are easy to emit. Capture the actor’s role at the time of access. Log denials and machine access, not just successful human reads. Keep a hot window in Firestore with the composite indexes declared up front, tier the rest to immutable object storage, and verify the export before you delete. Decide explicitly what happens when the audit write fails.
The test is simple: can you answer “who read this patient’s record in March” in under five minutes, eighteen months from now, without engineering help? If not, you have retention without an audit trail.
I’m a fractional CTO working mostly in healthtech and applied AI. If you’re building on regulated data and want the design looked at before it hardens, the 30-minute call is free and the Technology Health Check goes deeper.