Skip to content

Onset Sharing Audit and Patient Activity History

Goal

Every outbound sync from Onset to LastVet — success or failure — is recorded immutably and is visible to the patient in plain language. v1 covers only the LastVet sync. The schema is general enough that a second aggregator (or the deferred civilian snapshot-share path) can plug in without a refactor.

This doc replaces the earlier PROVIDER_AUDIT_AND_PATIENT_ACCESS_HISTORY.md, which assumed a third-party-provider read API. There is no such API in v1 (see ADR-002), so the audit table is now patient-share-events, not provider-access-events.

Audit table

data_share_events

Lives in Onset Supabase. One row per sync attempt. Append-only. Never updated, never deleted.

CREATE TABLE data_share_events (
  id                       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  occurred_at              TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  patient_app_user_id      UUID NOT NULL REFERENCES auth.users(id),
  channel                  TEXT NOT NULL CHECK (channel IN ('aggregator_push')),  -- v1 only value
  aggregator               TEXT NOT NULL CHECK (aggregator IN ('lastvet')),       -- v1 only value
  external_subject_id      TEXT NOT NULL,            -- LastVet veteran_id
  event_type               TEXT NOT NULL,            -- see catalog below
  trigger                  TEXT NOT NULL,            -- 'connect_initial' | 'protocol_changed' | 'scheduled_daily' | 'foreground_catchup' | 'manual' | 'revoke'
  request_id               TEXT NOT NULL,            -- correlation id; matches X-Idempotency-Key
  http_status              INT,                      -- nullable for client-side-only events
  result_status            TEXT NOT NULL CHECK (result_status IN
                            ('success','failed_validation','failed_auth','failed_consent',
                             'failed_rate_limited','failed_server','failed_network','revoked')),
  categories_pushed        TEXT[],                   -- e.g. ARRAY['supplements','behavioral']
  item_count               INT,                      -- number of items in this push (success only)
  latency_ms               INT,
  error_message            TEXT,                     -- redacted; no PHI
  client_version           TEXT,                     -- Onset CFBundleShortVersionString
  ip_hash                  TEXT                      -- SHA-256 of source IP, never raw
);

CREATE INDEX idx_data_share_events_patient_recent
  ON data_share_events (patient_app_user_id, occurred_at DESC);

CREATE INDEX idx_data_share_events_request
  ON data_share_events (request_id);

event_type catalog (v1)

event_type When result_status values
sync_attempted Every POST to /me/sync, before result is known always paired with a follow-up sync_completed
sync_completed POST to /me/sync returned (any status) success, failed_*
connection_established OAuth dance succeeded; first sync queued success
connection_revoked Patient disconnected from Onset Settings revoked
connection_revoked_remotely LastVet returned invalid_grant on refresh revoked
token_refreshed Refresh token exchange succeeded success
token_refresh_failed Refresh exchange returned 4xx failed_auth

A successful sync produces two rows: sync_attempted then sync_completed. They share request_id. We pair them so a sync that hangs past timeout still leaves a forensic trail.

Write rules

  • Append-only. Trigger / RLS policy denies UPDATE and DELETE for all roles except a sealed ops_audit_admin role used only for retention enforcement.
  • One row per attempt, regardless of outcome. Failures are logged in detail so the patient can see "we tried and it didn't work" — silent failures are not acceptable.
  • error_message is sanitized server-side: known categories ("network unavailable", "LastVet rate limit", "consent revoked at LastVet") get fixed strings; raw stack traces / response bodies are not stored.
  • ip_hash is SHA-256 with a per-environment salt. Raw IPs never land in this table.

Patient-facing UI

Onset Settings → Connected health records → LastVet → "Activity"

Reverse-chronological list, last 90 days, grouped by day. Each row:

─── May 20, 2026 ──────────────────────────────────────────
  ●  Synced 14 items  ·  Supplements, Adherence  ·  10:00 PM
  ●  Synced 2 items   ·  Lab results            ·   3:14 PM

─── May 19, 2026 ──────────────────────────────────────────
  ●  Synced 12 items  ·  Supplements, Adherence  ·  10:01 PM

─── May 18, 2026 ──────────────────────────────────────────
  ⚠  Sync failed     ·  We'll try again tomorrow ·  10:00 PM

Tapping a row expands to show: - Trigger ("Daily background sync" / "You tapped Sync now" / "After protocol change") - Categories pushed - Outcome with a plain-language explanation if failed - Timestamp - Onset client version

We do NOT show: request IDs, IP hashes, raw HTTP statuses, or LastVet internal IDs. Those live in the table for engineering forensics; they don't belong in patient UX.

The activity list links to LastVet's existing per-grant access view (the one providers' reads land in). Copy: "See providers who have viewed this data on LastVet →". Deep links into LastVet iOS via Universal Link.

Retention

  • Online retention in data_share_events: 24 months.
  • Cold archive: 6 years (matches HIPAA retention for accounting-of-disclosures-equivalent records). Cold archive is encrypted, write-once, separate-credential-only access.
  • Retention enforcement runs nightly by the ops_audit_admin role; it moves rows older than 24 months to cold storage and removes them from the hot table.

Monitoring (engineering, not patient-facing)

Alerts: - Repeated failed_consent for the same patient_app_user_id within 24h → potential bug in revoke handling. - Spike in failed_server across many patients → LastVet outage; surface in our internal status. - Spike in failed_validation after a release → schema drift, likely a payload-shape regression. - sync_attempted with no matching sync_completed after 60s → hang detection. - Daily integrity check that connection_established rows have a successful initial-sync row within 60s.

Cross-side reconciliation

LastVet writes its own audit row on its side for every /me/sync it processes. Both sides should be reconcilable by: - request_id (Onset's X-Idempotency-Key is logged on LastVet's side). - Time window + external_subject_id.

A nightly job (LastVet side, optional) emits a count-mismatch alert if the two logs drift. Not a v1 must-have but cheap to add.

What this doc explicitly does NOT cover

  • LastVet-side audit (separate, lives in LastVet's existing audit_log table).
  • Provider read access on Onset — there is none in v1.
  • Civilian sharing — tabled, see ADR-002.

What this doc replaces

The earlier PROVIDER_AUDIT_AND_PATIENT_ACCESS_HISTORY.md, which assumed Onset would log inbound provider reads. We don't have inbound provider reads, so we log outbound patient pushes instead. Same intent (transparency, immutability, retention), narrower surface.