Skip to content

LastVet Connect and Revoke (Patient-Facing UX + Data Model)

Purpose

Define the patient-facing flow for connecting Onset to LastVet, the data Onset stores about that connection, and the revoke paths from either side. Companion to ONSET_TO_LASTVET_SYNC_CONTRACT.md.

This is the v1 patient-sharing surface on Onset. There is intentionally only one aggregator and one set of scopes. We are not shipping a generic "connected apps" framework.

Principles

  • Patient-initiated only. Nothing connects automatically. Even after the veteran identifies themselves as a veteran in onboarding, we never auto-prompt; the connection card is visible but inert until tapped.
  • Per-domain share toggles. After connecting, the veteran chooses protocol stack, supplement routine, fitness routine, adherence, and optional labs summary in Onset Integrations. Provider scoping happens on the LastVet side via Onset consent subcategories.
  • Revocable from either side, idempotent. Disconnecting from Onset and disconnecting from inside LastVet must both terminate the connection cleanly. Re-connecting later is a fresh OAuth flow.
  • Fully visible. The patient can always see: that the connection exists, what's been synced, when, with what outcome, and a one-tap disconnect.

When the connect surface appears

The "Connected health records" section appears in Onset Settings on iOS for all users. The LastVet card lives under a "Veteran tools" subheader; civilians see the same card but learn from the copy ("for veterans with a LastVet account") that it doesn't apply to them.

We do not gate the card on an is_veteran self-attestation. Onset has no such field today and will not be adding one — see LASTVET_IDENTITY_BRIDGE.md for the rationale. Veteran verification is owned by LastVet (VA Lighthouse on their side); a successful OAuth into LastVet IS the verification, and civilians who tap Connect simply can't get past LastVet's authorize screen.

A static non-veteran hint sits below the LastVet card: "Not a veteran? Ask your provider to sign up at clinic.theonset.app to see your protocol there." It's text, not a CTA — see ADR-002.

States

                disconnected
                     │
                tap "Connect"
                     │
                     ▼
                connecting           ← OAuth dance in ASWebAuthSession
                     │
            ┌────────┴────────┐
            │ deny / fail     │ allow / success
            ▼                 ▼
       disconnected        connected
                              │
                  ┌───────────┼───────────────┐
                  │           │               │
            tap revoke   refresh fails   sync 403
                  │           │               │
                  ▼           ▼               ▼
            revoking     reconnect_required
                  │
                  ▼
            disconnected

connected is the only state that runs background syncs. reconnect_required is shown when LastVet has invalidated our refresh token (revoke from inside LastVet, or 90-day expiry); the patient sees the same "Connect" CTA as a fresh user, with copy: "Reconnect to LastVet to keep sharing."

Onset-side data model

Table: patient_aggregator_connections

Lives in Onset Supabase. One row per (patient, aggregator). v1 only ever has aggregator = 'lastvet'.

CREATE TABLE patient_aggregator_connections (
  id                       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  patient_app_user_id      UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  aggregator               TEXT NOT NULL CHECK (aggregator IN ('lastvet')),
  status                   TEXT NOT NULL
                            CHECK (status IN ('connected','revoked_local','revoked_remote','reconnect_required')),
  external_subject_id      TEXT NOT NULL,                -- LastVet veteran_id
  scopes                   TEXT[] NOT NULL,
  connected_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_synced_at           TIMESTAMPTZ,
  last_sync_outcome        TEXT,                          -- 'success' | 'failed:<reason>'
  revoked_at               TIMESTAMPTZ,
  revoked_reason           TEXT,
  UNIQUE (patient_app_user_id, aggregator)
);

Refresh and access tokens are NOT stored in Supabase. They live on the device in iOS Keychain, accessible only to the app, behind a kSecAttrAccessibleWhenUnlockedThisDeviceOnly attribute. This is deliberate: we don't want a cloud-stored token that survives device loss without explicit re-authorization.

Implication: connecting on a new device requires a fresh OAuth dance. Acceptable; matches the existing "Apple Sign-In on a new device requires re-auth" pattern.

iOS Keychain entries (per connected device)

All entries are stored with kSecAttrAccessibleWhenUnlockedThisDeviceOnly so they don't sync via iCloud Keychain and don't survive device transfer.

  • lastvet.refreshToken — opaque 32B base64url, rotating, 90d TTL. Never logged.
  • lastvet.accessToken — JWT, 1h TTL, refreshed in-flight as needed.
  • lastvet.idToken — JWT, OIDC identity assertion. Verified once on receipt; thereafter Onset reads only the cached identity claims below.
  • lastvet.veteranId — LastVet's UUID for this veteran (sub claim from id_token).
  • lastvet.identity — JSON blob with cached id_token claims: { name, veteran_verified, service_data_source, branch, rank }. Used by Settings to render the connected-state card.
  • lastvet.connectedAt, lastvet.scopes — for display in Settings.

Transient (consumed once per connect attempt, deleted on success or failure): - lastvet.pkce.codeVerifier - lastvet.pkce.state - lastvet.pkce.nonce

Connect flow (iOS UX)

Step 1: Card in Settings

