Patient audit emission
How clinical and patient-domain services write the compliance-mandatory audit.phi_access_logs and audit.consent_audit_logs rows.
Overview
Two tables. Two stakes.
| Table | Emitted when… | Stake |
|---|---|---|
audit.phi_access_logs | Any service exposes PHI to a user (view, create, amend, export) — including the cross-tenant in_kulcare search bucket and the auto-provision step that materialises a Clinical::Patient in a tenant. | HIPAA §164.312(b) requires identifiable access logs for 6 years. |
audit.consent_audit_logs | A data-sharing grant is recorded — explicit OR implicit (e.g. first booking at a new clinic is treated as electronic-method :data_sharing :granted). | DPDP Act 2023 §11 requires a provable record of the grant. |
All writes go through one entry point: Audit::Dispatch. Every domain call site that emits one of these row types calls Audit::Dispatch.log_phi_access(...) or Audit::Dispatch.log_consent(...). Direct Audit::PhiAccessLog.create! / Audit::ConsentAuditLog.create! from domains/ is a regression — Dispatch exists so call sites don't hand-roll the envelope (tenant_id, user_id, user_role, request_id, the defensive guard) and silently drift.
Technical Architecture
Envelope auto-fill. Dispatch pulls tenant_id / user_id / user_role / facility_id / session_id / request_id from explicit kwargs, then falls back to Shared::Current.* where safe. The fallback is not applied to Audit::Dispatch.log_consent's tenant: — see Sidekiq caveat below.
Fail-loud vs best-effort. Dispatch re-raises validation/connection errors. Cross-tenant emitters (federated search, find_or_create, auto-provision, first-time consent) let exceptions bubble so audit-write failure rolls back the transaction (#1344 item 7). Two read-path call sites (admissions show / discharge create) keep a narrow rescue ActiveRecord::ActiveRecordError so audit-infra outages don't 5xx the read — ArgumentError from Dispatch's own guards still bubbles (auth bugs are a different story).
API Reference
Audit::Dispatch.log_phi_access(**)
Audit::Dispatch.log_phi_access(
access_type: :view, # required, symbol
resource_type: 'Clinical::Patient', # required (or 'Identity::Patient' to skip patient_id presence check)
resource_id: clinical_patient.id,
patient_id: clinical_patient.id, # required unless resource_type == 'Identity::Patient'
access_reason: :treatment, # default :treatment
data_categories: %w[identity demographics],
metadata: { via: 'federated_search', matched_via: %w[phone] },
# Envelope — at least one source per row:
tenant: current_tenant, # OR tenant_id: '...' OR Shared::Current.tenant fallback
actor: current_user, # OR actor_id: '...' OR Shared::Current.user fallback
actor_role: 'doctor', # optional — derives from membership when actor is an object;
# defaults to 'unknown' when only actor_id is known
facility: @facility, # optional — falls back to Shared::Current.facility
session_id: '…', # optional — falls back to Shared::Current.session&.id
ip_address: request.remote_ip
)request_id is auto-merged into metadata from Shared::Current.request_id. Caller-supplied metadata[:request_id] wins (background-job replay case).
Audit::Dispatch.log_consent(**)
Audit::Dispatch.log_consent(
tenant: @tenant, # REQUIRED — no Shared::Current fallback (see Sidekiq caveat)
consent_type: :data_sharing, # required
action: :granted, # required
patient_id: clinical_patient.id, # OR patient: clinical_patient
granted_to: { facility_id: @facility.id, facility_name: @facility.name, tenant_id: @tenant.id },
scope: { data_categories: %w[identity demographics], via: 'public_booking_phone_match' },
collection_method: :electronic, # default :electronic
valid_from: Time.current, # defaults to Time.current (for :granted)
valid_until: nil,
ip_address: @request_context[:ip_address]
)request_id is auto-merged into scope — same precedence rules.
Sidekiq and Shared::Current
Shared::Current is ActiveSupport::CurrentAttributes — thread-local, auto-reset between requests but not auto-reset between Sidekiq jobs unless the worker explicitly does it.
The risk: a job running in tenant A finishes without Shared::Current.reset, the worker thread picks up the next job in tenant B, and a deep-stack service emits an audit row without passing tenant:. The row lands in tenant A.
The contract:
log_phi_access— falls back toShared::Current.tenant. Acceptable risk because PHI access is typically scoped to the actor's current tenant context anyway. Sidekiq workers MUSTShared::Current.tenant = job.tenantbefore doing PHI-touching work.log_consent—tenant:is REQUIRED. Consent is the compliance artifact for cross-tenant data sharing; the wrong tenant on a consent row is unrecoverable evidence corruption.
If you're calling from a Sidekiq job, prefer to pass tenant: / actor: explicitly on both methods — don't rely on the fallback.
Current call sites
| File | What it audits |
|---|---|
clinical/services/patients/federated_search_service.rb | B1 — in_kulcare exposure (PHI: Identity::Patient) |
clinical/services/patients/find_or_create_service.rb | C1 — cross-tenant data-sharing grant on phone-match auto-link |
clinical/controllers/admissions_controller.rb | Discharge summary READ |
clinical/services/discharges/create_service.rb | Discharge summary CREATE |
clinical/services/orders/create_appointment_order_service.rb | Auto-provision Clinical::Patient (clinic-initiated) |
patient/services/appointment_orders/create_service.rb | First-time consent + auto-provision (patient-initiated) |
Adding a new audit emission
- Find the service whose business action makes the PHI access / consent grant happen — emit there, not in the controller (so background-jobs and console tasks audit too).
- Choose
log_phi_access(view / amend / export / create) orlog_consent(data-sharing grant or revocation). - Pass
tenant:(consent ALWAYS, phi_access whenever you have it). Pass an actor object when available; fall back toactor_idonly when you genuinely don't have the user (e.g. service operating off a persisted row's foreign key). - Put flow-specific compliance data in
metadata:/scope:(notreason_detail, not free-text).metadata[:via]is the conventional discriminator — use it. - Don't add a
rescue StandardErroraround the call unless you can articulate why audit-loss is acceptable on this path. Cross-tenant exposures must fail-loud (#1344 item 7).
Dependencies
Audit::PhiAccessLog,Audit::ConsentAuditLog— the models. See their docstrings for column-level validations.Shared::Current— envelope source. Seebackend/domains/shared/models/current.rb.
Testing
- Unit-level:
domains/audit/spec/services/dispatch_spec.rbcovers tenant/actor resolution, role derivation, request_id merging, and ArgumentError on missing required envelope. - Integration: each call site has its own spec asserting one row gets written with the right
metadata[:via]. MockingAudit::PhiAccessLog.create!/Audit::ConsentAuditLog.create!still works — Dispatch calls them internally, so existingexpect(...).to receive(:create!)expectations match. - Manual QA: see
docs/guides/testing/patient-cross-tenant-qa.md§3.7 (B1 exposure), §5A.2 (identity_user_id absence), §5A.4 (Audit::Dispatch envelope).
Configuration
No env vars. Audit schema is loaded as part of the audit domain at boot. If the schema isn't loaded, Dispatch raises in dev/prod and silently no-ops in test (a slim test schema is a legitimate setup).
Common pitfalls
- Passing
Shared::Current.tenantinline — same as the fallback, defeats the explicit-is-better intent. Pass the@tenantyour service was called with. - Putting structured data in
reason_detail— that column isstring, not jsonb. Usemetadata:instead so the field is queryable (#1344 item 8). - Re-introducing
defined?(Audit::*)at call sites — the guard lives in Dispatch now. Don't copy the old pattern.
Status: ACTIVE (#1338 item 1 shipped 2026-05-18) Last Updated: 2026-05-18