Audit overview
PHIPA (Ontario’s Personal Health Information Protection Act) requires that custodians can produce, on request, a record of every access to identifying health information — who, what, when, why. Clinloop satisfies that with two layers:
audit_events— every read or mutation of PHI, every auth event, every authorization denial, and every rate-limit trip.audit_notifications— every push, SMS, or email send (or blocked send), with hashed recipient identifiers.
Both tables are append-only at the database level via row-level security.
Three accountability layers
| Layer | Mechanism | Covers |
|---|---|---|
| Decorator | @PhiAccess({ resourceType, eventType }) | Successful 2xx mutations and reads |
| Interceptor | ExceptionAuditInterceptor | ForbiddenException (authz.denied), ThrottlerException (request.rate_limited) |
| Manual | AuditService.record(...) | Auth flow, public endpoints, webhook rejections, anything not bound to a controller success |
The decorator + interceptor handle the common case automatically.
Anywhere a UnauthorizedException short-circuits before a controller
returns 2xx (signature mismatch on a webhook, expired OTP), call
AuditService.record(...) manually so the rejection is captured.
What goes in metadata
- ✅ Hashed identifiers —
email_hashed: SHA-256(lowercased) - ✅ Opaque IDs —
attempted_resource_id - ✅ Categorical fields —
previous_status,reason,tier,row_count,score_bucket - ❌ Raw email, phone, SIN, DOB, full name, address — never
- ❌ Free-text patient notes, medication, diagnosis — never
The audit suite includes a sweep test (12.2) that scans every metadata blob for likely PHI patterns at the end of the run.
Where to look next
- Event catalog — the canonical list of emitted events
- Test tracker — coverage status for every audited code path
- Compliance notes — data residency, retention, RLS, append-only invariants