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) |
| 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 | 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¶
- Enroll beneficiary with email on a program (org UI or API).
- Confirm SendGrid delivery (subject
{programName}: You've been enrolled). - Confirm
coordination_notificationsrow (kind=veteran_enrolled, audience=veteran). - 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