Superseded by
I3b-4-coordination-notifications-contract.md(master #4 scope). This doc remains the 4a push-primitive reference.
Thread: iOS I3b-4a — Coordination push primitive (APNs)¶
Sub-slice 4a of I3b-4 (coordination notifications). 4b–4e wait for device gate on 4a.
Decisions (Ryan, Sep 14 2026)¶
| Decision | Choice |
|---|---|
| Slice order | 4a-first (APNs prod + push primitive). 4b (Ω turn trigger) waits for sandbox push device gate. |
| Device cohort | Last1 ID 2a805b21-5352-4316-bb4b-a5d4f02a8173, MOCK program fbf54f34-5e58-47ea-882b-f693c627b508. |
| Deploy target | R940 only (VM 90 last.vet-api). Not AWS/ECS. |
| Sandbox on prod VM | APNS_USE_SANDBOX=true during Xcode debug device gates (debug tokens use sandbox gateway). |
Problem¶
Veterans can act on coordination writes (3a/3b) but have no server-driven signal when org advances Ω. Slice 4a adds the PHI-safe APNs primitive and an internal test send path. Automatic turn-change delivery is 4b.
Architecture (4a only)¶
last.vet-api PushNotificationService.sendCoordinationTurnNotification
→ APNs alert (programName title + turnCard.headline body)
→ custom deepLink lastvet://coordination/programs/{programId}
→ coordination { programId, kind: "turn" } // no doc names, no dialogue
Internal ops (device gate):
POST /api/internal/coordination/push/test
→ resolve veteran (veteranId or last1IdentityId)
→ veteran_push_devices active iOS tokens
→ sendCoordinationTurnNotification
PHI-safe payload allowlist¶
Only these user-visible strings may appear in the APNs alert:
| Field | Source | Max |
|---|---|---|
aps.alert.title |
program name | 120 |
aps.alert.body |
turnCard.headline |
200 |
Blocked in copy (builder rejects): URLs, file extensions (.pdf, etc.), upload/dialogue tokens, quotes/newlines.
Full payload shape (golden fixture):
{
"aps": {
"alert": {
"title": "Demo Pipeline (MOCK)",
"body": "Ready for you to submit."
},
"sound": "default",
"badge": 1
},
"deepLink": "lastvet://coordination/programs/fbf54f34-5e58-47ea-882b-f693c627b508",
"coordination": {
"programId": "fbf54f34-5e58-47ea-882b-f693c627b508",
"kind": "turn"
}
}
Golden file: last.vet-api/tests/fixtures/coordinationTurnPush.golden.json
Env (VM 90)¶
| Var | Value |
|---|---|
APNS_TEAM_ID |
A5TF9D2CT7 |
APNS_KEY_ID |
From Apple .p8 |
APNS_PRIVATE_KEY |
Full .p8 PEM (escaped newlines OK) |
APNS_TOPIC |
vet.last.app |
APNS_USE_SANDBOX |
true for Xcode debug device gate |
Import to BWS:
bash lastvet-gates/scripts/lastvet/import-lastvet-apns-p8.sh /path/to/AuthKey_XXXXXX.p8
sudo restore-lastvet-api-env-from-bws.sh --env prod # on VM 90
sudo systemctl restart lastvet-api-prod
Internal routes¶
Auth: Authorization: Bearer $LASTVET_INTERNAL_ADMIN_SECRET (or LAST1_PROOF_DRAINER_SECRET fallback).
GET status¶
GET /api/internal/coordination/push/status
→ { "configured": true, "useSandbox": true, "topic": "vet.last.app" }
POST test send (4a device gate)¶
POST /api/internal/coordination/push/test
Body: {
"last1IdentityId": "uuid", // or veteranId
"programId": "uuid",
"programName": "string",
"headline": "string"
}
Success:
{
"ok": true,
"apns": { "configured": true, "useSandbox": true, "topic": "vet.last.app" },
"veteranId": "...",
"deviceCount": 1,
"results": [{ "tokenSuffix": "abcd1234", "ok": true, "status": 200 }]
}
Errors: 503 apns_not_configured, 404 veteran_not_found, 404 no_push_devices, 400 invalid_push_content.
4a device-test checklist (Ryan)¶
- Build 74+ iOS signed in as test Last1 ID; app registers APNs token (
POST /veteran/me/devices/apns). - VM 90: APNS vars restored;
GET .../push/status→configured: true,useSandbox: true. POST .../push/testwith MOCK program + headline → push arrives on device within seconds.- Payload on device shows program name + headline only (no doc names).
Out of scope (later slices)¶
| Slice | Scope |
|---|---|
| 4b | Turn-change trigger off Ω projection + dedup |
| 4c | In-app notification center |
| 4d | iOS push handling + permissions + deep link |
| 4e | Full contract + curl gate (extends golden + dedup rules) |