Kulcare Docs
DeveloperPatientsAudit

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.

TableEmitted when…Stake
audit.phi_access_logsAny 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_logsA 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(
  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 to Shared::Current.tenant. Acceptable risk because PHI access is typically scoped to the actor's current tenant context anyway. Sidekiq workers MUST Shared::Current.tenant = job.tenant before 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

FileWhat it audits
clinical/services/patients/federated_search_service.rbB1 — in_kulcare exposure (PHI: Identity::Patient)
clinical/services/patients/find_or_create_service.rbC1 — cross-tenant data-sharing grant on phone-match auto-link
clinical/controllers/admissions_controller.rbDischarge summary READ
clinical/services/discharges/create_service.rbDischarge summary CREATE
clinical/services/orders/create_appointment_order_service.rbAuto-provision Clinical::Patient (clinic-initiated)
patient/services/appointment_orders/create_service.rbFirst-time consent + auto-provision (patient-initiated)

Adding a new audit emission

  1. 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).
  2. Choose log_phi_access (view / amend / export / create) or log_consent (data-sharing grant or revocation).
  3. Pass tenant: (consent ALWAYS, phi_access whenever you have it). Pass an actor object when available; fall back to actor_id only when you genuinely don't have the user (e.g. service operating off a persisted row's foreign key).
  4. Put flow-specific compliance data in metadata: / scope: (not reason_detail, not free-text). metadata[:via] is the conventional discriminator — use it.
  5. Don't add a rescue StandardError around 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. See backend/domains/shared/models/current.rb.

Testing

  • Unit-level: domains/audit/spec/services/dispatch_spec.rb covers 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]. Mocking Audit::PhiAccessLog.create! / Audit::ConsentAuditLog.create! still works — Dispatch calls them internally, so existing expect(...).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.tenant inline — same as the fallback, defeats the explicit-is-better intent. Pass the @tenant your service was called with.
  • Putting structured data in reason_detail — that column is string, not jsonb. Use metadata: 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

On this page