ConceptsAudit & PHI

Audit and PHI

What this is: how we make the platform HIPAA-defensible without it being a HIPAA platform. Two mechanisms: per-service audit logs and PHI safe-views.

Who it’s for: anyone writing a service that handles patient data, anyone debugging a privacy concern, anyone preparing for a security review.

What to read next: Brands and multi-tenancy, System overview, services/clinical.

The premise

Loop holds health data. Some of it is PHI under HIPAA’s definition (lab values tied to a named patient, clinical decisions, prescription history). Some of it is health-adjacent but not strictly PHI (a peptide name browsed without account). We treat both like PHI by default, because:

  • The rules are clearer (“we never log values”) than the boundary case-by-case.
  • A breach involving health-adjacent data is bad PR even if it’s not a regulatory breach.
  • Engineers don’t have to remember which fields are PHI on each model; the rule is the same everywhere.

The platform is HIPAA-defensible, not HIPAA-certified. We follow HIPAA-inspired patterns; we have BAAs with the few vendors that hold real PHI; partners that request PHI scopes via OAuth must sign a BAA.

Two mechanisms

Audit logs

Every state-changing API call writes an audit row in the same transaction as the state change.

Per-service audit table (ADR-0039):

CREATE TABLE <service>.audit_log (
  id              UUID PRIMARY KEY,
  occurred_at     TIMESTAMPTZ NOT NULL,
  actor_type      TEXT NOT NULL,         -- 'user' | 'service' | 'system'
  actor_id        TEXT NOT NULL,
  actor_brand_id  TEXT,
  event_type      TEXT NOT NULL,         -- 'clinical.biomarker.parsed', 'identity.connected_app.revoked'
  entity_type     TEXT,                  -- 'biomarker', 'oauth_grant'
  entity_id       TEXT,
  brand_id        TEXT NOT NULL,
  request_id      TEXT,                  -- correlate across services
  details         JSONB                  -- redacted, no raw PHI values
);

The middleware at @platform/hono/src/middleware/audit.ts:

  1. Reads c.set("audit_event_type", "...") set by the route handler.
  2. After the handler completes, inserts an audit row with actor (from token), entity, and details.
  3. If the route doesn’t set an audit_event_type AND it’s a mutation, the convention check audit-required fails CI.

What goes in details: shape changes, IDs of affected rows, redacted versions of inputs. What never goes in: raw biomarker values, free-text patient notes, full credit card numbers, any string that contains PHI.

Audit logs are append-only. No service has UPDATE or DELETE privileges on its own audit table — enforced by Postgres role grants.

PHI safe-view

The other half. Every log statement, every metric label, every error message passes through a safe-view wrapper that redacts PHI by structure.

import { safeView } from "@platform/core";
 
logger.info("biomarker parsed", safeView({
  user_id: "user_01HXY...",                  // safe — opaque IDs are fine
  biomarker: "testosterone",                 // safe — biomarker NAMES are fine
  value: 612,                                // REDACTED — values are PHI
  unit: "ng/dL",
  reference_range: { low: 264, high: 916 },  // partly redacted
}));
// logs: { user_id: "user_01HXY...", biomarker: "testosterone", value: "[REDACTED]", ... }

safeView() knows which field names are PHI and replaces their values with [REDACTED] before serialization. The list lives in @platform/core/src/phi-fields.ts and is updated by reviewers when a new domain adds a sensitive field.

Per ADR-0046:

  • Never call logger.info(rawObject) with a model that may contain PHI. Always wrap in safeView().
  • Never include raw PHI in error messages that bubble up to clients or logs. Error envelopes carry an error code and a public message; the detailed cause stays in the audit row.
  • Never include PHI in OTel metric labels. Cardinality + retention rules differ; metrics are not safe for PHI.

The convention check no-raw-phi-logging flags any logger.* call that passes a typed model from db/schema.ts without going through safeView(). Enforced repo-wide.

Who can read audit logs

  • The service that owns the audit table can read its own rows (no cross-service reads).
  • Staff with role audit:reader can read audit logs via the platform service’s admin endpoint, scoped to a brand.
  • The user themselves can request their own audit log via GET /v1/users/me/audit-log (scope: read:account).
  • Compliance/legal can export an audit slice for a regulatory request via a documented runbook.

There is no general “search all audit logs” endpoint. Queries are by actor_id, entity_id, or request_id.

The HIPAA boundary

What we do that aligns with HIPAA:

  • Minimum necessary access (scopes gate down to specific data)
  • Audit logging on every PHI access
  • Encrypted at rest (Aurora KMS) and in transit (TLS)
  • BAAs with vendors that hold PHI (WorkOS, AWS, Postmark for clinical email; Stripe is currently not BAA — that limits what PHI can flow there)
  • Per-user revocation of connected apps + audit trail

What we don’t do that a certified HIPAA platform would:

  • Annual third-party security audit (planned, not yet)
  • Formal incident response runbook with stated RTO/RPO (drafted, not exercised)
  • Workforce training records (informal)
  • Risk analysis documents (not yet)

This is by design — we’re building toward certification but ship today against the patterns. The audit-log + safe-view + scope-gating triad covers the load-bearing pieces.

When a partner requests PHI scopes

Third-party apps that request PHI scopes (e.g., read:biomarkers, read:protocols) cannot complete the authorization flow without a signed BAA. The OAuth /authorize endpoint rejects with invalid_scope if the client’s baa_signed = false and the requested scopes include any PHI scope.

BAA application: developer portal → app settings → “Apply for BAA”. Admin reviews and flips the flag.

Common mistakes

  • Logging the whole model. logger.info("user", user) where user has nested clinical fields. Always wrap in safeView().
  • Including PHI in error messages. throw new Error("testosterone reading 612 out of range") — the message gets logged. Use throw new ClinicalError({ code: "biomarker_out_of_range", biomarker_id: bid }) instead.
  • Forgetting audit on a new mutation. If the convention check doesn’t catch it (it should), reviewers must.
  • Allowing cross-brand audit reads. A staff member viewing brand A’s audit log should not see brand B rows without explicit cross-brand intent.

Source ADRs

ADR-0039 (per-service audit logs), ADR-0046 (PHI safe-views), ADR-0047 (observability sink), ADR-0052 (Connect / OAuth — BAA gate).