Skip to content

Audit Compliance: HIPAA & GDPR Requirements

Status — verified against migrations + code on 2026-08-02.

Shipped. The synchronous audit recorder (internal/core/audit/, 1A.1), key redaction (1A.5), failed-request logging (401-with-bearer / 403 / 5xx), append-only enforcement (no UPDATE/DELETE RLS policy + REVOKE from restartix_app), monthly range partitioning of audit_log / audit_ai_provenance with a forward-roll cron (1A.15), a mechanical coverage guard (cmd/check-audit-coverage, wired into make check), the platform-wide read API GET /v1/audit-logs with its Console UI, and the break-glass elevation primitive end-to-end (1B.11 + 1B.11.x) plus its patient-impersonation sibling (1B.13).

Not shipped. An org-scoped (clinic-facing) audit read endpoint, CSV export, any retention/aging engine at all (no archival to S3, no purge, nothing drops a partition), read-access auditing inside break-glass sessions, any break-glass review workflow, a breach-records table, and DSAR export / erasure routes. internal/core/gdpr/anonymize.go exists as a helper with no caller. These are Phase 2 (F11 compliance hardening + operational leftovers) in platform-completion.md.

Sections below are marked Shipped or Unbuilt individually. Where a control is unbuilt, this document says so instead of describing it in the present tense — an asserted safeguard with no enforcing mechanism is itself an audit finding.

Overview

This document details how the platform's audit system meets — and where it does not yet meet — HIPAA and GDPR requirements. The audit trail is designed to be tamper-evident, comprehensive, and retained for the required periods. Tamper-evidence and comprehensiveness are enforced today; retention beyond the hot Postgres tier is not.

HIPAA Requirements

164.312(b) - Audit Controls

Requirement: Implement hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information (ePHI).

Our Implementation:

RequirementImplementationStatus
Record all mutationsinternal/core/audit records one row per CREATE/UPDATE/DELETE. cmd/check-audit-coverage fails make check when a handler registered via r.Post/r.Put/r.Patch/r.Delete has no reachable audit.Record* call and no // audit-exempt: marker.Shipped (1A.1)
Record read access to ePHINot implemented. GETs emit no audit row, including inside a break-glass session — see Reads are not audited.Unbuilt
Examine activity logsGET /v1/audit-logs (superadmin, platform-wide, filterable by organization_id) + Console /audit-logs and /clinics/{id}/audit pages. No clinic-facing endpoint yet, though the RLS policy and the audit_log.view_org permission that would gate one both exist.Partly shipped
Tamper-evidentNo UPDATE or DELETE RLS policy on audit_log (RLS denies by default when no policy matches); restartix_app has INSERT/UPDATE/DELETE/TRUNCATE explicitly REVOKEd on the parent and REVOKE ALL on each leaf partition (partitions.EnsureMonthly re-applies the claw-back on every rolled partition, because parent RLS is not enforced on direct-partition access and new leaves inherit the ALTER DEFAULT PRIVILEGES grant from 000001).Shipped (1A.1 + 1A.15)
RetentionHot tier only. audit_log is monthly range-partitioned and rolls forward; nothing archives, detaches, or drops a partition.Hot: shipped. Warm/purge: unbuilt

What Gets Logged

Every audited mutation captures:

  • Who: actor_id (FK to principals.id) + actor_type ('human' | 'agent' | 'service_account' | 'system')
  • What: entity_type (TEXT), entity_id (UUID)
  • When: created_at (TIMESTAMPTZ, UTC) — also the partition key
  • How: actionCREATE / UPDATE / DELETE for mutations; ACCESS_DENIED (401/403) and INTERNAL_ERROR (5xx) for the failure path
  • Where: ip_address (INET, client IP via Cloudflare or X-Forwarded-For)
  • Result: status_code
  • Context: request_path, request_method, request_id, user_agent
  • Elevation: action_context (normal / break_glass / impersonation / gdpr_operation / org_entitlement_change / platform_membership_change), plus break_glass_id and impersonation_id when the request ran inside an elevated session
  • Diff: changes (JSONB, redacted — see Sensitive Data Masking)

