ADR-0007: Cloudflare R2 for file storage; separate compliance bucket with object-lock
Status: Accepted Date: ~2026-03 (estimate, around FIT-158 compliance forms work) Context owner: Owner
Context
FitKit stores three classes of files:
- General assets — CSV exports, member avatars, attached photos in messages.
- Progress photos — sensitive (member body images); access strictly scoped per member + their coach.
- Compliance artifacts — signed waivers, health declarations, parental consents, studio rental contracts. Israeli Sports Promotion Law requires 7+ years retention, immutability (no edits or deletions), and provability of who signed when.
Different durability and access requirements per class. Mixing them in one bucket is a foot-gun (one wrong lifecycle policy and you’ve deleted a court-relevant document).
Decision
Use Cloudflare R2 as the object store. Two buckets:
R2_BUCKET_NAME(e.g.,fitkit-exports) — general assets. Standard lifecycle, deletable.R2_COMPLIANCE_BUCKET_NAME(e.g.,fitkit-compliance) — signed PDFs and other regulatory artifacts. Bucket-level object-lock + lifecycle retention configured before production. No deletion permitted.
Access is via presigned URLs minted by the API:
- Standard presign: authenticated user via Clerk; URL embeds a short TTL and a scope to the user/org.
- Public token-gated presign (FIT-189): lets a member receive a signing link on WhatsApp/SMS and upload their signature without being signed in. The token is short-lived, single-use, and scoped to a specific form instance.
In dev, R2_COMPLIANCE_BUCKET_NAME falls back to R2_BUCKET_NAME for convenience. In production, the two MUST be different buckets, and the compliance bucket MUST have object-lock enabled at the provider level before any real data lands.
Consequences
Positive
- Compliance is a property of the bucket, not application code. We can’t accidentally delete a signed PDF by writing the wrong endpoint.
- R2 has no egress fees — the web app can stream large files without us paying twice.
- API stays stateless; files don’t touch local disk.
- The presign model means the API doesn’t proxy file uploads (good for throughput).
Negative
- Two-bucket architecture is one more thing to remember when adding a new file flow. Mistakes default to the general bucket, which is less safe for new compliance artifacts.
- Cloudflare R2 has had occasional regional latency spikes. We don’t multi-region today.
- Audit on the bucket itself (who downloaded what when) is limited to Cloudflare’s logs — not yet integrated with our audit pipeline (also a gap; see FIT-20).
Discipline
- New feature that writes a regulatory artifact? Use the compliance bucket and document the retention requirement in the feature’s
behavior.md. - Never hardcode the bucket name — always go through env config.
- For dev, set
PUPPETEER_EXECUTABLE_PATHso the forms PDF renderer can run locally.
Related
- features/forms/behavior.md — the primary compliance consumer
- features/uploads-r2/README.md
- runbooks/credentials-rotation.md