MalloryEstate Briefing
M·024Approved
← All briefings

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.

Dated 2026-06-14Programme Notifications & Nudges

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.

updates data-access layer over nudges table
Adds teaser/read_at/dismissed_at; kind widened to include briefing/eod (still Worker-internal only — /nudge keeps accepting just calendar/mention).
GET /updates, GET /updates/unread, POST /updates/read
List + unread-count/pointer-summary + mark-read endpoints, all edge-local (D1 only, no Hub calls).
/nudge route
Always persists the row; push uses the teaser field.
briefing.ts / eod.ts crons
Removed chat insertMessage calls; now write an Updates row + pithy push teaser instead.
ChatPointer component (web)
Coalesced, in-voice unread affordance at the top of the feed, linking to Updates.
UpdatesView component (web)
Reverse-chronological list reusing the ArchiveView pattern; expandable detail, "Go to source" link, mark-read on open.
Worker scheduled() prune
Daily decay of read-and-older-than-retention-window rows.

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

SnoozeInline reply-to-mention from UpdatesPer-kind filtering in the Updates viewRich in-app rendering of sourcesCross-device read-sync nuances
spec · docs/superpowers/specs/2026-06-14-updates-surface-design.md
plan · docs/superpowers/plans/2026-06-14-updates-surface.md
Mallory · Estate briefing