AI provenance lives in a sibling table, not on audit_log. audit_ai_provenance carries (audit_log_id, audit_log_created_at, model_id, inputs_hash, confidence) with a composite FK to audit_log(id, created_at) and the same monthly partition windows, so both tables hand off together at archival. It was split out so AI-features schema churn doesn't pollute the core audit table's compliance contract. There is no model_version column anywhere — the model is referenced by model_id FK into ai_models (1C.8).

Failed requests are also logged. The rules in audit.shouldLogFailure:

  • >= 500 — always
  • 403 — always (authenticated but not authorized)
  • 401 — only when the request actually carried a Bearer token, i.e. someone tried to authenticate. A tokenless 401 is unauthenticated noise; overridable via MiddlewareOptions.LogUnauthenticated401.

164.308(a)(1)(ii)(D) - Information System Activity Review

Requirement: Implement procedures to regularly review records of information system activity, such as audit logs, access reports, and security incident tracking reports.

Unbuilt — no review procedure is enforced by the platform today. An earlier version of this page asserted a review cadence (monthly log review, weekly failed-access review, break-glass review "within 24 hours by two approvers", quarterly compliance report). None of it is implemented, and the break-glass row in particular described a control the schema cannot express. It has been replaced with what actually exists.

What is actually enforced (technical controls):

ControlMechanismWhere
Break-glass requires a written justificationCHECK (length(btrim(reason_text)) >= 10) + closed reason_category enum (support_ticket, security_incident, dsar_routing, fraud_investigation, platform_engineering)000011_break_glass.up.sql
Break-glass is time-boundedCHECK (expires_at > opened_at AND expires_at <= opened_at + INTERVAL '4 hours'). Sessions never auto-renew.000011
Break-glass is scope-boundedscope IN ('patient_list','patient_detail','audit_full','cross_org_lookup','org_management'); a session for one scope does not cover another. RequireBreakGlass(scope) returns 403 break_glass_required / 410 break_glass_expired.000011, internal/core/middleware/break_glass.go
Break-glass is transparent to the clinicOn open, notify.Send(adminPrincipal, BreakGlassOpened, …) fans out to every org admin, with session_id:admin_principal_id idempotency keys. Not opt-out.breakglass/service.go, 1A.18
Clinic can see sessions against its own orgRLS SELECT policy: organization_id = current_app_org_id() AND current_app_has_permission('audit_log','view_org')000011
Elevated activity is linkableset_app_break_glass_session_id binds a GUC; the redefined audit_log_insert stamps break_glass_id + action_context='break_glass' on every audit row written downstream — no per-handler plumbing000011
Abandoned sessions get closedbreakglass.SweepExpired finalizes closed_at = expires_at for orphan-expired rows and emits an audit row attributed to the system principalbreakglass/sweep.go

What is NOT enforced (procedural controls, unbuilt):

  • No review workflow exists. break_glass_sessions has no reviewed_at, no reviewed_by_principal_id, no review_notes, and no approval columns of any kind. There is exactly one actor column for the elevating principal (principal_id) and one for whoever closed it (closed_by_principal_id). Break-glass is self-service, single-actor, no second approver, no review sign-off. A claim of "two approvers" or "reviewed within 24 hours" cannot be evidenced from the data.
  • No opened_from_ip on the session row. The originating IP is recoverable only indirectly, via audit_log.ip_address on rows carrying that break_glass_id.
  • No scheduled review job, no review dashboard, no quarterly export. There is no .github/workflows/hipaa-audit-check.yml and no cmd/tools/audit-check.
  • No anomaly alerting on audit_log. The Console system-health page has an audit-anomalies card, but there is no alerting pipeline behind mass-access or unusual-pattern detection.

