# Mobile push foundation — Phase 4 milestone 8

The staff app has explicit push opt-in and device disable controls under Notifications. Web and native notification details open the exact existing record editor after the server rechecks current notification eligibility. The existing write/approval rules still apply. Native push taps carry only an opaque delivery ID; the authenticated API resolves it to a currently eligible, unchanged record. The app never follows a URL or executes instructions supplied in a push payload.

**Local implementation only. Live delivery remains disabled.** No Expo project, push credentials, signing assets, phone permissions, device registrations or live sends were created during development. Compatible dependency patches are applied, but the full workspace audit still flags two upstream mobile-tooling issues; see DEPENDENCY-REVIEW.md before deployment.

## Opt-in and lifecycle

In a signed development/production app, press **Enable push on this phone**. The app checks that its EAS project matches the server before asking for OS permission and obtaining an Expo token. The server requires explicit consent, the matching project, an active staff session and a bounded iOS/Android token. Android uses the `staff-work` notification channel. Foreground banners/sounds/badges are suppressed; background alerts contain generic Phone Buzz text, with no company names, message text, amounts or IMEIs.

Tokens are encrypted at rest using a dedicated Fernet key, separately from MFA. API responses and aggregate diagnostics never expose token plaintext/ciphertext, session hashes or access credentials. One token has one current owner/session generation. Registration is idempotent within an active session. Another account cannot take over an actively subscribed token without the previous session ending or its owner disabling it. Re-registration after disable/new sign-in increments its generation; prior deliveries cannot target that new generation. Up to ten active registrations are allowed per account. Device settings list up to twenty registrations with active ones first.

Registrations are tied to the normal staff session lifetime (currently eight hours by default). Logout, expiry, account disable and security/role actions that revoke sessions prevent subsequent sends at the next sender check. Users explicitly enable push again for a new sign-in; this is not a long-lived refresh-token design. Reinstall/token rotation requires re-enabling push in this milestone. OS permission removal can also stop alerts before the provider reports it. Already-submitted alerts cannot be recalled. A revocation racing after the final sender check may still receive generic text; opening it requires fresh authorization.

## Durable queue and delivery meaning

A bounded sweep pages fifty device registrations and queues up to one hundred current unread source versions per device, including existing unread work at activation. Completed/reassigned/read/changed records and expired sessions are rechecked before sending. A unique device/generation/kind/source/version constraint prevents duplicate queue entries; atomic claims prevent simultaneous workers sending the same queued entry. A retry cannot create a new recipient or source. Marking an already-delivered version unread does not send it again.

Each pass processes at most thirty due sends/receipt lookups. Source updates may generate a new notification version; this is not a recurring due-date reminder, event-complete changelog or high-volume campaign system. Deployments with more than fifty devices must schedule each device page (`--device-offset 0`, `50`, etc.) fairly. Provider credentials are operator settings, not user input. Transport uses only fixed HTTPS Expo endpoints, finite timeouts, bounded responses, no redirects and no environment proxies.

States: `queued`, `sending`, `ticket` (Expo accepted), `handed_off` (receipt indicates APNs/FCM handoff), `failed`, `unknown`, `cancelled`. **Neither ticket nor handoff proves the device displayed or the person read the alert.** Read status changes only through the existing explicit centre controls.

Transient connection/rate/server failures retry with bounded exponential backoff, at most three send attempts. A timeout/malformed response after a potentially accepted send becomes `unknown`; interrupted sending claims expire to `unknown` after two minutes and are not automatically resent. Some explicit provider retries can still produce duplicates; no exactly-once remote delivery guarantee exists. Queued sends older than one hour are cancelled, and payload TTL is one hour. Receipts are first checked after fifteen minutes, with at most eight attempts and a 24-hour ceiling. A `DeviceNotRegistered` result disables only the matching device generation; an old receipt cannot disable a later registration. Receipt requests are read-only. Error storage uses bounded generic codes, never provider messages containing tokens.

## Configuration and operator commands

After hosting, dependency remediation, signing and push setup have been reviewed:

- Set `PB_PUSH_ENABLED=true` explicitly in the intended environment.
- Set `PB_PUSH_PROJECT_ID` to the real EAS project UUID and use the same `extra.eas.projectId` in the signed app configuration. No placeholder project was inserted.
- Set `PB_PUSH_KEY_FILE` to an absolute private file containing a generated Fernet key. Keep it outside the repository and back it up securely with the encrypted database. Do not use the MFA key. Key loss requires disabling and re-registering devices; rotation tooling is not yet supplied.
- Set `PB_PUSH_ACCESS_TOKEN` privately if Expo enhanced push security is enabled (recommended for deployment).
- Configure APNs/FCM credentials for the actual application identifiers and produce signed builds with the added `expo-notifications` plugin. Grant permission and test on physical devices before enabling staff delivery.

Both commands require `PB_DATABASE_URL` explicitly set in the operator environment to the intended database; they refuse to silently create/use a default database. From `platform/`, read-only inspection:

```sh
apps/api/.venv/bin/python scripts/inspect-push.py
```

Only after authorizing live activation, one bounded sending pass:

```sh
apps/api/.venv/bin/python scripts/run-push.py --send --device-offset 0
```

The command does nothing when push is unconfigured/disabled. No scheduler, automation or deployed worker was created. A supervised deployment must schedule bounded passes, cover all device pages, monitor `failed`/`unknown`/queue age, retain private logs and define database retention. Do not blindly reset unknown jobs to queued: the provider may already have accepted them.

## API and migration

- `GET /v1/push`: sanitized availability and this user's device settings.
- `POST /v1/push/devices`: explicit session-bound registration.
- `POST /v1/push/devices/{id}/disable`: owner-only disable.
- `GET /v1/push/deliveries/{id}`: recipient-only tap resolution, with generation/current-source checks.
- `GET /v1/notifications/{kind}/{id}/record`: current notification authorization followed by the existing record detail response.

Migration `a81da95be532` follows `f70c984ad421`, adding `push_devices` and `push_deliveries`. Existing business tables are unchanged; populated device data blocks destructive downgrade. Keep the existing PHP/MySQL website separate.

## Validation and remaining limits

Validation on 2026-10-10: 27 SQLite push/notification tests passed (two PostgreSQL-only skips); 31 distinct PostgreSQL push/notification/storage checks passed across the main and additional lifecycle runs, with concurrent initial enqueue/send also rechecked. All 21 shared contract tests and nine browser regression scenarios passed. Production web build, TypeScript and both native exports passed. Both local databases were backed up and migrated with all 47 existing tables unchanged and no push fixtures added.

API tests use generated temporary keys, synthetic records and mocked transports: opt-in validation, encryption, owner isolation, session/role revocation, source changes, read suppression, retries, uncertain outcomes, receipts, token invalidation, generation rollover and concurrent workers. PostgreSQL migration alignment and duplicate claims are checked. Contract tests reject arbitrary URL/instruction payloads. Browser regression covers exact inbox opening plus unchanged quotation/order/follow-up editors; TypeScript and both platform exports are checked. Native permission prompts, cold/warm taps, modal interactions, provider credentials and actual delivery still require physical-device tests.

Sources checked for this implementation: [Expo sending and receipt semantics](https://docs.expo.dev/push-notifications/sending-notifications/), [SDK 57 notifications API](https://docs.expo.dev/versions/v57.0.0/sdk/notifications/), [Expo push setup](https://docs.expo.dev/push-notifications/push-notifications-setup/).
