Skip to content

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)

  1. Build 74+ iOS signed in as test Last1 ID; app registers APNs token (POST /veteran/me/devices/apns).
  2. VM 90: APNS vars restored; GET .../push/status → configured: true, useSandbox: true.
  3. POST .../push/test with MOCK program + headline → push arrives on device within seconds.
  4. 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)