Designing the review columns and workflow is Phase 2 / F11 work — see platform-completion.md → Phase 2. Note for whoever picks it up: 000011 is already applied to production (live since 2026-06-05). Adding columns to break_glass_sessions cannot be done by editing that migration; it needs a new forward migration plus the per-environment catch-up-DDL pattern established in infra/scripts/000023-skip-note-prod.sql.

Queries that work today (superadmin, against the shipped read API or directly):

sql
-- All failed access attempts in the last 7 days
SELECT * FROM audit_log
WHERE action IN ('ACCESS_DENIED', 'INTERNAL_ERROR')
  AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC;

-- High-privilege actions on sensitive entities
SELECT * FROM audit_log
WHERE action IN ('CREATE', 'DELETE')
  AND entity_type IN ('patient', 'patient_profile', 'human', 'organization')
  AND created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC;

-- All actions by a specific principal, within one org
SELECT * FROM audit_log
WHERE actor_id = $1          -- principals.id (UUID)
  AND organization_id = $2   -- organizations.id (UUID)
ORDER BY created_at DESC;

-- Every elevation in the last 30 days, with its justification
SELECT organization_id, principal_id, scope, reason_category, reason_text,
       reason_ref, opened_at, expires_at, closed_at
FROM break_glass_sessions
WHERE opened_at > NOW() - INTERVAL '30 days'
ORDER BY opened_at DESC;

164.308(a)(8) - Evaluation

Requirement: Perform a periodic technical and nontechnical evaluation … that establishes the extent to which an entity's security policies and procedures meet the requirements of this subpart.

Our Implementation:

EvaluationStatusNotes
Audit coverage completenessShipped, per-commitcmd/check-audit-coverage runs in make check — every mutating handler must reach an audit.Record* call or carry an // audit-exempt: <reason> marker
Column classification completenessShipped, per-commitcmd/check-classification fails the build when a migration adds a column with no data-classification.md entry
Retention compliance checkUnbuiltNo warm tier exists to verify
RLS policy auditPartly shippedRLS integration-test harness (1A.2) covers policies under test; the /audit-rls sweep is a manual review, not a scheduled job
Break-glass log reviewUnbuiltSee above — no review mechanism to verify against
Penetration testingUnbuiltPhase 2

GDPR Requirements

Article 30 - Records of Processing Activities

Requirement: Each controller and, where applicable, the controller's representative, shall maintain a record of processing activities under its responsibility.

Our Implementation: the audit_log table serves as the record of processing activities (ROPA) for all data mutations. Note the controller/processor split: the clinic is the controller, RestartiX is the processor — see decisions.md → Why clinic is controller, platform is processor. The ROPA a clinic files is its own; audit_log is the processing record we maintain on its documented instructions.

ROPA ElementAudit Log FieldExample
Name and contact details of the controllerorganization_idorganizations.name"Restartix Clinic"
Purposes of the processingaction + action_context"Patient record update, normal context"
Categories of data subjectsentity_typepatient, patient_profile, human
Categories of personal dataentity_type + changes JSONBpatient_profile.name, patient_profile.phone
Categories of recipientsactor_id joined via organization_memberships.role_id → roles.codeadmin, specialist
Transfers to third countriesNot applicable (all data stored in eu-central-1)N/A
Time limits for erasure6 years floor; per-org override via organization_settings.audit_retention_months (CHECK >= 72)See retention section
Technical and organizational measuresRLS, encryption, audit loggingreference/rbac-permissions.md, reference/encryption.md, reference/rls-policies.md

Article 32 - Security of Processing

Requirement: The controller and the processor shall implement appropriate technical and organisational measures to ensure a level of security appropriate to the risk, including:

  • (a) the pseudonymisation and encryption of personal data
  • (b) the ability to ensure the ongoing confidentiality, integrity, availability and resilience of processing systems and services
  • (c) the ability to restore the availability and access to personal data in a timely manner in the event of a physical or technical incident
  • (d) a process for regularly testing, assessing and evaluating the effectiveness of technical and organisational measures for ensuring the security of the processing

