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:
- Reads
c.set("audit_event_type", "...")set by the route handler. - After the handler completes, inserts an audit row with actor (from token), entity, and details.
- If the route doesn’t set an audit_event_type AND it’s a mutation, the convention check
audit-requiredfails 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 insafeView(). - 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:readercan 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)whereuserhas nested clinical fields. Always wrap insafeView(). - Including PHI in error messages.
throw new Error("testosterone reading 612 out of range")— the message gets logged. Usethrow 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.
Related
- Brands and multi-tenancy —
brand_idflows through audit too - Auth model — scopes gate PHI access
- System overview
- Connect with Loop security — partner BAA process
- services/clinical — heaviest PHI surface
Source ADRs
ADR-0039 (per-service audit logs), ADR-0046 (PHI safe-views), ADR-0047 (observability sink), ADR-0052 (Connect / OAuth — BAA gate).