Veteran Radio Check contract (Option A, Step 5)¶
Approved Step 1 on 2026-09-20. Step 2 approved 2026-09-20: bidirectional write. Step 3 approved 2026-09-21: drop last1.app campaign SMS inbound. Step 4 approved 2026-09-21: linked LastVet taps write shared only. Step 5 approved 2026-09-21: cadence on last1app-db. lastvet tables stay.
SoT (linked): last1app-db veteran_radio_check_events / veteran_radio_check_current (VM 120, 192.168.1.103).
Fail-safe / unlinked (LastVet): lastvet-db radio_check_events (pending + unlinked taps + settings_updated) and radio_check_settings (local copy). Not dropped in Step 5.
Not SoT: org threads, unit check_ins, campaign instances, last1.app /member/check-in.
Record¶
veteran_radio_check_events: append-only. Keyed bylast1_identity_id. Callers passoccurred_at. RPC does not stampnow(). Idempotent on(last1_identity_id, channel, occurred_at).veteran_radio_check_current: latestoccurred_atper identity wins. A later different answer in the same period is kept.veteran_radio_check_sms_dedup: TwilioMessageSidonly.veteran_radio_check_settings: IANAtimezone,enabled,frequency(daily|weekly|monthly|semiannual),day_of_week0-6,day_of_month1-28,anchor_month1-6,hour_local,minute_local. Default weekly, PT Sunday 10:00. LastVetcadence_daysstays 7 for older readers. Reminders are not sent from this table.
Channel: sms | lastvet_os | radiocheck_app | last1_web. Veterans check in on LastVet, the RadioCheck app, SMS, or the last1.app member page.
Response: ok | struggling | need_support.
SMS / RadioCheck app map: all_good → ok; need_help → need_support; going_dark → struggling. Non-check-in does not stamp.
Crisis: payload allowlist response, cycle_key, channel only. No SMS body, notes, or crisis text. need_support does not flip LASTVET_CRISIS_LIVE_ENABLED.
cycle_key: in the veteran's timezone. daily YYYY-MM-DD, weekly YYYY-Www, monthly YYYY-MM, semiannual YYYY-H1 or YYYY-H2. Quiet means no answer in that period. The same answer again is not written. SMS webhook does not read lastvet-db.
Identity / unlinked gate¶
Phone is SMS lookup only. Resolve users.last1_identity_id, else user_profiles.last1_identity_id. If none: do not append shared; org/unit/LastVet-local writes still happen.
Unlinked LastVet tap: lastvet-db only. No last1.app HTTP. No 500. No wait.
Linked LastVet GET/POST uses veterans.last1_identity_id. No last1.app requireLinkedIdentity on Radio Check routes. Auth: LastVet session + Bearer LAST1_APP_SECRET.
HTTP (LastVet seam)¶
Host: https://last1.app + LAST1_APP_SECRET. Not Kong, not PostgREST, not lastvet-api → last1app-db.
GET /api/internal/radio-check/current/by-last1id/:id→{ last1IdentityId, timezone, enabled, frequency, dayOfWeek, dayOfMonth, anchorMonth, hourLocal, minuteLocal, lastResponse }POST /api/internal/radio-check/events/by-last1id/:idbody allowlistresponse,channel,occurred_at(cycle_keyignored; server computes from timezone, frequency, and occurred_at). A different answer in the same period appends. SMS still appends.PUT /api/internal/radio-check/settings/by-last1id/:idbody allowlisttimezone(required),enabled,frequency,dayOfWeek,dayOfMonth,anchorMonth,hourLocal,minuteLocal. Omitted frequency, day of month, or anchor month keeps the stored value.
RadioCheck app and sms-webhook stamp via RPC on last1app-db (same host as the data).
HTTP (member website)¶
Session auth on last1.app. The server resolves user_profiles.last1_identity_id and forces channel=last1_web.
GET /api/member/radio-check→{ linked, respondedThisCycle, response, channel, occurredAt, settings }. No identity:linked: false.POST /api/member/radio-checkbody{ response }. Same answer this period returnsunchanged: true. A different answer appends.PUT /api/member/radio-check/settingsuses the same settings allowlist as the internal PUT.
LastVet write order (Step 4)¶
Linked
1. If shared current this period has the same response: do not write. Return 201.
2. If shared current this period has a different response: HTTP POST the new response. Do not insert lastvet-db on HTTP ok.
3. If lastvet-db has the same response and shared does not: HTTP heal with that occurred_at.
4. HTTP down / other failure: insert lastvet-db, return 201 + sharedWrite: "pending", not 500.
Unlinked: lastvet-db only. Same answer this period does not insert another row. A different answer does.
SMS + a LastVet tap both exist on the shared ledger. Latest real occurred_at wins current.
Overlay¶
Linked GET uses shared cadence (enabled, dayOfWeek, hourLocal, minuteLocal, timezone) when last1.app HTTP succeeds. Unlinked and HTTP-down stay on lastvet-db settings. If linked and shared cycle_key equals LastVet currentCycleKey (computed in the shared timezone): shared current is SoT. respondedThisCycle=true, dueNow=false, lastResponse from shared (includes channel). lastvet events are not the linked current cycle. If shared GET fails, lastvet pending can still display.
SMS inbound¶
Twilio posts to RadioCheck https://api.last1.app/functions/v1/sms-webhook. last1.app POST /api/webhooks/sms is retired (410 sms_webhook_retired). Campaign instance rows are not SoT and are not updated from that URL.
Held¶
- DROP lastvet Radio Check tables (pending/heal and unlinked taps still live there).
- Slice-2 emit (wrong direction).
- LastVet SMS send, reminder pings, copying notes or program check-ins onto this record. Org Radio Check reads
veteran_radio_check_currentand does not write a channel.