Initial mock for the durable, pull-based notification queue: events append rows to a per-principal inbox (IcyDB entity, stable memory); the bell in the global top bar shows the unread count and opens the panel. No push, no sockets — the queue is read on page load, which is the IC-native model. Covers V1 only: the in-app queue, flood control (coalescing + per-source cap), the cycle-runway watchdog, and retention. Out of scope (V2): email/push delivery, per-category preferences, cross-kind digests.
01Placement · the app-wide top bar (Decision 6)The bell lives in the persistent global top bar on every surface — studio, launchpad, marketplace, landing — whenever a user is logged in. Anonymous visitors get no bell at all (Decision 5): the bar shows Connect instead. The white context bar below keeps only its breadcrumb + current-principal chip; it stops carrying the bell, ending the current drift where each surface copies its own.
Same bar skeleton, but the bell is gone (Decision 5) and the wallet pill becomes a white Connect button. No principal chip.
02Bell · badge statesThe badge renders unread_count() — a cheap dedicated endpoint, not a feed fetch. No unread → no badge (never a grey zero). Display caps at 9+; the true count lives in the panel. Clicking toggles the panel anchored under the bell.
All read
Unread
Capped
Left to right: no badge at zero (never a grey “0”) · exact count up to 9 · ten or more renders 9+.
03Panel · the queue, newest firstEach row: icon by kind · title · one-line body · relative time. Unread rows carry a blue dot and stronger title weight; read rows are muted. Clicking a row follows its deep-link and marks it read; the × dismisses (deletes) the row. Header: Mark all read; filter All / Unread. Footer: Load more via the IcyDB cursor — the panel paginates, it never fetches the whole queue. The ×12 tally on the claim row is flood-control coalescing (frame 04), not a digest.
Notifications
Cycle runway · 14 days
Neon District is projected to run dry around 30 Jul. Top up in Billing.
2h ago
Claim received ×12
Paper Trail · Corner Shop — 12 claims since you last checked.
3h ago
Vendor empty
Corner Shop sold out — every staged copy has been claimed.
5h ago
Export complete
Generator Vaulties · batch #7 (500 outputs) is ready to download.
Yesterday
You were paid
Royalty payout · 12.40 ICP from a Paper Trail sale.
2d ago
Vendor started
Corner Shop went live on the Launchpad.
3d ago
Creator access granted
Your account can now create projects and collections.
6d ago
Retention never surfaces in the panel — it's housekeeping (frame 07): read rows purge after 7 days, hard cap 50 per principal.
04Flood control · coalescing + per-source cap (V1)Queue protection, not a digest: one noisy vendor must never bury the rest of the bell. Coalescing — an event whose recipient already holds an unread row with the same kind + subject_ref updates that row: count +1, created_at refreshed, so it re-sorts to the top. Per-source cap — one subject_ref may hold at most 5 unread rows across all kinds; past that, new events coalesce into the source's newest row regardless of kind. Cross-kind / cross-source summaries ("12 claims today") remain the V2 digest feature.
Coalescing · same kind + subject, unread
Claim received ×12
Paper Trail · Corner Shop — 12 claims since you last checked.
Refreshed 3h ago · first at 09:14
Claim #13 arrivescount → ×13 · row re-sorts to top
Row is read, claim #14 arrivesnew unread row appended
Only unread rows coalesce — reading the tally closes it; the next event starts a fresh row. Same rule dedupes timer sweeps (a re-run never writes “about to end” twice).
6th event · any kindcoalesces into the newest Corner Shop row
Everyone else’s notificationsuntouched — 45 of 50 slots stay free
Keeps a runaway source from occupying the whole 50-row queue. Schema impact of both mechanisms: one field — count (Nat, default 1).
05Timer events · the cycle-runway watchdogEvent-driven rows fire on activity; the watchdog fires on the passage of time — exactly the case where nobody is looking. A platform sweep monitors every project's cycle runway and writes a warning to the project's admins (fan-out, Decision 2) at each threshold crossing: 30 / 14 / 3 days. Dedupe via subject_ref = project_id + threshold; a threshold re-arms once runway recovers above it, so a project that dips again warns again. Every row deep-links to the project's Billing tab.
Cycle runway · 30 days
Neon District has about a month of cycles left at current burn.
16 Jun
Cycle runway · 14 days
Two weeks left. Top up now to keep Neon District's canisters running.
2 Jul
Cycle runway · 3 days
Critical — Neon District runs dry this week. Top up immediately.
13 Jul
Every watchdog row deep-links to the project's Billing tab. One notification per project per threshold crossing, per admin. Distinct from the event-driven “low balance / top-up performed” rows, which fire on treasury activity.
06Empty statesAll caught up is a personality moment — the Toko character belongs here (empty/success moments, never destructive ones). The panel-scale version stays calm and small; this is not the full-page 920px empty-hero. With the Unread filter active and nothing unread, a one-line variant keeps the read history visible below.
Notifications
You’re all caught up
Nothing waiting. We’ll keep anything that happens here until you’ve seen it.
Notifications
No unread notifications.
Vendor started
Corner Shop went live on the Launchpad.
3d ago
Switch to All to see read items
07Lifecycle, endpoints & IC guaranteesDeveloper reference — not a screen. The write is a best-effort side-effect after the real action commits: icydb has no rollback, so a failed notification write must never fail or reverse the action (the export still happened even if the row didn't land). Personal events write one row; team events fan out one row per project-admin principal (insert_many_atomic, Decision 2). All endpoints are caller-scoped: a principal only ever reads or mutates its own queue.
Row lifecycle
Event commits
Export finishes, vendor sells out, sweep crosses a threshold.
→
Append / coalesce
Row written to the recipient's queue — or an unread same kind + subject row takes count +1.
→
Unread
Badge counts it; the row sits durably across sessions, devices, days.
→
Read
Click-through, per-row, or Mark all read · read_at stamped.
→
Housekeeping
Read rows purge after 7 days; hard cap 50 per principal, oldest read dropped first (Decision 3).