observe-RFC-002 — Koder Push (self-hosted push transport for koder-notify)
Author: Koder Engineering Date: 2026-05-30 Status: Accepted (ratified by owner 2026-05-30) Modules:
infra/observe/notify(owner — koder-notify gains apushchannel family + device registry substrate)services/ai/ai(consumer — run-lifecycle notifications, AICORE-144; device registry built ad-hoc there in part 1 consolidates here)services/foundation/id(consumer — Koder ID app push; the transportgate-RFC-001left open)engines/sdk/koder_kit(client — device registration + push receipt helpers for Flutter apps)
Origin: AICORE-144 push-provider decision + reuse-first discovery that koder-notify already exists and gate-RFC-001 punted the push transport.
1. Summary
Add push as a transport family to koder-notify (the existing notification gateway in infra/observe/notify), with a shared, tenant-scoped device registry as substrate and pluggable per-platform adapters:
| Surface | Adapter | Self-hosted? |
|---|---|---|
| Web / PWA | WebPush (VAPID) | ✅ fully — keypair we generate; no external account |
| Android | UnifiedPush (ntfy distributor) | ✅ self-hosted, opt-in (needs on-device distributor) |
| Android | FCM (Google) | ❌ last-mile is Google (default OS transport) |
| iOS | APNs (Apple) | ❌ last-mile is Apple (impossible to self-host) |
| App foreground | SSE stream (AICORE-143 fatia 4) | ✅ already done — no provider |
The relay, policy, device registry, and the WebPush leg are 100% Koder self-hosted (self-hosted-first.kmd). The native last-mile (APNs/FCM) is a platform constraint, not a Koder choice — captured here so the decision is explicit and durable.
2. Problem
- No push today.
koder-notifydelivers WebhookEmailSlackTelegramPagerDuty/Teams — all server→human-via-chat. There is no app→device push.
- AICORE-144 needs it. The Koder AI mobile surface (AICORE-143) wants
"push when a run finishes / needs input" — the Codex-mobile parity gap.
gate-RFC-001punted it. That RFC adds Koder ID app push to the authgate but declares the transport choice a non-goal ("lives in servicesfoundationid"). The decision was never made.
- Ad-hoc duplication risk. AICORE-144 part 1 already shipped a device
registry inside the Koder AI gateway (
services/ai/ai/gateway/internal/ devices). Left there, every component that wants push reinvents it (reuse-first.kmdviolation). It should be a shared substrate.
3. Goals
- Self-host the maximum — relay + policy + device registry + WebPush leg
are Koder code; only the native last-mile uses APNs/FCM.
- Pluggable adapters — adding/removing a transport doesn't touch callers.
⚠️ The existing
channel.Channelseam is alert-shaped (Type(),Send(*AlertPayload),Validate();AlertPayload= RuleNameMetricValue/ ThresholdSiteIDSeverity/Status) — built for ops alerts, not a generic user notification. Push (target user + title + body + run metadata) does not fitAlertPayloadcleanly. See §5.1 — generalizing the notification model is the main design cost of this RFC. - One shared device registry, tenant-scoped (
multi-tenant-by-default.kmd). - One send API for all consumers (AI runs, auth gate, future).
- PII-safe (
observability-first.kmd) — tokens are secrets; payloads carryno PII beyond what the user already sees.
4. Non-Goals
- Replacing APNs/FCM last-mile. iOS background push is APNs-only (Apple
platform constraint); Android default is FCM. We self-host the sender, not the carrier.
- Building the UnifiedPush distributor app. Pursued separately if/when
Android self-host is prioritized; this RFC only defines the adapter seam.
- Changing the existing koder-notify channels (emailslacketc.).
5. Architecture
consumer (AI gateway / auth gate / …)
│ POST /v1/notify { target_user, title, body, metadata } (X-API-Key)
▼
koder-notify
├─ resolve channels for target (config + user prefs)
├─ PushChannel:
│ ├─ DeviceRegistry.ListForTenant(user) → [{token, platform}]
│ └─ per device, dispatch to the platform adapter:
│ WebPush(VAPID) │ UnifiedPush │ APNs │ FCM
└─ deliveries ledger (existing SQLite) records per-device result- PushChannel dispatches to a tenant's devices. It cannot reuse
channel.Channel.Send(*AlertPayload)as-is (alert-shaped, ops-only — §5.1); it consumes the generalized notification type instead. - DeviceRegistry is a new
internal/devicespackage in koder-notify:Register/Deregister/ListForTenant, tenant-scoped, SQLite (matching notify's storage). Generalizes and absorbs the AICORE-144 part-1 store (same shape:{token, platform, koder_user_id, created/updated}). - Adapters live under
internal/channel/push/:webpush.go(VAPID),unifiedpush.go,apns.go,fcm.go. Each is nil/disabled until its config/creds are present (the gateway's established nil-by-default pattern).
5.1 Generalizing koder-notify's notification model (the real design cost)
koder-notify today is alert-centric: channel.Channel.Send(*AlertPayload) (RuleNameMetricValueThresholdSiteIDSeverityStatus) — "a monitoring rule fired", server→human-via-chat. A user-facing push ("your run finished", "approve this sign-in") has a different shape: { target_user/tenant, title,
body, deeplink, metadata{run_id,…} }. Decision point:
- (A) New
Notificationsupertype + parallelUserChannelseam(
Send(ctx, *Notification)). Existing alert channels untouched; push (+ future user-facing email/Talk) implement the new seam. Cleaner; minor dispatcher duplication. Recommended — additive, doesn't risk the live alert path (always-on.kmd). - (B) Generalize
AlertPayload→Notificationwith alert fields as anoptional sub-struct; migrate the 6 existing channels. One model, but invasive.
6. WebPush leg (the creds-free, fully self-hosted first slice)
- Generate a VAPID keypair once → store private key in
meta/context/credentials/(per credential posture), public key shipped to web clients. - Web/PWA client subscribes via the Push API → sends its subscription
(endpoint + p256dh + auth) to the device registry as a
webdevice. webpush.gosigns + POSTs the encrypted payload to the subscriptionendpoint. No Google/Apple account, no external service.
- This is the recommended Phase 1: proves the relay end-to-end with zero
external dependency, serving the Koder AI web surface.
7. Native adapters (creds required — capture tasks)
| Adapter | Credential needed | Capture |
|---|---|---|
| APNs (iOS) | Apple push key (.p8) + key id + team id | accounts.md Apple Developer; store key in credentials/ |
| FCM (Android) | Firebase service-account JSON (or migrate to UnifiedPush) | Firebase project under the Koder Google account |
| UnifiedPush | self-hosted ntfy endpoint + distributor app | infra provisioning (ntfy on a Koder host) |
Per self-hosted-first.kmd, UnifiedPush is the preferred Android path; FCM is the pragmatic default until the distributor story is in place. iOS has no self-hosted option — APNs is mandatory.
8. Migration / coordination
- Consolidate the device registry: move AICORE-144 part-1's
services/ai/ai/gateway/internal/devicesinto koder-notify (or have the AI gateway proxy registration to koder-notify). Re-point the mobile/v1/devices/registercall accordingly. - Re-scope AICORE-144 part 2: instead of the AI gateway sending push
directly, it calls
koder-notify /v1/notifyon run-lifecycle events (terminal / awaiting_approval). koder-notify owns the fan-out. - Wire gate-RFC-001's Koder ID push to the same PushChannel.
9. Rollout (phased)
- Phase 1 ✅ delivered (2026-05-30) — WebPush (VAPID) adapter + device
registry in koder-notify +
/api/v1/notifypush channel. Creds-free. Serves web/PWA. Crypto interop-verified against the RFC 8291 §5 published vector (byte-exact aes128gcm body + intermediate CEK/nonce + Decrypt round-trip of the published body —internal/channel/push/webpush_vector_test.go). - Phase 2 — consolidate AICORE-144 part-1 registry; re-scope 144 part-2 to
call koder-notify; mobile registers
android/iosdevices. - Phase 3 — APNs + FCM adapters as creds land; UnifiedPush if Android
self-host is prioritized.
10. Open questions (for owner ratification)
- Canonical home of the device registry — koder-notify (this RFC's
proposal) vs servicesfoundationid (gate-RFC-001 implied it) vs a standalone foundation service. Recommendation: koder-notify, since it owns delivery.
- Android default — FCM now (turnkey) vs invest in UnifiedPush self-host
first. Recommendation: FCM adapter + UnifiedPush as a fast-follow.
- Provider creds — approve capturing Apple push key + Firebase
service-account (or commit to UnifiedPush) — currently none exist in the context (verified 2026-05-30).
11. References
infra/observe/notify(koder-notify — channel seaminternal/channel/channel.go)gate-RFC-001-multi-channel-notifications(punted the push transport)services/ai/ai/backlog/pending/144-mobile-run-notifications-backend.md(AICORE-144; part 1 device registry shipped in the AI gateway)services/ai/ai/backlog/done/143-mobile-agent-run-surface.md(the mobile surface that consumes run push)policies/self-hosted-first.kmd,policies/multi-tenant-by-default.kmd,policies/observability-first.kmd,policies/reuse-first.kmd