Our Implementation:

MeasureImplementationStatus
Pseudonymisationinternal/shared/pseudonym.UserID(uuid) → SHA-256 hex digest of a principals.id. Helper only; the telemetry forwarder that would apply it is not built. (The function name is legacy — the input is a principal id, not a "user" id; there is no users table. Rename to PrincipalID is queued for when the telemetry pipeline ships.)Helper shipped (1A.5); application unbuilt
Encryption at rest (column-level)AES-256-GCM via internal/core/crypto/, wire format [1-byte version][12-byte nonce][ciphertext+tag] in BYTEA. Live encrypted columns today: organization_billing.tax_id_encrypted (000003), platform_service_providers.credentials_encrypted (000015), outbound_webhook_subscriptions.signing_secret_encrypted + signing_secret_previous_encrypted (000016), organization_integrations.credentials_encrypted + inbound_signing_secret_encrypted (000017). Scope is deliberately narrow — auth_secret and pii_regulated only; see decisions.md → Why most PII is plaintext.Shipped (1A.3)
Patient-side regulated PII (CNP)patient_profiles has no encrypted column today. CNP capture as opt-in per form template, stored pii_regulated / encrypted BYTEA on patient_profiles, was settled 2026-08-02 and is not built — see platform-completion.md → CNP handling.Unbuilt
ConfidentialityRLS on every tenant table + per-org permission codes (current_app_has_permission) + audit loggingShipped
IntegrityDatabase constraints, foreign keys, append-only audit_logShipped
AvailabilityProduction RDS is Multi-AZ (infra/envs/production/main.tf), ECS Fargate across AZsShipped (live since 2026-06-05)
Restore capabilityRDS backup_retention_period = 7 (7-day PITR) plus the Layer-2 logical backup runner (cmd/backup-runner) and restore drill (cmd/restore-drill, ran green 2026-08-02)Shipped; cross-region replication and a schedule for the drill remain open
ResilienceHealth checks, ECS restarts, pgbouncer poolingShipped
Regular testingmake check guards (audit coverage, classification, SOUP, migrations) run per-commit; penetration testing and load gating are not scheduledPartly shipped

Article 33 - Breach Notification

Requirement: In the case of a personal data breach, the controller shall without undue delay and, where feasible, not later than 72 hours after having become aware of it, notify the personal data breach to the supervisory authority.

See reference/gdpr-compliance.md for the documented procedure. Because the clinic is the controller, the 72-hour notification obligation to ANSPDCP is the clinic's; the platform's obligation as processor is to notify the clinic without undue delay (Art. 33(2)).

What exists technically:

  • All qualifying failed access attempts (401-with-token, 403, 5xx) land in audit_log with action = 'ACCESS_DENIED' | 'INTERNAL_ERROR'
  • Break-glass sessions are queryable per-org, with justification, and notify org admins on open
  • Sentry is wired in the Portal only

What does not exist:

  • No breach_records table in any migration. Breach documentation has no schema-backed home today.
  • No anomaly detection or alerting — nothing watches for mass access (e.g. ">100 records in 1 minute"), and no such rule is implemented anywhere.
  • Datadog is not used by this platform. Observability today is CloudWatch alarms plus Sentry (Portal only); "Sentry / Datadog observability tooling" is an open item on the Console system-health page. Earlier revisions of this document referenced Datadog queries as if they existed.
  • No severity classification / escalation playbook — flagged as open and unowned in platform-completion.md → Security incident response.

Retention Policy

HIPAA Requirement: 6-Year Minimum

HIPAA 164.316(b)(2)(i) requires retaining documentation for 6 years from the date of its creation or the date when it last was in effect, whichever is later. organization_settings.audit_retention_months (CHECK >= 72) lets an org extend that floor; NULL inherits the platform default.

