Storage & traceability
Every signed waiver is sealed once, lands in a private bucket under a random key, and traces back to the patient through the database.
When the patient signs the text-PHI authorization (after an OTP challenge), Rails spawns the PDF packet (the way a codex review or a skill run produces an artifact), uploads it to a private Google Cloud Storage bucket under a random object key, and writes a row that links the patient to the exact object. Nothing is public; downloads stream back through Rails (no signed-URL hand-out), and every read is audit-logged.
Artifact.payload), and the runtime service account has no list permission.On GCP. GCS is HIPAA-eligible under Google Cloud's BAA. This is a separate, private bucket — not the public
gs://ronanrx-public-assets, and not the R2 leads bucket.End-to-end
Sign, seal, store, record, retrieve.
One write path. The seal step is the only new heavy piece; everything else reuses the Artifact / Event plumbing the platform already has (plus a new append-only PhiAccessLog).
Patient signs
After an OTP challenge, the signature is captured on the secure page (auth.ronanrx.com/a/:token). This fires the seal.
Document sealed
A workflow renders the PDF packet, embeds the signature image + token evidence + timestamps, and computes document_sha256 and signature_sha256.
Lands in a private GCS bucket
Uploaded to a private, CMEK-encrypted, versioned Google Cloud Storage bucket at a random key waivers/<random>.pdf (no patient structure in the path). Uniform bucket-level access; no public access, ever.
Recorded + traceable
After the upload, a DB transaction writes the Artifact (object key + generation + hashes) and an Event. Patient → artifacts → object.
Retrieved on demand
Support or patient access streams the file back through Rails (attachment, no preview) after an OTP/auth re-check and a revocation check. Every read writes a PhiAccessLog.
Bucket layout
Keys are random; the database holds the mapping.
No patient or structure in the path — there is nothing to enumerate. The Artifact row is the only link from a patient to their objects.
gs://ronanrx-waivers # GCS · project ronanrx-prod · private · uniform access · CMEK · versioned └── waivers/ ├── 9f3c1a…e7.pdf # random 128-bit key — sealed packet, no patient/structure in the path ├── a1b7f2…04.pdf └── … # patient↔object mapping lives only in the DB (Artifact.payload)
Data contract
What each record holds.
The bytes live in GCS; the database holds the object reference + hashes, so the link is queryable and the file is verifiable. No Active Storage needed — Artifact.payload already exists.
| Record | Fields | Why it matters |
|---|---|---|
TextAuthorization | patient_id, status, document_version, signed_at, expires_at, revoked_at, phone_hash | Source of truth for whether the thread may carry clear PHI. |
Artifact(kind: text_phi_authorization_pdf) | payload: { gcs_bucket, object, generation, document_sha256, signature_sha256, byte_size, signed_at } | Links the patient to the exact GCS object (with its generation for versioned reads); hashes make the file tamper-evident. |
Event | authorization.signed, waiver.sealed, authorization.revoked | Lifecycle activity feed for support + patient UI without opening the PDF. |
PhiAccessLog | request_id, token_id, consent_id, object, generation, ip_hash, ua_hash, outcome | Append-only PHI-access ledger — one row per proxied read/issue; the canonical access audit (not the hard-fail AuditLog gate ledger). |
For internal review
What we need to make it work.
Left: the pieces to build — the records reuse the existing Artifact / Event plumbing (plus a new append-only PhiAccessLog); the GCS layer is net-new. Right: the wedges, all resolved.
Pieces to build
- A private GCS bucket (
ronanrx-prod) + aWaiverStoreon thegoogle-cloud-storagegem (private upload, Rails-proxied read, random keys). Net-new: adds the gem + a least-privilege SA. Config:GCS_WAIVERS_BUCKET+ creds (env now, Workload Identity on Cloud Run). Workflows::SealTextAuthorization+SealTextAuthorizationJob— idempotent (partial unique index), seal state machine, enqueued post-commit; a DB-driven reconciliation job inconfig/recurring.yml.- A Prawn PDF generator (stroke-rendered signature, capped input).
- Extend
Consent(kindtext_messaging) + a dedicatedAuthorizationTokentable + theArtifactpayload above. - OTP challenge + token services (HMAC phone binding, atomic single-use consume, rate limits) gating sign + download.
- A role-gated retrieval endpoint that streams the file back through Rails (attachment, no preview) after an OTP/auth + revocation re-check, and writes a
PhiAccessLogper read. - Two guarded Linq templates (sign
/a/, share/w/). - Tests at the ★★★ bar: seal transitions + guards + idempotency + revoked-mid-seal, PHI-not-in-key/log, OTP/token.
Decisions (resolved)
- D1 · Key: random 128-bit object key — DB holds the patient mapping.
- D2 · Store: private GCS bucket (separate from public assets + R2 leads).
- D3 · PDF: Prawn, deterministic server-side.
- D4 · Timing: async seal + Turbo Stream signal.
- D5 · Model: extend Consent +
AuthorizationToken. - D6 · Lifecycle: retention via
Consent::RETENTION_YEARS(value pending counsel); revocation keeps the artifact. - D7 · Access: least-priv SA, Workload Identity on Cloud Run.
- Auth: OTP challenge over the SMS bearer link.