Skip to content

Thread: I3b-4 — Coordination notification system

Master contract for coordination notifications (email + iOS push + in-app, veteran ↔ org). Supersedes I3b-4a-coordination-push-contract.md (4a push primitive remains valid as a delivery adapter slice).

Decisions (Ryan, Sep 14 2026)

Decision Choice
Architecture One event source (coordination state change) → fan-out to channels/recipients
Drift discipline Events off server projection changes (prev-vs-new), never client inference
PHI-safe copy programName + headline only on ALL channels (email, push, in-app)
Email last1.app SendGrid only (no duplicate on last.vet-api)
Org email audience (WIN v1) All org staff with program-coordination access
Enrollment push Email + in-app always; push only if tokens exist
Veteran email CTA (v1) https://last.vet + "open LastVet if installed"
Slice order Enrollment-first: 4b → 4c → 4d → (ops gate) → 4a ✅ → 4e → 4f → 4k prefs → 4g–4j
Notification prefs Veteran-controlled: per-type × per-channel; dispatcher reads store before fan-out (4k)
Deploy target R940 (VM 104 last1.app + VM 90 last.vet-api for push adapter)

Notification matrix

Event Notify Email iOS push In-app
Veteran enrolled Veteran ✅ ⬜ if tokens ✅
Org advances Ω (veteran turn) Veteran ✅ ✅ ✅
Veteran acts (org turn) Org staff ✅ n/a ✅

Architecture

last1.app write (enroll, org advance, veteran act)
  → re-project Ω / turnCard
  → detect state change (prev vs new)
  → CoordinationNotificationEvent + dedup
  → preference gate (4k): skip channel/type veteran opted out
  → fan-out:
       email (SendGrid)
       in-app (coordination_notifications row)
       push → POST last.vet-api (4e+, veteran turn only)

last.vet-api
  → APNs sendCoordinationTurnNotification (4a, shipped)
  → GET /veteran/me/notifications BFF (4g)

Event model

type CoordinationNotificationKind =
  | "veteran_enrolled"
  | "veteran_turn_opened"
  | "org_turn_opened";

type CoordinationNotificationEvent = {
  kind: CoordinationNotificationKind;
  organizationId: string;
  programId: string;
  programName: string;
  beneficiaryId: string;
  headline: string;
  last1IdentityId?: string | null;
  eventId?: string | null;
  veteranTurnAction?: string | null;
  orgTurnAction?: string | null;
  stateSeq?: number | null;
  occurredAt?: string;
};

Dedup keys

Kind dedupKey
veteran_enrolled {beneficiaryId}:{programId}:enrolled
veteran_turn_opened {beneficiaryId}:{eventId}:{veteranTurnAction}:{stateSeq}
org_turn_opened {beneficiaryId}:{eventId}:{orgTurnAction}:{stateSeq}

Delivery dedup: (dedupKey, channel, recipientKey) in coordination_notification_deliveries.

PHI-safe copy allowlist

Field Source Max
programName program.name 120
headline turnCard.headline or fixed enrollment copy 200

Blocked: URLs in copy fields, file extensions, dialogue markers, quotes/newlines. Email CTA URL is template-only (not in headline).

Enrollment fixed headline: You've been enrolled.

Repo split

Piece Repo
Event bus, dedup, email, in-app store last1.app
APNs delivery last.vet-api (4a)
Veteran bell + push UX last.vet-ios (4g, 4i)
Org bell last1.app org UI (4h)

Slice map

Slice Scope Gate
4a ✅ APNs primitive + internal test send Sandbox push landed (Sep 15 2026)
4b ✅ Event model + DB + dedup + dispatcher skeleton + emit-test Unit tests + golden event
4c ✅ PHI-safe copy builder + SendGrid email channel Golden email fixtures
4d ✅ Enrollment hook → email + in-app Ops: enroll WIN vet → email arrives
4e Veteran turn trigger + push hook to last.vet-api Org advance MOCK → push
4f Org turn trigger → org email + in-app Veteran act → staff notified
4k Notification preferences (veteran-controlled) Settings toggles + dispatcher respects opt-out
4g Veteran in-app center (LastVet bell + BFF) Device: bell → program
4h Org in-app center Org login → bell
4i iOS push permissions + deep link Background push opens detail
4j Full curl gate + CI fixtures Automated contract gate

4k placement: After delivery channels work (4e push + 4f org-notify), before calling #4 done (4j). Cannot ship multi-channel notify without veteran control surface.

4k — Notification preferences (design)

Principle: Veteran-controlled notifications, consistent with veteran-controlled data. One global on/off is too blunt for multi-channel × multi-type coordination.

Settings surface: LastVet iOS Profile → Notifications (BFF to preference API).

Matrix (type × channel):

Type push default email default in_app default
your_turn on on on
enrollment on on on
program_updates off off on
Future marketing-ish types off off off

Map event kinds → preference type: veteran_turn_opened → your_turn; veteran_enrolled → enrollment; org-facing types use org prefs later (4h scope).

Store: Per veteran (last1_identity_id or linked veteran id), rows keyed (veteran_id, notification_type, channel) → enabled boolean. Source of truth in last1.app (dispatcher lives there). last.vet-api holds a read-through copy or BFF proxy for iOS Settings GET/PATCH.

Dispatcher rule (binding): Before each channel send in coordinationNotificationDispatcher, call isChannelEnabled(veteranId, preferenceType, channel). If false: skip send, log delivery_skipped_preference, do not write delivery row as sent. Opt-out is enforced at fan-out, not UI-only.

Out of scope for 4k v1: Org staff notification prefs (defer to 4h); SMS; marketing campaigns.

Internal ops routes (last1.app)

Auth: Authorization: Bearer $LAST1_APP_SECRET

POST emit-test (4b+)

POST /api/internal/coordination/notifications/emit-test
Body: CoordinationNotificationEvent (JSON)
→ { emitted, dedupKey, notificationId?, deliveries[] }

4d enrollment ops test

  1. Enroll beneficiary with email on a program (org UI or API).
  2. Confirm SendGrid delivery (subject {programName}: You've been enrolled).
  3. Confirm coordination_notifications row (kind=veteran_enrolled, audience=veteran).
  4. Re-enroll same beneficiary/program → dedup (no second email).

4a cross-reference

Push payload golden: last.vet-api/tests/fixtures/coordinationTurnPush.golden.json

APNs import: lastvet-gates/scripts/lastvet/import-lastvet-apns-p8.sh