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 a push channel 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 transport gate-RFC-001 left 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

  1. No push today. koder-notify delivers WebhookEmailSlackTelegram

    PagerDuty/Teams — all server→human-via-chat. There is no app→device push.

  2. AICORE-144 needs it. The Koder AI mobile surface (AICORE-143) wants

    "push when a run finishes / needs input" — the Codex-mobile parity gap.

  3. gate-RFC-001 punted it. That RFC adds Koder ID app push to the auth

    gate but declares the transport choice a non-goal ("lives in servicesfoundationid"). The decision was never made.

  4. 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.kmd violation). 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.Channel seam 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 fit AlertPayload cleanly. 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 carry

    no 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/devices package 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 Notification supertype + parallel UserChannel seam

    (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 AlertPayloadNotification with alert fields as an

    optional 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 web device.

  • webpush.go signs + POSTs the encrypted payload to the subscription

    endpoint. 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

  1. Consolidate the device registry: move AICORE-144 part-1's

    services/ai/ai/gateway/internal/devices into koder-notify (or have the AI gateway proxy registration to koder-notify). Re-point the mobile /v1/devices/register call accordingly.

  2. Re-scope AICORE-144 part 2: instead of the AI gateway sending push

    directly, it calls koder-notify /v1/notify on run-lifecycle events (terminal / awaiting_approval). koder-notify owns the fan-out.

  3. Wire gate-RFC-001's Koder ID push to the same PushChannel.

9. Rollout (phased)

  • Phase 1delivered (2026-05-30) — WebPush (VAPID) adapter + device

    registry in koder-notify + /api/v1/notify push 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/ios devices.

  • Phase 3 — APNs + FCM adapters as creds land; UnifiedPush if Android

    self-host is prioritized.

10. Open questions (for owner ratification)

  1. 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.

  2. Android default — FCM now (turnkey) vs invest in UnifiedPush self-host

    first. Recommendation: FCM adapter + UnifiedPush as a fast-follow.

  3. 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 seam internal/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