Target Three-Tier Retention Strategy — the engine is unbuilt

TierStorageDurationQueryableStatus
HotPostgreSQL audit_log (monthly partitions)0-12 monthsYesShipped
WarmS3 JSONL archives12 months - 6 yearsOn requestUnbuilt
DeletePurgedAfter 6 yearsNoUnbuilt

Verified state, 2026-08-02:

  • No aging engine exists anywhere on the platform. The partition machinery is forward-only: partitions.EnsureMonthly (internal/core/partitions) is the shared P41 primitive, audit.EnsurePartitions binds it to audit_log + audit_ai_provenance, partitionroll.RollAll fans out across every partitioned table, and cmd/api-partition-roll (default -ahead=3) runs it on a cron. Nothing drops, detaches, or archives a partition. grep -r "DROP PARTITION" services/ returns nothing. Every partitioned table grows monotonically.
  • audit_log is the hard case. Break-glass entries, GDPR-operation entries, and key-rotation events are never deleted (CLAUDE.md), and audit_retention_months is a per-org override while a monthly partition holds many orgs — so a naive partition drop would violate one tenant's extension while satisfying another's. Archive-to-S3-then-drop is the only correct shape.
  • s3.AuditArchiveKey(orgID, year, month) in internal/integration/s3/keys.go is the pre-positioned key builder for the warm tier. It is currently referenced only by its own test.

Full per-table retention windows (notifications 18mo, usage_records 24mo, outbound_webhook_deliveries 90d, etc.) are recorded in platform-completion.md → Per-table retention windows. That table is authoritative; do not duplicate it here.

Archival Process — design sketch, not shipped

No archival job exists. When one is built, two constraints from the current schema bind it:

  1. audit_log is partitioned, and restartix_app cannot write to it. DELETE FROM audit_log WHERE created_at < $1 is the wrong shape twice over — DELETE is REVOKEd on the parent and every leaf partition, and a partitioned table sheds old data by DETACH PARTITION + DROP TABLE, not by row-wise delete. The job runs on the admin pool and operates on whole monthly partitions.
  2. audit_ai_provenance must be handed off in the same window. Its composite FK targets audit_log(id, created_at) and it carries identical monthly ranges precisely so both tables move together. Archiving one without the other breaks the FK.

Sketch of the intended shape:

monthly, for each partition older than the retention cutoff:
  1. Stream the partition to S3 as gzipped JSONL, grouped by organization_id
     → s3://<bucket>/<s3.AuditArchiveKey(orgID, year, month)>
  2. Verify the upload (object exists, row count matches)
  3. DETACH the audit_ai_provenance partition, then the audit_log partition
  4. DROP both
  5. Skip any org whose organization_settings.audit_retention_months still
     covers the window — per-org overrides are why this cannot be a blanket drop
  6. Separately, purge S3 objects older than 6 years, excluding the
     never-deleted classes below

Special Retention Rules

Never deleted:

  • break_glass_sessions rows (permanent — the session row IS the forensic artifact; the audit rows linked by break_glass_id are what the archival job must preserve alongside it)
  • patient_impersonation_sessions rows, same rationale (1B.13)
  • Audit entries with action_context = 'gdpr_operation' (7-year retention)

Extended retention:

  • Breach notification records: 7 years per GDPR Art. 33 — no table exists to hold them
  • Key rotation events: permanent

Downloading Warm Archives — unbuilt

There is no archive-download endpoint. GET /v1/admin/audit/archive does not exist; neither does the warm tier it would read from. When both ship, the intended shape is a pre-signed S3 URL with a short expiry, gated on superadmin.

What Gets Logged

Logged Entities