┌──────────────────────────────────────────────────────┐
│  Connected health records                            │
│  ─────────────────────────────────────────────       │
│  Veteran tools                                        │
│                                                       │
│  LastVet                                              │
│  For veterans with a LastVet account. Push your       │
│  protocol and adherence to your LastVet record so     │
│  your providers can see them.                         │
│                                                       │
│                                          [ Connect ] │
│                                                       │
│  Not a veteran? Ask your provider to sign up at       │
│  clinic.theonset.app to see your protocol there.      │
└──────────────────────────────────────────────────────┘

Visible to all users. The copy makes the audience clear without us gating on a self-attested veteran flag. Civilians who tap Connect will be stopped at LastVet's authorize screen because they don't have a LastVet account.

Step 2: Pre-flight explainer (one screen, dismissible)

Plain language. No legal copy here — that lives on the LastVet consent screen. This screen is purely so the veteran knows what they're about to be asked.

Connect Onset to LastVet
─────────────────────────

What we'll share
  • Your active supplement protocol
  • Whether you took each item (last 90 days, then daily)
  • Lab results you've already chosen to share with your practitioner

What we won't share
  • Your daily journal entries (you can turn that on later)
  • Anything from Onset that isn't on this list

Where it goes
  Into your LastVet record. From there, you control which
  providers see it inside LastVet.

You can disconnect at any time, from either app.

                                    [ Cancel ]  [ Continue ]

Step 3: ASWebAuthSession to LastVet's /oauth/authorize

The veteran lands on LastVet's hosted consent screen — LastVet handles its own auth (email + password, or Apple Sign-In on LastVet's side). LastVet renders the "Onset wants permission to..." screen with the canonical scope list.

Step 4: Result screen in Onset

On success: card flips to the connected state; Onset queues a full initial sync; success toast. On denial or error: card stays disconnected; light error message: "We couldn't connect. Try again later."

Connected state card

┌──────────────────────────────────────────────────────┐
│  LastVet                       [ ●  Connected ]      │
│  SGT, Army  ·  ✓ Verified veteran                    │
│  Connected May 20, 2026                               │
│  Last sync: 2 hours ago — 14 items synced             │
│                                                       │
│  [ Sync now ]   [ View activity ]   [ Disconnect ]   │
└──────────────────────────────────────────────────────┘

Identity line ("SGT, Army · ✓ Verified veteran") comes from the cached id_token claims: - Rank + branch from https://last.vet/claims.rank and .branch if present; omitted otherwise. - "Verified veteran" badge shown only when https://last.vet/claims.veteran_verified == true. Unverified veterans (LastVet account exists but VA Lighthouse hasn't completed) get a connected card without the badge — Onset doesn't second-guess LastVet's verification state.

Tapping "View activity" opens data_share_events for this connection — last 90 days, scrollable list, each row showing timestamp / category / item count / outcome. See PROVIDER_AUDIT_AND_PATIENT_ACCESS_HISTORY.md.

Revoke flows

From Onset

"Disconnect" tap → confirmation alert ("Disconnect from LastVet? Your data already in LastVet stays there. New changes won't be shared until you reconnect.") → on confirm, Onset: 1. Calls DELETE /api/v1/oauth/token on LastVet with the refresh token. 2. Deletes the Keychain entries. 3. Sets patient_aggregator_connections.status = 'revoked_local', revoked_at = now(), revoked_reason = 'user_disconnected_from_onset'. 4. Cancels any scheduled syncs. 5. Writes a connection_revoked row to data_share_events.

From inside LastVet

Veteran goes to LastVet's "Connected apps" surface and disconnects Onset. LastVet: 1. Sets revoked_at on the OAuth grant row. 2. Invalidates the refresh token. 3. Future Onset access-token requests fail with invalid_grant.

Onset's next sync attempt (or refresh attempt) returns 401/invalid_grant. Onset transitions: - status = 'revoked_remote' - Writes a connection_revoked_remotely row to data_share_events. - Stops all scheduled syncs. - Card flips to reconnect_required with copy: "LastVet ended this connection. Reconnect to keep sharing."

The two states are tracked separately because the UX text differs: revoked_local is "you did this" (no banner), revoked_remote is "they did this" (banner with reconnect CTA).

Re-connecting

Tap "Connect" again → fresh OAuth dance → fresh row in LastVet's oauth_authorization_grants. Onset upserts patient_aggregator_connections back to connected. Onset performs a full snapshot sync as if it were a brand-new connection — LastVet's (veteran_id, source, source_key) unique constraint dedupes any re-pushed items, so no duplicate timeline entries.

What this doc explicitly does NOT cover

  • The wire-level sync contract — see ONSET_TO_LASTVET_SYNC_CONTRACT.md.
  • LastVet-side migrations — see LASTVET_API_CHANGES_REQUIRED.md.
  • The audit table schema — see PROVIDER_AUDIT_AND_PATIENT_ACCESS_HISTORY.md.
  • Implementation timeline — see LASTVET_INTEGRATION_PLAN.md.

What this doc replaces

The earlier PROVIDER_CONSENT_MODEL.md, which described a generic per-scope consent grant catalog. v1 doesn't need that on the Onset side because the per-scope grants live on the LastVet side (consent_grants table, already in production). Onset's consent surface is a single connect/revoke per aggregator.