// ── Core's own delivery channels ─────────────────────────────────────────── // // ENGAGEMENT.md §3.1 / Phase 3. All three are core's, and none of them is a game // concept: a mailbox, a push endpoint and an inbox row are the same three things // on any shard running this platform. // // **They are declared here before two of them can deliver anything**, and that is // deliberate rather than premature. A preference is a durable user statement; the // three columns of the preferences screen have to exist from the moment the table // does, or the first person to open it after Phase 6 finds an email toggle that // has never had a value and a screen that changed shape under them. Registering // the metadata early costs nothing — the registry holds no behaviour yet — while // registering it late means back-filling opinions users were never asked for. // // The `defaultMode`s below are the whole of G9: "per-channel defaults differ and // there is nowhere to express that generically". This is that place. const { registerDeliveryChannel } = require('./channels') const emailChannel = require('./emailChannel') const pushChannel = require('./pushChannel') const inappChannel = require('./inappChannel') const CHANNELS = [ { id: 'push', label: 'Push', description: 'A silent tickle to your phone; the app then pulls the real content.', // The tickle invariant (docs/android/PLAN.md §11), stated structurally rather // than as a comment: what leaves the server on this channel is { stream, ref } // and never a message body. Phase 7's `deliver` reads this flag; declaring it // false here is what makes "push must not carry content" a property of the // registration instead of a rule each caller has to remember. carriesContent: false, // **Opt-IN, and this is the one place the phase's own acceptance line was // wrong.** ENGAGEMENT.md Phase 3 said a fresh user's push defaults to // 'instant'; §3.1 called push "opt-OUT", borrowing the semantics of // `team_notification_prefs` (where no row does mean notified). But push // STREAM subscriptions have never worked that way: `notification_subscriptions` // holds a row only when a user opted in, so no row means not subscribed. // Defaulting to 'instant' here would have projected the entire catalog into // `GET /auth/me/notifications/subscriptions` for every existing user, and the // shipped Android client would have shown every toggle switched on after an // upgrade nobody asked for. Settled by the org lead 2026-08-29: 'off'. defaultMode: 'off', // A batched tickle is a contradiction — the content is not in the message, so // there is nothing to roll up. Ten events are ten wakeups or one; either way // the app pulls the same inbox. supportsDigest: false, // Phase 7: the oldest sink is the last to get a `deliver`, because until the // inbox existed there was nothing for a content-free tickle to point at. addressFor: pushChannel.addressFor, deliver: pushChannel.deliver, }, { id: 'email', label: 'Email', description: 'A message to your verified address.', carriesContent: true, // Opt-IN, per §7.1 Q1: standard marketing-email practice, and the posture // `team_notification_prefs.email_mode` already takes ('off' by default). defaultMode: 'off', supportsDigest: true, // Phase 6: the first channel with a body. `supportsDigest` above is now load- // bearing rather than aspirational — a 'digest' preference means the engine // writes NO outbox row and the digest worker re-derives the content at send // time (§4.2b), which is a different delivery path rather than a batched one. addressFor: emailChannel.addressFor, deliver: emailChannel.deliver, // Phase 9: the only channel that declares one. The Phase 1b verification // gate is an email fact, and this is the seam that keeps it out of the // generic engine - see channels.js's `eligible` docs. eligible: emailChannel.eligible, }, { id: 'inapp', label: 'On the site', description: 'An item in your notification inbox on the website and in the app.', carriesContent: true, // **Opt-OUT, and the only one of the three that is** — settled by the org // lead 2026-08-31, which is the Phase 7 decision this comment used to defer. // // The argument against a live default was never about in-app: it was that // push wakes a device the user is holding and email leaves the building, so // both must be asked for. An inbox item does neither. It is a row on a page // the user chose to open, on this deployment, costing them one glance — and // left at 'off' the surface would ship dead, because no rule could reach // anyone until every user found a toggle for a channel they had never seen // deliver anything. The backlog Phase 3 worried about cannot happen either: // the table is empty at cutover, rules default to `enabled = 0`, and every // rule carries a per-hour ceiling. defaultMode: 'instant', // Instant-only, and unlike push the reason is not that batching is // meaningless — it is that the inbox IS the batch. A digest of inbox items // is a list of things already sitting in a list. supportsDigest: false, addressFor: inappChannel.addressFor, deliver: inappChannel.deliver, }, ] for (const channel of CHANNELS) registerDeliveryChannel(channel) module.exports = { CHANNELS }