audit_log.entity_type is free-text TEXT; these are the values the platform emits today (the read API's filterableEntityTypes allow-list is narrower than this — it exposes only the subset the Console filter UI offers, and lags behind).

DomainEntity types emittedNotes
Tenancyorganization, organization_membership, organization_settings, organization_domain, organization_billing, organization_entitlements, organization_designation, organization_ownership_transfer, organization_invite, location, platform_membership, subscription_override
Identityhuman, patient, patient_profileThere is no user entity type — there is no users table. Human profiles are humans (PK principal_id); the per-org patient link is patients; the portable patient identity is patient_profiles.
Elevationbreak_glass_session, patient_impersonation_sessionOpen and close both audit; the session row is the body-of-record
Consent & legalconsent, consent_purpose_version, legal_document_template, organization_legal_document
Clinical contentexercise, exercise_render, exercise_tag, protocol, program, program_phase, program_session, program_asset, session, session_exercise, session_run, session_pairing, patient_assignment, assignment_pauseTaxonomy entities carry an exercise_ prefix: exercise_category, exercise_body_region, exercise_equipment, exercise_movement_pattern, exercise_condition, exercise_recovery_phase, exercise_instruction, exercise_contraindication, exercise_prerequisite
Pose configexercise_pose_config, exercise_pose_metric, exercise_pose_landmark, exercise_pose_feedback_rule, pose_data_quality_override
Commerceaccess_offer, access_offer_campaign, access_offer_sku_binding, access_offer_fulfillment, patient_content_grant, patient_subscription, patient_subscription_override, patient_tier, catalog_section, catalog_entry, share_link
Integrationsorganization_integration, outbound_webhook_subscription, platform_service_provider, ai_model
Failure pathhttp_requestWritten by the audit middleware for ACCESS_DENIED / INTERNAL_ERROR

Entity types for unbuilt domains — appointment, form, form_template, specialist, custom_field, document — do not exist yet. Those are F1/F3/F4/F5/F6 in platform-completion.md; no tables, no handlers, nothing to audit. segment is out of scope entirely (F8).

Reads are not audited, including under break-glass

GET requests emit no audit row. This is deliberate for ordinary reads (volume, and RLS is the confidentiality control). But it also means:

A break-glass session that only reads produces no audit_log rows. RequireBreakGlass binds the break_glass_id + action_context = 'break_glass' GUCs so that any audit row written downstream carries the linkage — but if the elevated request is a GET, nothing writes a row. There is no READ or ACCESS action constant in internal/core/audit (ActionCreate / ActionUpdate / ActionDelete only), and the read API's action allow-list confirms it (CREATE, UPDATE, DELETE, ACCESS_DENIED, INTERNAL_ERROR).

What is recorded for a read-only elevation: the break_glass_sessions row itself (who, which org, which scope, why, when opened, when it expires/closed) and the admin-notification fan-out. That establishes that platform staff had access to a scope, with justification and a time window — it does not establish which patient records they opened.

Closing this gap means auditing reads inside elevated sessions. That is F11 work and is not implemented today. Until it is, statements like "break-glass sessions log all actions including reads" are false and must not appear in a compliance representation.

Sensitive Data Masking

Shipped (1A.5). Before writing to audit_log, values under sensitive keys in the changes JSONB are replaced with [REDACTED]. The same predicate backs slog redaction, so logs and audit rows agree.

Masked Patterns

The key list lives in internal/shared/redact:

go
var sensitiveKeys = []string{
    "password", "secret", "token", "apikey",
    "authorization", "cookie", "session",
}

Matching is separator- and case-insensitive and substring-based, so api_key, api-key, X-API-KEY, refresh_token, and clerk_session_token all match (locked by redact_test.go). Adding a new sensitive key happens in internal/shared/redact — both consumers pick it up.

Example:

json
// Original request body
{
  "name": "John Doe",
  "email": "john@example.com",
  "password": "hunter2"
}

// Logged in audit_log.changes
{
  "name": {"old": "Jane Doe", "new": "John Doe"},
  "email": {"old": "jane@example.com", "new": "john@example.com"},
  "password": "[REDACTED]"
}

PII Masking in Analytics

The Telemetry service stores to PostgreSQL + S3, not ClickHouse — see decisions.md → Why telemetry is PG + S3, not ClickHouse. Earlier revisions of this page described a ClickHouse pipeline; that design was rejected.

The intended masking contract, when the audit→telemetry forwarder ships (it does not exist today — internal/core/audit has no telemetry dependency):

  • Principal IDs are pseudonymised with pseudonym.UserID (SHA-256), never raw principals.id
  • IP addresses may resolve to country/city but the raw address is not forwarded
  • Patient names / emails are never forwarded

Querying Audit Logs

Admin API — GET /v1/audit-logs (Shipped)

Superadmin-only (RequireSuperadmin at the route), platform-wide. Reads through the owner pool, which bypasses RLS — org scoping is done by the organization_id filter, not by a URL path segment.

GET /v1/audit-logs?page=1&limit=50&sort=-created_at&action=CREATE,DELETE&organization_id=<uuid>

Query parameters (all optional):

ParamShapeNotes
qstringCase-insensitive substring across action, entity_type, request_path
actionCSVAllow-list: CREATE, UPDATE, DELETE, ACCESS_DENIED, INTERNAL_ERROR
entity_typeCSVValidated against a fixed allow-list in audit/handler.go; unknown values are dropped, not 422'd
actor_typeCSVhuman, agent, service_account, system
action_contextCSVnormal, break_glass, impersonation, gdpr_operation, org_entitlement_change
status_classCSV2xx, 4xx, 5xx
organization_idCSV of UUIDsNo allow-list — the value space is dynamic
actor_idCSV of UUIDsprincipals.id
created_after / created_beforedate/timestampParsed by apiquery.ParseDateRange, inclusive
sortCSV, - prefix for descAllow-list: created_at, action, entity_type, actor_type, status_code, action_context. Default -created_at.
page, limitintStandard apiquery.ParsePage; default 50, hard cap apiquery.MaxLimit (500)

Response:

json
{
  "data": [
    {
      "id": "01938b27-7df1-7c8a-9d3a-1b2c3d4e5f60",
      "organization_id": "01938b27-7df1-7c8a-9d3a-aabbccddeeff",
      "actor_id": "01938b27-7df1-7c8a-9d3a-112233445566",
      "actor_type": "human",
      "action": "UPDATE",
      "entity_type": "patient_profile",
      "entity_id": "01938b27-7df1-7c8a-9d3a-998877665544",
      "changes": { "phone": { "old": "…", "new": "…" } },
      "ip_address": "203.0.113.42",
      "user_agent": "Mozilla/5.0...",
      "request_path": "/v1/patient-profiles/01938b27-7df1-7c8a-9d3a-998877665544",
      "request_method": "PATCH",
      "status_code": 200,
      "request_id": "01938b27-7df1-7c8a-9d3a-0011aabbccdd",
      "action_context": "normal",
      "created_at": "2026-02-13T10:00:00Z"
    }
  ],
  "pagination": { "page": 1, "limit": 50, "total": 1234 }
}

AI-provenance fields are not in this payload — they live in audit_ai_provenance and have no read endpoint yet.

Clinic-facing (org-scoped) read — unbuilt

The substrate exists: the audit_log.view_org permission is seeded (000002), it is granted to the admin system role template, and the RLS SELECT policy is

sql
CREATE POLICY audit_select ON audit_log FOR SELECT USING (
    organization_id = current_app_org_id()
    AND current_app_has_permission('audit_log', 'view_org')
);

There is no GET /v1/organizations/{id}/audit-logs handler yet. When one ships it must mount middleware.RequireURLOrgMatchesScope("id") per P47 and read through the app pool so RLS applies.

CSV export — unbuilt

GET /v1/audit-logs/export does not exist. Nothing in internal/core/domain/audit produces CSV.

SQL Queries (Direct Database Access)

All identifiers are UUIDs (P26 — UUIDv7 for new PKs; audit_log.id defaults to gen_random_uuid()). Never integers.

Entity history:

sql
SELECT * FROM audit_log
WHERE entity_type = 'patient'
  AND entity_id = '01938b27-7df1-7c8a-9d3a-998877665544'
  AND organization_id = '01938b27-7df1-7c8a-9d3a-aabbccddeeff'
ORDER BY created_at DESC;

Backed by idx_audit_org_entity_time (organization_id, entity_type, entity_id, created_at DESC).

Principal activity:

sql
SELECT * FROM audit_log
WHERE actor_id = '01938b27-7df1-7c8a-9d3a-112233445566'
  AND organization_id = '01938b27-7df1-7c8a-9d3a-aabbccddeeff'
  AND created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC;

Backed by idx_audit_org_actor_time.

Failed requests:

sql
SELECT * FROM audit_log
WHERE status_code >= 400
  AND organization_id = '01938b27-7df1-7c8a-9d3a-aabbccddeeff'
  AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC;

Backed by idx_audit_status (status_code, created_at DESC).

Everything written during one break-glass session:

sql
SELECT * FROM audit_log
WHERE break_glass_id = '01938b27-7df1-7c8a-9d3a-77665544aabb'
ORDER BY created_at;

Backed by the partial index idx_audit_breakglass … WHERE break_glass_id IS NOT NULL. Remember this returns mutations only — see Reads are not audited.

Compliance Checklist

HIPAA

  • [x] All mutations are logged — 1A.1, mechanically enforced by cmd/check-audit-coverage in make check
  • [x] Audit logs are tamper-evident — no UPDATE/DELETE RLS policy; restartix_app UPDATE/DELETE/TRUNCATE revoked on parent and every leaf partition
  • [x] Failed access attempts are logged — 5xx, 403, and 401-with-bearer
  • [x] Audit table is partitioned for multi-year retention — 1A.15, monthly range partitions + forward-roll cron
  • [x] Break-glass is justified, scoped, time-bounded, and clinic-notified — 1B.11 / 1B.11.x
  • [x] A platform-wide audit read surface exists — GET /v1/audit-logs + Console pages
  • [ ] Read access logged inside break-glass sessions — no READ action exists; elevated GETs write nothing
  • [ ] Break-glass review workflow — no reviewed_at / reviewer / approver columns on break_glass_sessions; no second-approver flow; no review tooling. 000011 is applied to production — adding these needs a new migration plus per-env catch-up DDL.
  • [ ] 6-year warm-tier retention — no archival job, no purge, nothing drops a partition
  • [ ] Clinic-facing audit read endpoint — permission + RLS policy exist, handler does not
  • [ ] CSV export
  • [ ] Scheduled review procedure / compliance export — no workflow, no cmd/tools/audit-check

GDPR

  • [x] Record of processing activities (ROPA) maintained in audit_log — 1A.1
  • [x] Sensitive-key redaction in audit changes and in logs — 1A.5, shared predicate
  • [x] Column-level encryption live for auth_secret-class columns — 1A.3
  • [x] Column classification registry enforced per-PR — cmd/check-classification in make check
  • [x] Restore capability — 7-day RDS PITR + logical backup runner + a restore drill that ran green
  • [x] Break-glass transparency to the clinic (always-on admin notification, per-org RLS visibility)
  • [ ] DSAR export end-to-end — no route; only the internal/core/gdpr/anonymize.go helper, which has no caller
  • [ ] Erasure end-to-end — anonymise per Art. 17(3)(c) with the audit trail preserved
  • [ ] Pseudonymisation applied — helper ready, telemetry forwarder unbuilt
  • [ ] Patient regulated-PII encryption (CNP) — settled 2026-08-02, not built
  • [ ] Breach detection / alerting on audit patterns — no rule, no table (breach_records does not exist), no anomaly pipeline
  • [ ] Retention automation — see above

Open items map to Phase 2 (F11 compliance hardening) in platform-completion.md, which is authoritative for sequencing.