Mock for the durable, pull-based notification queue. A notification is a durable message addressed to a principal: a producer appends a record to that principal's queue in stable storage, and the bell in the global top bar shows the unread count and opens the panel. No push, no sockets — the client polls, and the server-side queue is authoritative. Covers V1 only: levels and categories, the pending queue, aggregation and flood control, conditions vs. events, retention, revision-keyed polling and the producer outbox. Deferred: real-time push, out-of-band delivery (email / mobile / SMS), per-category preferences and muting, multi-action rows, cross-kind digests.
Aligned to notification-system-design.md (supersedes notification-system-spec.md and the earlier Canic notification service note). Section refs below are that document's. Related: Top bar · Project cycles / Billing · Navigation.
Two correctness issues — not design preferencesReplay-safe ingestion (§18). A producer retry must not duplicate a notification or double-increment an aggregation count — see frame 11. Polling keyed on a per-user revision, never the newest notification ID (§22). Grouping, reading and resolving all change a notification without producing a new ID, so an ID cursor silently misses them — see frame 10.
01Placement · the app-wide top bar§9The bell lives in the persistent global top bar on every surface — studio, launchpad, marketplace, landing — whenever a user is logged in. Notifications are global and per-user: someone browsing the marketplace must still see "your vendor is empty". The white context bar below keeps only its breadcrumb and current-principal chip; it stops carrying the bell, ending the drift where each surface copies its own. Anonymous visitors see no bell at all — there is nothing to show — just a sign-in affordance.
Same bar skeleton, but the bell is gone and the wallet pill becomes a white Connect button. No principal chip. The anonymous principal is rejected by the service outright (Part VI).
02Bell · badge states§9 · §22The badge renders the unread_count that arrives with the poll response — there is deliberately no separate unread-count query. Every Poll reply, changed or unchanged, carries the current count, so the badge is always as fresh as the last poll and costs nothing extra. 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 · no badge
Unread · exact
Capped display
Left to right: no badge at zero (never a grey “0”) · exact count up to 9 · ten or more renders 9+. The header badge, the panel, the Projects page and the Billing page all read the same client-side state — one poller per application (§11), never one per consumer.
03Panel · the pending queue, newest first§9 · §10 · §24Each row: an icon tinted by level · a title · a one-line body · relative time · optionally one action button (§10). Ordering is by last_event_at, the most recent contributing event — notcreated_at, which keeps meaning first creation and is never mutated. Unread rows carry a blue dot and stronger title weight; read rows are muted. Clicking a row follows its deep link and marks that row read. Header: Mark all read. Filter: All / Unread. Footer: paginated Load more — the panel never fetches the whole queue in one response (§23). The ×12 tally is coalescing (frame 05), not a digest.
Notifications
Cycle runway · 14 days
Neon District is projected to run dry around 30 Jul.
projects2h ago
12 claims received ×12
Paper Trail · Corner Shop — 12 claims since you last checked.
marketplace3h ago · first at 09:14
Vendor empty
Corner Shop sold out — every staged copy has been claimed.
marketplace5h ago
Export complete
Generator Vaulties · batch #7 (500 outputs) is ready to download.
studioYesterday
You were paid
Royalty payout · 12.40 ICP from a Paper Trail sale.
billing2d ago
Vendor started
Corner Shop went live on the Launchpad.
marketplace3d ago
Studio access granted
Your account can now create projects and collections.
access_security6d ago
The category chip is shown here so the mapping is legible during design review; whether it ships as a visible chip or only as an icon-and-grouping signal is a UI call. Categories drive filtering, visual grouping and navigation, and preferences (V2) will key on categories, never on kind strings (§4). Retention never surfaces in the panel — it is housekeeping (frame 09).
Open — flagged during this alignment pass§9 specifies a per-row dismiss control (the ✕ above), but NotificationCommand in §21 has only ApplyProducerBatch, MarkRead and MarkReadThrough — there is no Dismiss variant. Either the panel loses per-row dismiss for V1, or §21 gains a fourth command. The mock shows the ✕ because §9 asks for it; the protocol frame (12) does not invent the command.
04Levels and categories · two independent axes§3 · §4Every notification has exactly one level and exactly one category. Level drives visual presentation, retention, producer urgency and eventual out-of-band routing; it does not define a processing queue or replace domain state. Category drives filtering, grouping, navigation and future preferences. The two are independent — projects + warning means something entirely different from projects + success. The category vocabulary is closed: a producer cannot invent one, and adding one is a design change. Naming: this field was called severity in an earlier draft and in the previous version of this gallery — it is level.
Five levels · retention follows meaning
Level
Meaning
Retention
info
Something happened; no action normally required
Capped
success
Something the user or platform started completed
Capped
warning
Needs attention but is not yet failing
Persistent
error
Something failed
Persistent
critical
Failing or approaching failure, where ignoring it may cause interruption or loss
Persistent + eligible for out-of-band delivery later
Row tints in frame 03 are this field. critical is the only level rendered as a solid fill rather than a tint — it is the one level that may later trigger email / push / SMS, so it must be visually unmistakable.
Six categories · closed vocabulary
marketplace
Vendors, claims, sales, listings.
studio
Uploads, generation, exports, review outcomes.
billing
Payouts, treasury, cycles, top-ups.
projects
Project lifecycle and runway.
access_security
Studio access granted · revoked · restored. Role changes, ownership transfer, custody review, continuity, authenticity flags.
vault
Custody arrangements, vaulties, insurance review.
These names are product vocabulary, not Canic vocabulary, and must not leak into canic-core — Canic knows about Fleets, Components, canisters and identity; it does not know what a marketplace or a vault is.
Login, device and passkey events belong to Internet Identity and are invisible to Toko. access_security covers platform-native events only. The three studio-access events — granted · revoked · restored — sit on one bullet in the catalog rather than being split across the studio and vault entries.
05Aggregation and flood control§6 · §25One noisy vendor must not bury everything else in a user's bell. Volume is controlled at the source, before eviction is ever considered — aggregation is the first line of defence, retention the second. Coalescing: if an event fires and the recipient already holds a pending notification in the same group, do not append — increment count, update last_event_at (so the row re-sorts to the top) and let the title render the tally. Per-source cap: a single source may hold at most N pending notifications per recipient across all groups (proposed N = 5); beyond that, new events coalesce into that source's newest row. This is not the V2 digest feature — no cross-kind summaries, no cross-source rollups. It is queue protection.
Coalescing · same group, still pending
Token #1 is live
Token #2 is live becomes 3 tokens are now live
Token #3 is live (count 3)
12 claims received ×12
Paper Trail · Corner Shop — 12 claims since you last checked.
created_at 09:14 · last_event_at 12:41
Claim #13 arrives, row still pendingcount → ×13 · last_event_at updated · re-sorts to top
Row has been read, claim #14 arrivesnew pending row appended
created_atnever mutated — stays 09:14
Only pending rows coalesce. Once a user has read a grouped notification a later event starts a new one, rather than silently modifying something already consumed. Timer sweeps are deduped separately, producer-side, per subject per threshold (§5) — not by this service-side grouping rule.
Group identity · scope, not a bare key
A producer's raw group_key is not globally unique. The effective grouping scope is:
Grouping additionally requires compatible level semantics. Different levels never merge — which is exactly what makes escalation work (frame 06). Grouping never combines across users, producers, categories, incompatible levels, or unrelated semantic groups.
Per-source cap · proposed N = 5 pending per source
Alice's pending Marketplace rows5, across every vendor and every group
6th Marketplace event · any groupcoalesces into Marketplace's newest row
Billing, Projects, Studio rowsuntouched — the cap is per producer
Batch operationsone summary row per batch — never per item
“Generation complete — 250,000 tokens”, not 250,000 rows. Future batch features inherit this rule. Schema impact of both mechanisms: count plus last_event_at.
Open — flagged during this alignment pass§6 motivates this cap with “one noisy vendor must not bury everything else”, which reads as a per-vendor limit. But source is ProducerIdentity, derived from caller() (§19, §24) and keyed as producer in the group index (§26) — so N = 5 caps all Marketplace rows for that recipient, not five per vendor. That is materially stricter than the prose suggests: two busy vendors would compete for the same five slots. Either N is a per-producer value and needs to be larger, or the cap needs a finer key than source.
06Conditions and events are different · resolve§7Some notifications describe an event — your upload completed. Others describe a condition — Project Alpha is low on cycles. A condition can stop being true without the user doing anything, so a producer may resolve a condition notification. Read and resolved are different facts and are recorded separately: read_at means the user saw it; resolved_at means the underlying condition stopped being true. Both remove a row from the pending view. Keeping them distinct keeps the audit trail truthful — which matters most for billing and security — because a producer resolving something is not evidence the user ever saw it.
A condition that solves itself
10:00 Project Alpha is low on cycles
10:05 automatic refill succeeds
10:05 producer resolves the notification
11:00 user opens the app and is not warned
about a solved problem
Project Alpha is low on cycles
Balance fell below the refill floor.
10:00 · pending
Project Alpha is low on cycles
Resolved by the producer — automatic refill succeeded.
resolved_at 10:05 · read_at null
Struck-through, muted treatment is the design-review rendering of a resolved row. In the shipping panel a resolved row simply leaves the pending view; it lives on in bounded history with read_at still null, which is the truthful record.
Escalation · resolve, then publish
Resolution is also what makes escalation safe. A producer does not mutate the level of a notification the user may already have read. It resolves its warning and publishes a critical — a new row, a new record. Level is not part of the §25 grouping tuple, but grouping additionally requires compatible level semantics, so the two never merge.
Cycle runway · 14 days
Superseded — runway kept falling.
warning · resolved_at 13 Jul
↓
Cycle runway · 3 days
Neon District runs dry this week. Top up immediately.
critical · pending
read_atthe user saw it
resolved_atthe condition stopped being true
Both setlegal — they answer different questions
Rendered title and body never re-render after copy changes. A notification is a historical record of what the user was told (§24).
07Timer events · sweeps, thresholds and re-arming§5Event-driven rows fire when an action completes; timer-based rows are produced by a scheduled sweep — exactly the case where nothing is happening and nobody would otherwise look. The cycle-runway watchdog monitors every project's runway and warns the project's admins at 30 / 14 / 3 days: one notification per project per threshold crossing, re-armed once runway recovers above that threshold, deep-linking to the project's Billing tab. It is distinct from the event-driven low-balance row — that fires on treasury activity, the watchdog fires on the passage of time. The level shown against each threshold below is a design proposal, not doc text — §5 specifies the thresholds but assigns no level to any of them, and §5 assigns no levels to the event-driven catalog either.
Cycle runway · 30 days
Neon District has about a month of cycles left at current burn.
projectsinfo16 Jun
Cycle runway · 14 days
Two weeks left. Top up now to keep Neon District's canisters running.
projectswarning2 Jul
Cycle runway · 3 days
Neon District runs dry this week. Top up immediately.
projectscritical13 Jul
The rest of the timer set
Vendor about to end24h / 1h before scheduled end
Listing about to expire§5 gives no dedup scope — to be specified
Redemption expiry7d and 1d before the 30-day auto-cancel, deduped per request per threshold. No re-arm specified
Insurance review requiredoracle suggestion exceeds insured amount past the drift threshold, per item, re-armed on re-insure
Dedup and re-arm rules are specified per row type, not globally: only cycle runway and insurance review define re-arming. Vendor-end and listing-expiry dedup scopes are still open.
Fan-out · project notifications are user-scoped
A project does not own a shared inbox. If a project has three admins the Projects producer emits to all three principals; the service sees three recipients and does not know what a project is or why those principals were chosen. A shared project queue would immediately infect the service with membership and domain authorization semantics.
Admin setresolved at event time
Future eventsgo to the new set
Existing rowsstay with whoever received them — rows are records
Not eight inter-canister calls (§17). Recipient vectors stay bounded.
08Empty states§9“You're all caught up” is a personality moment — the Toko character belongs here (empty and success moments, never destructive ones). The panel-scale version stays calm and small; this is not the full-page 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.
marketplace3d ago
Switch to All to see read items
The guarantee this empty state is quietly asserting: a user can close the browser, stay away for days, return, and still retrieve every notification that remains pending (§2). Correctness never depends on a websocket, an open tab, or client-side history.
09Retention · the cap and the drop ladder§8 · §27Retention follows meaning. Informational (info, success) are bounded — a working cap of 50 pending per user, configurable until measurement says otherwise. Persistent (warning, error, critical) are never evicted merely because informational notifications arrived; they remain pending until read, or resolved by the producer. “Persistent” means don't silently discard an active warning because 50 success messages arrived — it does not mean infinite storage. Retention work happens during existing mutations: capped insertion at write time, group-index cleanup on read/resolve/replace, bounded opportunistic history cleanup. V1 adds no periodic GC and no scheduler.
Drop ladder at the cap · in order
1 · Oldest read
Already consumed. Cheapest thing to lose.
→
2 · Expired informational
Informational rows past their useful life.
→
3 · Coalesce harder
Apply the per-source cap more aggressively before deleting anything new.
→
4 · Oldest informational pending
Unseen, but only info / success.
→
5 · Persistent pending
Last resort only, oldest first. A warning / error / critical is never dropped while any rung above it still has something to give.
Pending queue vs. history
Pending queuethe V1 guarantee — fully retrievable
History (read + resolved)bounded recent convenience, separately capped
Informational cap50 pending per user (working value)
Per-source cap5 pending per source per recipient (proposed)
Prefer a simple count cap over a timer-driven TTL subsystem. Pending queue and history are different concepts and are bounded separately.
When retention runs
Capped informational retentionat insertion
Group-index cleanupon read, resolve or replacement
History cleanupbounded opportunistic work during existing updates
Periodic maintenance taskonly if measurement demands it
And if it is ever needed, consume Canic's canonical timer ownership rather than adding a scheduler.
Open — flagged during this alignment passRung 2 has no defined input. The ladder says “expired informational”, but the document defines no expiry mechanism anywhere — no expires_at, no TTL, no age field in the record (§24) or the indexes (§26). §8's count-cap preference is scoped to history, not to pending rows, so it does not settle this either way. Expiry could be evaluated lazily at insertion without any scheduler — §27 defers periodic maintenance rather than forbidding it — but the input itself still needs defining, or rung 2 should be dropped. The previous gallery invented a 90-day expires_at to fill this gap; that value appears nowhere in the design and has been removed rather than carried forward.
Open — flagged during this alignment passRung 5 contradicts the persistence guarantee. §8 states persistent rows “are never evicted merely because informational notifications arrived”, and acceptance criterion 19 restates it. But the ladder is triggered at the cap — i.e. by informational pressure — and its last rung drops “persistent pending … oldest first”. Both statements are in §8. The ladder is presumably meant as a genuine last resort after the queue is entirely persistent rows, but as written the two sentences conflict. Worth an explicit rule: either persistent rows are exempt from the cap and bounded separately, or the guarantee needs qualifying.
10Delivery to the client · why the cursor is a revision§11 · §22 · §23This is one of the two non-negotiable correctness items. Polling must not key on the newest notification ID. Grouping, reading and resolving all change a notification without producing a new ID, so an ID cursor silently misses them — and the same failure occurs on resolve, on read from another device, and on regrouping. Each user therefore carries a monotonically increasing revision, incremented by any mutation affecting their visible notification state.
The failure an ID cursor produces
10:00 N17 "Tokens are live", count 1
client receives N17
10:10 another token goes live
service updates N17 → count 2
no newer ID exists, so
poll(since = N17) → nothing
the client never sees the update
An unchanged poll is tiny. A changed poll returns the current bounded snapshot. Simpler than maintaining a delta stream, and it needs no client-visible tombstones for read, resolved or regrouped records. The badge count rides along — there is no separate unread-count query.
One poller per application · adaptive cadence
Login, or tab becomes visiblepoll immediately
Active sessionpoll frequently — 60s starting value
Repeated unchanged resultsback off gradually
Idle or background tabpoll slowly or pause
Exact timings are UX and load tuning, not part of the data contract. Polling is acceptable for V1 because notification reads are query calls, which are free — there is no cycle argument against it.
The header badge, the panel, the Projects page and the Billing page all consume the same client-side state. They do not each poll.
If the user's revision changes mid-pagination, the client restarts from the new revision. This avoids snapshot-locking and historical versions. On login the complete pending set must be retrievable; it does not have to arrive in one response.
11Producer side · outbox, batching, replay safety§16–§19 · §28The governing principle is persist locally, coalesce locally, batch remotely. Every notification-producing domain keeps a small durable local outbox. When a domain operation reaches the durable point at which a notification is warranted, the producer records the intent locally — and the domain operation does not need the service call to succeed synchronously. This avoids making domain state and notification state one distributed transaction.
Commit path · the notification is a best-effort side effect
Domain op commits
Marketplace transaction commits. Marketplace state committed and notification intent committed to the outbox.
→
Coalesce in the outbox
Four “token live” events become one item — 4 tokens are now live — before anything crosses a canister boundary.
→
Conditions may cancel
Low-cycle at 12:00, refilled at 12:01 → collapses to nothing at the 12:03 flush. Zero records, zero calls.
→
Bounded batch out
One update, many mutations, many recipients. critical may flush the current batch immediately — urgency is a flush policy, not a protocol difference.
→
Frontier accepted
Service advances next_expected_seq. Until the producer can prove acceptance, the same pending batch stays authoritative.
Replay safety · sequence frontier, not event IDs
producer sends first_seq = 101, mutations 101..104
service commits next_expected_seq = 105
a retried batch is recognised as already applied
An inter-canister call can commit successfully even when the producer fails to observe the response. With grouping, a duplicate is worse than a repeated row, because it silently inflates a count: "Your token is live" submitted twice → "2 tokens are live", which is simply wrong.
Retries are exact. A producer never mints a fresh sequence identity because a call timed out, a transport failed, or a response was lost.
Why not (source, event_id)? Both achieve replay safety, but a frontier needs one integer per producer whereas event-ID dedup needs a retained set of seen IDs — and avoiding an unbounded global event-ID table was an explicit goal (§31). The tradeoff: a frontier requires a producer's batches to be ordered. Since each producer owns its outbox and submits sequentially, ordering is free — but a producer needing concurrent unordered submission is the assumption that breaks. The previous gallery specified idempotency on event_id; that is the rejected option.
Call topology · one direction, no lookups
Producer ──bounded update batch──▶ Notification Service
Calls to producers at notification timenone
Root / Coordinator / Wasm Store / Directorynone at ingestion
Producer identityderived from caller() + local Canic authority state
source in the payloaddeliberately absent
Invariant: notification ingestion performs no inter-canister authorization or discovery lookup. A producer cannot claim source = billing while actually being Marketplace — source is trusted service metadata, not caller-supplied. Do not route through Root; Root is not a notification router.
Notifications never break the domain operation. “Marketplace sale commits” must never become “Marketplace sale fails because the Notification Service was briefly unavailable.” On the IC there is no rollback across canisters, so the notification is written as its own operation after the triggering action commits.
The previous gallery said “retries 3× on a timer then drops with a log line — no full outbox in V1.” The design requires a durable outbox; the intent is retained and retried until accepted, not dropped.
12Protocol and the record§20–§21 · §24 · §26Developer reference — not a screen. The application exposes exactly two methods: notification_command (update) and notification_query (query). These are the Notification application's own protocol, separate from Canic management endpoints such as canic_command and canic_status. Different callers do not require different methods — authority belongs to the variant, not the method name. Producers gain a client, not endpoints: no canic_notifications, no notification_status, no notify_user.
MarkReadThrough is revision-aware so a notification arriving after the user viewed the panel is not accidentally marked read — which a naive mark-all-read would do. That is why it replaces the reviewed MarkAllRead { category } (§32).
Read state updates are batched. The client collects read IDs and sends one MarkRead after a short debounce, when the panel closes, or at another suitable boundary — never one update per row.
The previous gallery listed list_notifications, unread_count, mark_read, mark_all_read, dismiss and clear_read as thin wrappers over IcyDB. That endpoint family does not exist in this design.
Append-oriented, not strictly immutable. Only aggregation state (count, last_event_at), read state and resolution state may change. A grouped notification reads created_at 10:00, last_event_at 10:37, count 5 — and the feed orders on last_event_at. The previous gallery used occurred_at; the field is last_event_at.
There is no dead actions: [] field in V1 — multi-action rows (Accept / Reject / Retry) are future scope, and the record anticipates an additive optional field when they arrive. An action carries navigation information, never executable instructions.
All strings and vectors carry explicit maximum encoded sizes. No collection is unbounded.
Indexes · a few stable maps, no broker
User metadataPrincipal → revision, next_notification_id, unread_count, capped_pending_count
Only currently groupable records need a group entry; once read or resolved it can be removed. The producer frontier is compact — there is no table of every historical producer event. Use the application's existing stable-storage patterns rather than creating another persistence framework.
V1 event catalog
Event-driven: generator upload complete · batch generate complete · export complete · vendor started / empty (sold out) / ended · claim received on your vendor · sale completed on your listing · you were paid (royalty or beneficiary payout) · cycles low balance / top-up performed · studio access granted · content review outcome · vault and custody (arrangement proposed or amended, continuity events, authenticity challenged or revoked, role changes, project ownership transferred).
Timer-based: vendor about to end (24h / 1h) · cycle runway watchdog 30 / 14 / 3 days · listing about to expire · redemption expiry (7d / 1d) · insurance review required.
Deferred to their own features: auction ended / outbid / won / closing soon (auctions ship after marketplace v1) · buyback offer received and accepted (Phase 2, collectable-cards launch) · dormancy and at-risk warnings.
Each event maps to a level, a category, a title, a body, and a deep link. Internal workflow phases do not become user notifications: prepare / allocate / upload / verify / commit / activate / publish is one line to the user — “Your collection is now live.”
13Place in Canic · one managed service, sharded by recipient§13 · §14 · §30The Notification Service is one managed application service inside a Fleet. It is not part of canic-core, not a new Fleet infrastructure role, not a Root or Coordinator responsibility, and not a new endpoint family emitted by every producer.
The notification record · levels · categories · grouping · retention · recipient queues · read state · resolution · poll revision · producer replay state · its application API.
Durable state“Managed by Canic” does not mean “safe to reinstall without preserving the queue.” This service exists specifically to preserve information across a user's absence. Ordinary upgrades preserve state; any future release requiring destructive replacement must first provide explicit application-state migration, backup/restore, or an accepted reset policy.
If it ever shards · by recipient principal
V1 is one Notification Service per Fleet. If it needs to scale, shard by recipient principal — never by subnet, and never by producer. This is not a preference. The per-user revision counter requires exactly one authority per user. Sharding by subnet or producer would scatter one user's notifications across services, and then:
Per-user revisionno single service could maintain it — polling breaks
Unread countwould need fan-in across shards on every poll
Groupingcould not see a user's existing pending rows
Drop laddercould not reason about the queue as a whole
Per-user sharding keeps all four intact, because one shard owns one user's entire queue. The platform already has this pattern — the user hub creates per-user shards — so notifications follow it rather than inventing a second sharding axis. It also answers the push question: a single source of truth per user is what push needs, and push would target the user's shard.
If reliable push ever arrives it becomes an additional delivery path over the same authoritative queue — never a second notification store.
14Dead target and security§9 · Part VIThe deep link resolves at click time through one shared resolve-or-fallback helper, never per surface. A deleted or absent target — a removed draft, a pre-Live object that's gone — lands on this calm dead end, never an error page. Rare by construction, since Live vendors end into read-only records and collections delete only pre-Live; but drafts do get deleted. Authorization happens at the destination, never at read time: a notification is a record, not a permission, so the deep-link target enforces access like any other route and a removed admin simply cannot open the page.
This item is no longer available
The thing this notification pointed to has since been removed. Nothing is wrong with your account — the notification stays in your feed as a record.
Authorization
ApplyProducerBatchadmitted producers only, verified against locally held Canic authority state
Poll / MarkRead / MarkReadThroughoperate only on the authenticated caller's queue
Recipient as a read parameterdoes not exist
Anonymous principalrejected
The recipient is not a parameter on read operations, which makes Bob → list_pending(Alice)structurally inexpressible rather than merely forbidden. Do not invent a separate notification authentication framework — reuse the protected identity and Directory authority other inter-canister protocols use.
Identity and edge cases
The queue is keyed by the user's stable application principal — the Internet Identity principal, or a wallet principal such as Oisy. Delegation principals are not used anywhere on this platform. A queue keyed by a transient session identity would strand a user's notifications the moment their session changed, which is the exact failure this system exists to prevent. Server-side ownership also means read state follows the user across devices.
User deletedprincipals are not deleted on the IC; rows age out via retention
Principal rotatedno migration — notifications address the principal, full stop
User blockedfriends-list blocks are social-layer only; platform notifications unaffected
Two devicesserialized per canister; mark-read races are idempotent no-ops
Maintenance windowthe producer outbox retains the intent and retries
Orderingguaranteed within one producer's stream; no global total order. Clock skew affects sort only, never correctness
15Boundaries and what is still openPart VII · Part IXWhat the service understands, what it deliberately does not, and the values still to be confirmed before build.
Understands
recipient · level · category · title · body · action · grouping · producer · time · read state · resolution state
Those concepts stay with the applications that own them. The service stores and delivers the message it is given, and never queries a producer later to reconstruct one.
Explicit non-goals for V1
A distributed event bus · pub/sub infrastructure · Kafka-like topics · websockets · cross-canister streaming · producer fan-out queries · one notification canister per producer · a project queue · permanent audit history · generic telemetry · notification-derived domain policy · arbitrary HTML bodies · centrally stored notification templates · cross-Fleet routing · generic scheduler infrastructure · a new Canic infrastructure role.
Open decisions
1 · Informational cap50 pending info/success per user — confirm after representative testing
2 · History capbounded recent-history policy; prefer a count cap before TTLs
3 · Poll timingsactive interval, backoff, hidden-tab behaviour — configuration, not protocol. 60s starting value
4 · Payload limitsmaxima for title, body, CTA label, CTA route, group key, recipients per publish, mutations per batch, notifications per page, read IDs per ack
6 · Per-source cap valueproposed N = 5 pending rows per source per recipient
Added by this gallery7 · Per-row dismiss. §9 specifies the control; §21 has no command for it (frame 03). 8 · “Expired informational”. Rung 2 of the drop ladder has no defined expiry input anywhere in the document (frame 09). 9 · Persistence vs. the ladder. §8 says persistent rows are never evicted under informational pressure; the same section's ladder drops them at the cap. Both cannot hold as written (frame 09). 10 · What the per-source cap counts. §6 reads as per-vendor; source is per-producer. N = 5 is very different under each (frame 05). 11 · Level per event. §5 lists the catalog but assigns no level to any row. The mappings drawn in frames 03 and 07 are proposals.
16What changed from the 2026-07-16 galleryalignment passRecorded so the previous version's claims are not accidentally reintroduced from memory or from the archived file.
Area
2026-07-16 gallery said
Design document says
Level field
named severity; all five values named in prose but only three tints drawn (ok / warn / err)
the field is level (§3). Five values are now drawn distinctly, critical as a solid fill
Categories
absent
six, closed vocabulary, one per notification (§4)
Sort key
occurred_at
last_event_at; created_at never mutated (§24)
Coalescing trigger
existing unread row, same kind + subject_ref
existing pending row in the same group: recipient + producer + category + group_key, compatible levels (§6, §25)
two methods: notification_command, notification_query (§20–§21)
Badge source
dedicated unread_count() endpoint
rides along with every Poll response; no separate query (§22)
Poll cursor
not specified; feed keyed on rows
per-user revision — never the newest notification ID (§22, correctness issue)
Mark all read
plain mark_all_read()
MarkReadThrough { revision, category } (§21, §32)
Retention
read rows purge after 7 days; informational expire at ~90 days via expires_at
count caps; a TTL subsystem is disfavoured but not ruled out, and the history cap is an open decision. Retention runs during existing mutations (§8, §27, Part IX·2)
Resolution
absent
resolved_at distinct from read_at; escalation by resolve-then-publish (§7)
Actions
no CTA on rows
one optional action — label + navigation target (§10)
Architecture
“IcyDB entity, stable memory”
managed application service in a Fleet; shard by recipient principal if ever needed (§13, §14)