Onset → LastVet Sync Contract¶
Purpose¶
Define the wire-level integration between Onset (iOS / Supabase) and LastVet (last.vet-api) that lets a veteran's protocol, adherence, and shared lab summary appear inside their LastVet record so LastVet can route it to their providers.
Roles¶
| Role | Identity | Where it lives |
|---|---|---|
| Veteran (patient) | Onset patient app user; LastVet veteran record | iOS, onset-api (R940), LastVet Postgres |
| Onset client | Registered OAuth client of LastVet, identified by client_id = "onset-ios" and a confidential client secret (or PKCE for the mobile half of the flow) |
LastVet oauth_clients table (new) |
| LastVet API | last.vet-api Express server |
Resource server for /api/v1/me/sync |
The veteran is the actor on every sync. There is no third-party provider, no Onset clinician, no admin in this loop.
Connect flow (one-time, per device)¶
The veteran taps "Connect to LastVet" in Onset Settings. We use OAuth 2.0 Authorization Code + PKCE with OIDC, with LastVet hosting the consent screen. The shipped redirect URI is the custom scheme onset://lastvet/callback (HTTPS Universal Link fallback on theonset.app remains registered).
Authoritative spec for the connect flow, token shapes, refresh rotation, threat model, and JWT claim formats: LASTVET_IDENTITY_BRIDGE.md. This document references that doc rather than duplicating it.
High-level shape:
Onset iOS LastVet API / Web
| |
|-- ASWebAuthSession ----->| GET /oauth/authorize
| | ?response_type=code
| | &client_id=onset-ios
| | &redirect_uri=onset://lastvet/callback
| | &scope=openid profile protocol.write adherence.write labs.write
| | &state=<random>&nonce=<random>
| | &code_challenge=<sha256(verifier)>&code_challenge_method=S256
| |
| | veteran auths to LastVet, sees consent screen, taps Allow
| |
|<- 302 custom scheme -----| onset://lastvet/callback?code=...&state=...
| |
|-- POST /oauth/token ---->| authorization_code grant + code_verifier
| |
|<-- access_token (1h) ----|
| refresh_token (90d) |
| id_token (OIDC) | ← contains verified veteran identity claims
| scope |
After this, Onset:
- Verifies the id_token (signature, iss, aud, nonce, exp).
- Stores tokens and identity claims in iOS Keychain (WhenUnlockedThisDeviceOnly). No tokens are stored in Supabase.
- Upserts patient_aggregator_connections in Onset Supabase: (patient_app_user_id, aggregator='lastvet') → status='connected', external_subject_id, scopes, connected_at.
The id_token's verified claims are the veteran identity Onset uses for display: sub (LastVet veteran UUID), name, https://last.vet/claims.veteran_verified, .branch, .rank. Onset never stores or computes a separate is_veteran flag — see LASTVET_IDENTITY_BRIDGE.md.
Sync cadence¶
Onset pushes to LastVet on these triggers:
| Trigger | Payload | Notes |
|---|---|---|
| Connection established | full snapshot of current protocol + last-90-day adherence | Single batched POST. |
| Protocol changes (clinic update, custom edit, new assignment accepted) | new protocol snapshot | Diffs not enough — LastVet needs the full current state for the "Supplements" tab. |
| Daily, 22:00 local time | yesterday's adherence + today's pending items | Single best-effort job; failures retry next morning. |
| App foregrounded after >24h gap | catch-up since last_synced_at |
Bounded: 30 days max; older data is dropped on the floor. |
| Manual "Sync now" tap | full snapshot | Settings → Connected health records → LastVet → Sync now. |
| Connection revoked | none | Onset stops pushing immediately and tells LastVet to invalidate the refresh token. |
Idempotency: every item carries a stable source_key (see below). Duplicate syncs of the same item are no-ops on LastVet's side because of the (veteran_id, source, source_key) unique constraint that already exists in health_record_items.
Endpoint¶
POST https://api.last.vet/api/v1/me/sync
Headers:
Authorization: Bearer {access_token}
Content-Type: application/json
X-Onset-Client-Version: <Onset CFBundleShortVersionString>
X-Idempotency-Key: <UUID per request>
LastVet today exposes this route at last.vet-api/src/routes/health-record.ts:39. The required change there is in LASTVET_API_CHANGES_REQUIRED.md.
Payload shape¶
{
"categories": ["medications", "supplements", "behavioral", "labs"],
"syncedAt": "2026-05-20T22:00:00Z",
"items": [
{
"sourceKey": "onset:protocol:<protocol_id>:item:<item_id>",
"source": "onset",
"category": "supplements",
"eventType": "supplement_active",
"title": "Magnesium Glycinate",
"subtitle": "400 mg, evening, with food",
"occurredAt": "2026-05-20T19:00:00Z",
"code": null,
"codeSystem": null,
"valueText": "400 mg PO QHS",
"raw": {
"protocol_id": "<uuid>",
"protocol_revision": 7,
"protocol_name": "Recovery Protocol — Dr. Byrnes",
"practitioner_name": "Dr. Byrnes",
"clinic_protocol_id": "<uuid|null>",
"schedule_summary": "every day, evening, 400 mg",
"is_practitioner_authored": true,
"active_from": "2026-05-01",
"active_through": null
}
},
{
"sourceKey": "onset:adherence:<protocol_id>:<item_id>:2026-05-19",
"source": "onset",
"category": "behavioral",
"eventType": "supplement_intake_logged",
"title": "Magnesium Glycinate — taken",
"subtitle": "Evening dose, on time",
"occurredAt": "2026-05-19T19:14:00Z",
"valueText": "completed",
"raw": {
"protocol_id": "<uuid>",
"item_id": "<string>",
"item_name": "Magnesium Glycinate",
"scheduled_block": "evening",
"scheduled_for": "2026-05-19T19:00:00Z",
"completed_at": "2026-05-19T19:14:00Z",
"skipped": false
}
},
{
"sourceKey": "onset:lab:<lab_result_id>:<biomarker>",
"source": "onset",
"category": "labs",
"eventType": "lab_result",
"title": "Vitamin D, 25-OH",
"subtitle": "37 ng/mL (ref 30–100)",
"occurredAt": "2026-05-02T00:00:00Z",
"code": "1989-3",
"codeSystem": "http://loinc.org",
"valueText": "37 ng/mL",
"raw": { /* full Onset LabValue */ }
}
]
}
Field-by-field rules¶
source: always the literal string"onset"for items written by Onset. LastVet's/me/synccurrently overwritessourceto"va"; that needs to allow"onset"for Onset-issued tokens. See LastVet changes doc.sourceKey: opaque to LastVet, stable per Onset entity. Prefix schemeonset:<entity>:<id...>is mandatory so the namespace can't collide with VA imports. Must be deterministic — re-syncing the same logical item produces the samesourceKey.category: must be one of LastVet'sCONSENT_CATEGORIES. v1 usesmedicationsandsupplementsfor protocol items,behavioralfor adherence events,labsfor lab summaries shared via Onset,self_reportedfor daily journal entries (phase-gated, behind a toggle).eventType: free string up to 50 chars — used by LastVet to label the event in the timeline UI. Onset's vocabulary:supplement_active,supplement_discontinued,supplement_intake_logged,supplement_intake_skipped,lab_result,daily_checkin.occurredAt: when the thing happened in real life, not when it was synced. Forsupplement_active, that's the protocol activation date. Forsupplement_intake_logged, the timestamp the user marked it complete.raw: free-form JSON for Onset's own consumption later (e.g., re-rendering the protocol in a LastVet provider view). LastVet stores this verbatim. Do not put PHI here that wouldn't already be reconstructible from the other fields —rawis not separately scope-redacted.
What gets synced (share domains)¶
| Domain | Onset source | LastVet consent key | Payload hints |
|---|---|---|---|
| Protocol stack | Active UserProtocolItem rows |
onset_protocol_stack |
raw.stack_type = "protocol_stack" |
| Supplement routine | Same items, daily context | onset_supplement_routine |
raw.routine_kind = "daily" |
| Fitness routine | FitnessProtocol + weekly days |
onset_fitness_routine |
eventType: fitness_active, raw.stack_type = "fitness" |
| Adherence | ProtocolCompletion (90-day window) |
protocol_adherence |
category: behavioral, fitness ids fitness:{id}:{weekday} |
| Labs (optional) | LabResult when practitionerShareLabs |
labs_summary |
category: labs |
Stable sourceKey prefixes include onset:stack:, onset:routine:, onset:fitness:, and onset:adherence:.
What gets synced (legacy four categories section)¶
medications and supplements¶
Every active item in every active StoredProtocol for the patient. One health_record_items row per (protocol, item). Discontinuation produces a supplement_discontinued event with occurredAt = discontinuation date and subtitle describing why ("clinic update", "patient removed", "protocol ended"). Items currently held in titration / cycle phases are still included; the schedule summary in subtitle reflects the current phase.
behavioral¶
One row per logged completion (or explicit skip) of a protocol item. Use this category — not a custom one — because LastVet's existing consent UI already exposes "Check-in History" under behavioral and providers know how to read it. We do not back-fill historical adherence on first sync; we backfill the last 90 days only.
labs¶
Lab biomarker summaries already present in Onset that the patient has chosen to share with their practitioner (preferences.practitionerShareLabs). We do not auto-share Onset-imported labs that the patient has not flagged for sharing — labs require an extra explicit opt-in inside the LastVet connect flow, mirrored in Onset Settings.
self_reported (phase-gated)¶
Daily journal entries — sleep, energy, mood, pain. Off by default. Available as a toggle once the basic protocol/adherence sync is stable in production. Mentioned here for completeness; not part of v1 wave.
Auth on every sync¶
Each POST /me/sync carries a Bearer access token. The access token includes (as JWT claims):
- iss = "https://api.last.vet"
- aud = "lastvet-resource-server"
- sub = veteran_id (LastVet internal UUID)
- client_id = "onset-ios"
- scopes = ["protocol.write", "adherence.write", "labs.write"]
- exp = now + 1h
- jti = ...
LastVet's authMiddleware will need a JWT verification path that runs before the existing header-based bootstrap path. New x-user-type value "system_onset" (or simply: trust the JWT's client_id and bind req.veteranId = sub). See LastVet changes doc.
Refresh and revocation¶
- Access tokens TTL 1 hour. Refresh tokens rotate on use, TTL 90 days.
- A successful sync that returns a 401 triggers Onset to attempt one refresh; on failure, Onset surfaces "LastVet connection expired — reconnect" in Settings.
- Patient-initiated revoke from Onset: Onset calls
DELETE /api/v1/oauth/tokenwith the refresh token, sets localconnection_status = revoked, stops scheduling syncs. The LastVet side invalidates the token immediately and emits aconnection_revokedaudit event. - Patient-initiated revoke from inside LastVet (via the existing "Connected apps" surface or a new one): LastVet invalidates the token. Onset's next sync gets a 401 on access and an
invalid_granton refresh; Onset transitions torevoked_remotelyin Settings and stops trying.
Failure handling¶
| Status | Onset behavior |
|---|---|
| 200 | record last_synced_at, count synced items |
| 400 (validation) | log full error to internal telemetry, do not surface to patient unless three consecutive failures (then: "There was a problem syncing — we're looking into it.") |
| 401 (token expired/invalid) | refresh once, retry once, else surface "Reconnect" |
| 403 (scope/consent) | mark connection as needing reauthorization |
| 409 (idempotency replay) | accept as success |
| 429 | exponential backoff, max 1h |
| 5xx | retry next scheduled sync; do not surface |
Audit¶
Every sync attempt — success or failure — writes one row to Onset's data_share_events table (see PROVIDER_AUDIT_AND_PATIENT_ACCESS_HISTORY.md). LastVet writes its own event to its existing audit log. The patient sees both surfaces:
- In Onset Settings → Connected health records → LastVet → "Activity": last 90 days of Onset-side sync attempts and outcomes.
- In LastVet's existing "Where my data went" view: aggregated cross-source.
Veteran-facing display (LastVet iOS)¶
Synced Onset data is stored in health_record_items with source = 'onset'. Veterans read their own copy via:
GET /api/veteran/me/onset-summary
Authorization: veteran session (same as other `/api/veteran/me/*` routes)
Response buckets mirror the provider OnsetIntegrationCard: protocolStack, supplementRoutine, fitnessRoutine, adherenceSeries, labs, plus itemCount, syncedAt, and completion rates.
LastVet iOS Your Record placement:
| UI surface | Purpose |
|---|---|
| Onset category pill (top row) | Dedicated read-only view with sub-tabs: Protocol stack, Supplement routine, Fitness routine, Adherence, Labs |
| Onset source filter (same row as All / VA / LastVet / Provider) | Filters Timeline to Onset-sourced adherence entries |
| Onset status chip | Shows item count + last sync time (separate from the LastVet self-reported chip) |
Onset data is not mixed into the LastVet (platform) source filter — that filter remains for veteran-entered self-reported and social entries only.
Providers continue to use GET /api/v1/veterans/:id/onset-summary (consent-gated) and the provider portal Onset card.
What this contract does NOT do¶
- It does not let LastVet write back into Onset. The provider portal in LastVet can leave clinical notes, but those stay in LastVet — Onset doesn't read LastVet.
- It does not let providers register on Onset. Providers continue to use LastVet's existing portal.
- It does not export the patient's full Onset data set. Only the four categories listed above.
- It does not replace the existing practitioner→patient adherence push (
shared_adherencetable on Onset's clinic side). That continues to operate independently.
Open questions tracked elsewhere¶
- iOS implementation details:
LASTVET_INTEGRATION_PLAN.md. - LastVet-side migrations and code changes:
LASTVET_API_CHANGES_REQUIRED.md. - Patient-facing UI copy:
LASTVET_CONNECT_AND_REVOKE.md. - Audit table schema:
PROVIDER_AUDIT_AND_PATIENT_ACCESS_HISTORY.md.