Notifications & Nudges
Updates Surface
One canonical Updates store, a single M-voiced chat pointer, and pithy push teasers — replacing today's briefing/EOD duplication and giving nudges a home.
Situation
Mallory-initiated messages (calendar/mention nudges, morning briefing, end-of-day) get a single persistent home instead of duplicating full text into both chat and push. A fully separate notifications stream was considered and rejected — it would make Mallory feel like "an app that notifies you" rather than "a person who messages you", breaking the wave-spanning identity/presence principle.
The resolution: chat stays two-way conversation only, with a single coalesced, in-voice pointer shown when unread updates exist; the detail lives in a dedicated Updates view; push becomes a teaser that taps through rather than a copy of the full text.
Key decisions
Generalise nudges into an updates store, table kept
The existing nudges table gains teaser/read_at/dismissed_at columns and a widened kind set (briefing/eod added) rather than a churny rename — an updates-named data-access layer sits over the same table.
Record-always-persisted; push-best-effort
The nudge engine's gating (shouldSendNow, quiet hours, daily cap, dedupe) applies only to push delivery — the Updates row is written unconditionally, fixing today's silent loss of quiet-hours-suppressed now nudges.
Briefing/EOD stop writing to chat
insertMessage calls into the conversation are removed from both crons; they instead write an Updates row plus a pithy teaser push, ending the chat/push duplication.
Single coalesced chat pointer
One derived, in-voice element reflecting the whole unread set (not one per item), never persisted as a chat message, clearing when all items are read.
Worker-side auto-decay, mark-all-read-on-open
Old read items are pruned Worker-side (the Hub's decay job can't reach D1 under the edge-local invariant); unread items are never auto-decayed; entering the view marks visible items read for simplicity.
Architecture
One Worker-owned data-access layer over the existing nudges table backs three edge-local endpoints, a chat pointer, and a dedicated Updates view.
teaser/read_at/dismissed_at; kind widened to include briefing/eod (still Worker-internal only — /nudge keeps accepting just calendar/mention).teaser field.insertMessage calls; now write an Updates row + pithy push teaser instead.Risks & guardrails
Persona-presence dilution
The core design risk (a separate notification stream feeling like an app, not a person) is addressed structurally by keeping the pointer in-voice and in-chat rather than routing everything to a silent inbox.
Backfill unread spike
Existing nudges rows have no read_at; the plan favours backfilling them as read to avoid a one-time unread-count spike on migration.
Retires a broken deep link
The obsidian:// mobile deep-link path is retired in favour of an in-app "Go to source" link, since detail now lives in the Updates view rather than requiring a vault jump.
Delivery
Migration + data layer + pure core
Migration 011 adds the Updates columns; a db.ts data-access layer plus pure pointer-summary/decay-cutoff logic land under unit tests.
Write-site changes
/nudge always persists; briefing.ts and eod.ts stop inserting to chat and write Updates rows + teaser pushes instead.
Endpoints + decay
The three /updates endpoints and a scheduled() decay prune are added.
Web: client, view, pointer, wiring
An API client, UpdatesView, ChatPointer, and +page.svelte wiring, followed by browser verification before PR.
Out of scope
docs/superpowers/specs/2026-06-14-updates-surface-design.mdplan ·
docs/superpowers/plans/2026-06-14-updates-surface.md