Imported from mniarc/mercato-hackon (
ai-company/packages/core/src/modules/push_notifications/AGENTS.md). Install upstream withnpx skills add mniarc/mercato-hackon --skill push_notifications. Copyright stays with the author.
push_notifications Module — Agent Guidelines
Push delivery rails. Owns the delivery log, the push notification delivery strategy, and the
send-push worker. It does not own device tokens (that's devices), per-user opt-out (that's
notifications), or provider credentials/transport (that's the communication_channels hub +
FCM/APNs/Expo channel packages). Spec: .ai/specs/2026-04-28-push-notifications-and-devices.md (Module 3).
Architecture
- Strategy (
lib/push-delivery-strategy.ts) registers via the notificationsdelivery-strategiesgenerator plugin (exportdeliveryStrategiesfromnotifications.delivery-strategies.ts). It runs inside the persistentnotifications:deliversubscriber and only enqueues — the actual send happens in the worker so a slow provider never blocks notification creation. - Worker (
workers/send-push.worker.ts→lib/push-delivery.ts) atomically claims the row (pending→sending, so a redelivered at-least-once job is processed once), resolves the tenant pushCommunicationChannel+ hub adapter (channelAdapterRegistry) + creds (integrationCredentialsService) and callsconvertOutbound→sendMessage— thecommunication_channelstest-sendflow. Retries transient failures with exponential backoff + jitter (3 attempts, shared@open-mercato/shared/lib/delivery/retry), recordsnext_retry_at, and marks the rowexpiredonce retries are exhausted (vsfailedfor terminal errors); on theunregisteredsentinel soft-deletes the device. - Queue (
lib/queue.ts) mirrors the webhooks queue:createModuleQueue+enqueuePushDelivery. It is enqueue-only in BOTH strategies — the consumer is alwaysworkers/send-push.worker.ts, run by a worker process (async) or the local worker runner /drainIntegrationQueue(local). Never boot a consumer from the enqueue path: the local strategy'sprocess()only returns after its first drain, so awaiting it runs the send inside the enqueueing request, and a retryable send re-enqueues from within that handler and deadlocks on its own bootstrap. - Reaper (
lib/push-reaper.ts→workers/reclaim-stuck.worker.ts) recovers rows stranded insendingby a crashed worker — the send-path claim only matchespending, so such a row has no outstanding job and would never terminate. A per-tenant@open-mercato/schedulerinterval entry (registered best-effort insetup.ts, mirroring thecommunication_channelspoll-tick) fires the tick; rows still insendingpastOM_PUSH_STUCK_RECLAIM_MINUTES(default 5) are re-opened + re-enqueued when attempts remain, else finalizedexpired. Each transition is an atomicnativeUpdateguarded onstatus='sending'+ still-staleupdated_at, so overlapping ticks or a worker that re-claimed the row never re-open an active delivery. The per-tick scan is batch-bounded byOM_PUSH_STUCK_RECLAIM_BATCH_LIMIT(default 500, oldest-stuck first) so a stranded backlog from a provider/queue outage cannot load an unbounded row set into memory in one tick — the remainder drains on subsequent ticks (mirrors the receipt reaper'sOM_PUSH_RECEIPT_BATCH_LIMIT). - Fan-out (
lib/push-fanout.ts,fanOutPushDeliveries) is the shared device-resolution + provider routing + delivery-row insert + enqueue. The strategy (visible notifications) andsendCustomPushcall it; it stays preference-agnostic. Its channel/device short-circuits (no push channel / no devices / no provider match →{ enqueued: 0 }) are push's technicalisConfiguredequivalent and remain the authoritative "is push set up for this tenant/recipient" check. - Opt-out is enforced upstream, once (Phase 7). The
pushstrategy no longer callsisChannelEnabled/checksnonOptOut; the notifications create-time gate (shouldDeliver) already resolved per-channel opt-out intonotification.channels, and the dispatcher only invokes this strategy whenpush ∈ channels. The strategy still reads the type forsilent(delivery style). - Silent push is not a separate API — it is just a notification whose type is declared
silent: true(NotificationTypeDefinition.silent), created through the normalnotificationService.create()flow (create()→notifications:deliversubscriber → thepushstrategy). The strategy derivessilentfrom the type, sends a content-available (data-only) push, and skips user-facing copy; the in-appNotificationrow is still created and per-channel preferences still apply (now enforced by the notifications create-time gate, not the strategy) — to make a silent type always fire, declare itnonOptOut: true. There is nosendSilentPushhelper. - Admin custom push (
lib/send-custom-push.ts, exposed in DI aspushNotificationService) is a one-off visible push with literal title/body that fans out directly (no in-app row, no email, no preference check); it backsapi/custom-send/route.ts. - Flexible payload. A notification's optional
data(arbitrary app-readable map, also exposed to in-app clients) andpushOptions(flatsound/badge/image/priority/channelId/bodymap, both from thenotificationsmodule) ride the push enveloperaw. The adapters mappushOptionsonto each provider's native message and branch onsilent; seecommunication_channels/lib/push-envelope.ts(PushOptions,readPushEnvelope,resolvePushBody).
Always
- Never export
OM_PUSH_FAKE_PROVIDERSby hand and pointTC-PUSH-004+/TC-CHANNEL-PUSH-005..007at a live server that does not itself have it. Their.meta.tsgate skips them when the flag is absent from the test process, which is the protection you want; exporting it defeats that gate. The fake swaps the provider SDK client indi.tsregister(), so whichever process claims the delivery job must have the flag — a worker without it sends against the real provider, and on real credentials that means a real push to the recipient's real devices. Start the worker (and the server) withOM_PUSH_FAKE_PROVIDERS=1, or use the ephemeral harness, which sets it for both the app and the drain child. - Resolve cross-module entities (
UserDevice,CommunicationChannel) via DI tokens (ctx.resolve(...)), not import-time references, to stay decoupled. - Keep
push_tokena secret: persist onlyprovider+ last-8token_snapshot; never expose a full token in any API/UI/log. - Soft-delete an
unregistereddevice through thedevices.user_devices.deactivatecommand (system ctx:auth: null, systemActor: true) — never mutate thedevicestable directly. - Keep the
unregisteredsentinel identical across provider adapters (result.metadata.unregistered === trueorresult.error === 'device_unregistered') so the worker's soft-delete fires uniformly. - Keep the delivery log append-only (status transitions only); it is intentionally optimistic-lock-exempt.
- To send a silent push, declare the notification type
silent: true(in its module'snotifications.ts) and create it via the normalnotificationService.create()— thepushstrategy turns it into a content-available wake-up. Do not add a bespoke silent-send path.
Never
- Never add token-management or self-serve notification CRUD here (device fields live in
devices). - Never introduce a
PushProviderinterface — the hubChannelAdapterregistry is the provider seam; real providers are separatechannel-*packages (Phase 4).
Validation
yarn workspace @open-mercato/core test -- push_notifications
yarn workspace @open-mercato/core build