R
RonanRx
Signed waiver storage & traceability
Discussion draft PHI · private GCS bucket GCP · ronanrx-prod Sealed on signature Rails-proxied download

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.

Open the patient signing page →The embedded mock

PHI boundary. The object key is a random 128-bit token — no patient id, email, or phone in the path — so a bucket listing or a leaked log reveals nothing. The patient↔object mapping lives only in the DB (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.

Secure portal

Document sealed

A workflow renders the PDF packet, embeds the signature image + token evidence + timestamps, and computes document_sha256 and signature_sha256.

Seal workflow

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.

WaiverStore (GCS)

Recorded + traceable

After the upload, a DB transaction writes the Artifact (object key + generation + hashes) and an Event. Patient → artifacts → object.

Rails (txn)

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.

Rails proxy

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.

RecordFieldsWhy it matters
TextAuthorizationpatient_id, status, document_version, signed_at, expires_at, revoked_at, phone_hashSource 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.
Eventauthorization.signed, waiver.sealed, authorization.revokedLifecycle activity feed for support + patient UI without opening the PDF.
PhiAccessLogrequest_id, token_id, consent_id, object, generation, ip_hash, ua_hash, outcomeAppend-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

  1. A private GCS bucket (ronanrx-prod) + a WaiverStore on the google-cloud-storage gem (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).
  2. Workflows::SealTextAuthorization + SealTextAuthorizationJobidempotent (partial unique index), seal state machine, enqueued post-commit; a DB-driven reconciliation job in config/recurring.yml.
  3. A Prawn PDF generator (stroke-rendered signature, capped input).
  4. Extend Consent (kind text_messaging) + a dedicated AuthorizationToken table + the Artifact payload above.
  5. OTP challenge + token services (HMAC phone binding, atomic single-use consume, rate limits) gating sign + download.
  6. 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 PhiAccessLog per read.
  7. Two guarded Linq templates (sign /a/, share /w/).
  8. 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.