Skip to content

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 by last1_identity_id. Callers pass occurred_at. RPC does not stamp now(). Idempotent on (last1_identity_id, channel, occurred_at).
  • veteran_radio_check_current: latest occurred_at per identity wins. A later different answer in the same period is kept.
  • veteran_radio_check_sms_dedup: Twilio MessageSid only.
  • veteran_radio_check_settings: IANA timezone, enabled, frequency (daily | weekly | monthly | semiannual), day_of_week 0-6, day_of_month 1-28, anchor_month 1-6, hour_local, minute_local. Default weekly, PT Sunday 10:00. LastVet cadence_days stays 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/:id body allowlist response, channel, occurred_at (cycle_key ignored; 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/:id body allowlist timezone (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-check body { response }. Same answer this period returns unchanged: true. A different answer appends.
  • PUT /api/member/radio-check/settings uses 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_current and does not write a channel.