Spec: F77 — Unified Notification Center
Status: shipped in source; contract status partial pending runtime evidence
1. Problem & CEO Job
See clearly + decide fast + trust the signal. Lumi already has notification fragments: Telegram delivery in F21, email in F35, mobile push in F36, and scattered module-specific nudges in chat, work, follow-ups, approvals, billing, and support. That is not a notification system. It is channel plumbing without a single source of truth. A CEO cannot answer three basic questions reliably: what needs attention now, what already reached me on another device, and which alerts are merely noisy repeats. Without a unified notification center, cross-channel delivery becomes duplication theater instead of signal.
F77 defines the notification system as one control loop: an in-app event ledger is canonical; channels fan out from it; every module plugs into the same event contract; every user can see, read, and tune what reaches them.
2. User Stories & Acceptance Criteria
-
As a CEO or operator, I want one in-app notification center, so that I can see all actionable alerts regardless of whether they also arrived by email, push, or Telegram.
- Given a new actionable alert from chat, work, intel, CRM, support, billing, or hub, when the system emits it, then an in-app notification row is created for the targeted member with title, body, category, route, and read state. Ordinary channel/DM messages remain in the Messages stream and do not create notification-center rows.
- Given unread notifications, when I open
/notifications, then unread items appear first and I can mark one or all as read without leaving the page.
-
As a user on web or mobile, I want channel delivery to hang off the same event, so that reading the same alert in-app and on another surface still refers to one underlying thing.
- Given a push-eligible event, when push delivery is attempted, then the in-app notification exists even if push is suppressed by preferences or quiet hours.
- Given an email-only event, when email delivery is attempted, then the event can still be represented in the notification ledger when the event is user-facing and not a purely transport concern.
-
As a member, I want notifications to route back into the exact module context, so that the notification is a doorway, not a dead artifact.
- Given a decision alert, when I click it, then I land on
/intel/decisions. - Given an approval alert, when I click it, then I land on
/crm/approvals. - Given a mention or task-comment alert, when I click it, then I land on the relevant work or chat surface.
- Given a message or mention alert, when I click or tap it on web or native, then its route preserves the message id and the client opens the exact authorized message context; a channel-only route opens that channel’s supplied topic (defaulting to
generalonly when omitted) and a conversation-only route opens the exact authorized DM/group-DM thread.
- Given a decision alert, when I click it, then I land on
-
As an owner/admin, I want one preference spine across channels, so that notification behavior stays coherent across web, mobile, email, and push.
- Given
/settings/account, when I adjust notification preferences, then those controls continue to govern email/push fanout while in-app remains the canonical always-available ledger. - Given future module expansion, when a new notification category is added, then it extends the shared contract rather than inventing a parallel table or page.
- Given
-
As a channel member, I want a per-channel delivery mode, so that high-volume rooms do not drown out messages that deserve my attention.
- Given an active channel membership, when I open the channel notification control, then I can choose all messages, mentions only, or muted.
- Given a mentions-only channel, when a teammate posts an ordinary message, then push is suppressed while a direct mention still alerts me.
- Given a muted channel, when any message is posted, then push is suppressed while the message thread and its server-owned unread watermark remain available; direct mention alerts still use the canonical in-app event.
3. Scope Boundaries
- In: canonical in-app notification event model; list/read APIs; notification center page on web; route-aware links back into modules; shared event-creation helper; integration contract for work/intel/crm/connect/mobile/email/push; first production wiring for existing push-triggered categories.
- Out: browser-native web push permission prompts; Apple/Android local-notification UI polish inside native shells beyond existing F36 push delivery; notification batching rules beyond existing digest/quiet-hours behavior; per-object subscription matrices; external customer-facing notification systems.
- In: a member-owned per-channel push delivery mode (
all,mentions, ormuted) for active message-channel memberships. This is a bounded delivery override, not a general per-object subscription engine.
4. Data Model
NotificationPreferenceremains the shared channel preference spine from F35/F36. It does not own in-app visibility; in-app is canonical and always available.ChannelNotificationPreference— one tenant-scoped row per active member/channel pair:organization,member,channelmode:all | mentions | muted(defaultall)organization + member + channelis unique; channel membership remains the authorization source.
NotificationEvent— new tenant-owned model inapps.notifications:- extends
UUIDModel organizationFKmemberFKcategory:approval | decision | brief | followup | mention | work | crm | support | billing | systemmodule:intel | work | crm | connect | hub | support | settings | billingtitle,bodyroute(frontend deep-link path)payload_json(small structured context, optional)dedupe_key(optional string; used to collapse obvious retries)read_atnullable timestamp
- extends
- Ordinary
messagerows remain readable for migration safety and route/audit correlation but are not a member-visible center projection: the member center filters them from list/read/mark-all and the unread aggregate excludes them. New ordinary message pushes use F29 channel/conversation read watermarks instead. - State model: unread =
read_at is null; read =read_at != null. No extra status enum.
The event ledger is append-first. We do not update hot business tables to track notification state.
5. API Contract
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| GET | /api/notifications/ | member | ?status=unread|all&limit=50&cursor=<opaque> | {results, unread_count, next, previous}; immutable UUIDv7 -id cursor | 400 invalid status/limit/cursor; 401, 403 |
| POST | /api/notifications/<id>/read/ | member | — | {id, read_at} | 404 cross-tenant |
| POST | /api/notifications/mark-all-read/ | member | — | {marked_count} | 401, 403 |
| GET/PATCH | /api/notification-preferences/me/ | member | existing F35/F36 contract | existing preference object | unchanged |
| GET/PATCH | /api/messages/channels/{channel_id}/notification-preference/ | active channel member | `{mode: all | mentions | muted}` for PATCH |
Tenant scoping: every event query filters by organization=request.tenant and member=request.membership. A member can never read or mutate another member’s notifications.
No AI-generated content contract applies here directly; notifications point back to source surfaces that already own evidence requirements.
6. UI / UX
- Route:
/notifications - Page:
NotificationCenterPage - Placement: personal navigation group in the main sidebar, not HQ. This is a user surface, not a staff test harness.
- Floating launcher: a third pod in the
CommandDock(the bottom-end floating dock that already carries Chat Lumi and Messages), so the unread “needs you” signal follows the CEO to every screen. The bell pod wears the live unread-notification count (UnreadBadgedot, shareduseUnreadCounts); opening it revealsNotificationsPanel— an unread-first, scannable launcher whose rows mark-read and deep-link into the raising module (the F77 “doorway”), with a mark-all-read action and an “open full” link to/notifications. Before F77’s dock work the unread-notification count was borrowed onto the Chat Lumi pod; it now has its own pod and the Chat pod carries no badge. - States: loading skeleton, empty unread state, populated unread/all feed, per-row mark-read, mark-all-read.
- Visual rule: concise editorial feed cards, no fake inbox chrome, no marketing copy, no decorative noise. A notification must scan in one breath: module, message, time, action target.
- Components:
PageHeader,Tabs,Card,Badge,Button,Skeleton,EmptyState; dock launcher usesCommandDock,NotificationsPanel,UnreadBadge. - i18n namespace:
notificationsfor page copy; sidebar gainsnotifications. The dock launcher reuses the existingnotificationsnamespace verbatim (title, description, mark-all, empty, time, category) so it ships no new locale key and the 62-locale key-parity gate stays green. - Native/mobile contract: the same event ledger feeds native notification-center surfaces later; route payload stays web-path based in v1 because mobile already mirrors major routes through F30 shell navigation. The canonical member notification-center path is
/notifications; native shells also accept the legacy/settings/notificationsalias so assistant/action links remain compatible without creating a second screen. - Native launcher contract: the top-rail bell and notification-center title are localized UI. Cross-platform runtime proof must navigate and assert them in both supported audit languages (
Notifications/Thông báo) before checking stable screen selectors; an English-only launcher assertion is not valid Vietnamese-device evidence. - Messages channel control: the native channel header exposes a localized notification button. Its dialog uses three calm, mutually exclusive choices (all messages, mentions only, muted), saves immediately, preserves the thread while transport is suppressed, and exposes retryable load/save errors without optimistic state.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Push-triggered alert creates one in-app NotificationEvent even with multiple device tokens | backend | backend/tests/test_push_notifications.py |
| 2 | Quiet-hours-suppressed push still creates unread in-app notification | backend | same |
| 3 | GET /api/notifications/ returns only current member rows and unread count | backend | backend/tests/test_notification_center.py |
| 4 | POST /api/notifications/<id>/read/ marks own row read; cross-member/cross-tenant returns 404 | backend | same |
| 5 | POST /api/notifications/mark-all-read/ marks only current member unread rows | backend | same |
| 6 | /notifications renders unread feed, marks an item read in place, and routes back into the target module | frontend/unit + E2E | frontend/src/test/NotificationCenterPage.test.tsx; frontend/tests/e2e/lumi.spec.ts |
| 7 | CommandDock renders the notifications pod, surfaces the unread count in its aria label, and opens NotificationsPanel | frontend/unit | frontend/src/test/CommandDock.test.tsx |
| 8 | NotificationsPanel shows the caught-up empty state, marks a row read + routes through its doorway on open, and clears all from mark-all | frontend/unit | frontend/src/test/NotificationsPanel.test.tsx |
| 9 | Active member reads/updates only their own channel mode; invalid modes and non-members fail closed | backend | backend/tests/notifications/test_messaging_channel_notifications.py |
| 10 | Muted/mentions-only modes suppress the correct push fan-out while keeping Messages watermark state and direct mention events separate | backend | same |
| 11 | Native channel header opens the three-mode dialog and reconciles a successful update or retryable error | shared KMP unit/source | android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/messages/MessagesScreenModelChannelNotificationsTest.kt (NATIVE_LOAD, NATIVE_PATCH, NATIVE_RETRY_UPDATE); android/shared/src/jvmTest/kotlin/com/locuno/lumi/shared/ui/MessagesNotificationModeSourceTest.kt (NATIVE_RETRY_SOURCE) |
| 12 | Message and mention pushes preserve channel/topic or conversation plus message context, and web/Android/iOS request or resolve the exact message context before channel fallback; channel-only routes preserve the supplied topic with a general fallback; conversation-only routes resolve to the exact DM/group-DM thread | backend + web/Android/iOS unit | backend/tests/notifications/test_push_assistant_message.py; frontend/src/test/connect/MessageThread.test.tsx; android/app/src/test/kotlin/com/locuno/lumi/android/FieldExecutionRoutingTests.kt; android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/messages/MessagesScreenModelTest.kt; ios/UnitTests/Workspace/WorkspaceTests.swift |
| 13 | Native unread/all filter switches keep only the latest response when an earlier request returns late | shared KMP unit | android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/notifications/NotificationsScreenModelTest.kt |
| 14 | Native read and mark-all failures retain the exact failed mutation behind Retry; successful retry clears the error, reloads the feed, and notifies the shell badge owner | shared KMP unit + source contract | android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/notifications/NotificationsScreenModelTest.kt; scripts/tests/test_f77_notification_source.py |
| 15 | Ordinary message pushes stay out of the notification center; Android/iOS Messages badges sum channel + DM unread watermarks; native provider payloads request the default sound | backend + Android/iOS unit/source | F77.AC007.* |
Backend coverage remains ≥99%. Frontend first slice must type-check cleanly.
8. Current Behavior
F77 now ships as a canonical in-app notification center backed by NotificationEvent:
- Backend: tenant-scoped notification ledger, list/read/mark-all APIs, deduped event creation helper, and push-to-ledger wiring for approvals, decisions, follow-ups, mentions, CRM comments, and chat/work surfaces. Ordinary channel/DM message pushes retain hidden route-bearing compatibility events;
messagerows are excluded from the member feed and unread aggregate. - Frontend:
/notificationsroute in the personal sidebar, unread/all tabs, mark-read and mark-all actions, route-aware “Open” CTA, and a shell-preserving load-error state. Opening an unread routed alert also reconciles it as read while preserving immediate navigation; a failed reconciliation is surfaced as the existing localized toast. - Native actions: failed mark-read and mark-all mutations preserve the exact failed action behind a localized inline Retry affordance; a successful retry reloads the selected feed and notifies the shell badge owner. Routed rows keep the whole card tappable for exact navigation, while read and open actions use compact icon-only controls with localized accessibility labels so a mobile feed stays scannable.
- Preferences:
/settings/accountstill owns email/push fanout viaNotificationPreference; in-app visibility remains always-on. - Channel delivery: the selected channel’s native header owns a per-member
all/mentions/mutedpush mode. The mode gates message transport only; channel/DM read watermarks remain the Messages source of truth, and direct mentions remain canonical notification events. A failed mode save retains the exact requested mode behind Retry instead of treating the previous successful mode as a completed save. - Staff tooling:
/hq/notificationsremains a separate internal test harness and is not the member notification center.
9. Error States
- Notification list fetch fails: show
notifications.load_error, keep page shell intact. - Mark read fails: the native screen keeps the item unread, shows a localized inline error, and offers Retry for that exact notification id. A routed row stays on the notification center until mark-read succeeds; the web surface keeps its existing
notifications.mark_read_errortoast. - Mark all fails: the native screen shows a localized inline error with Retry for the exact mark-all operation and makes no optimistic unread-count claim; the web surface keeps its existing
notifications.mark_all_errortoast. - Missing route target: notification still renders, but the native row fails closed: it hides the Open action and shows a localized no-destination explanation. The active Android/iOS typed resolver is the source of truth for whether a route is actionable; bad route strings remain implementation bugs and should be logged.
10. Diagrams & Docs Touched
- backend/DIAGRAMS.md: add
NotificationEventto ER diagram and extend notification flow - frontend/DIAGRAMS.md: add
/notificationsroute - F35 and F36: they become channel specs under F77’s umbrella, not standalone system definitions
11. Implementation Audit
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Canonical NotificationEvent ledger model exists in apps.notifications | ✅ match | backend/apps/notifications/models.py |
| 2 | Shared event creation helper dedupes unread retries | ✅ match | backend/apps/notifications/event_service.py |
| 3 | Push fanout creates in-app events before preference/quiet-hours suppression | ✅ match for actionable categories and hidden message compatibility events; ordinary message rows are excluded from the center projection | backend/apps/notifications/push.py; backend/apps/messaging/message_notifications.py; backend/tests/notifications/test_push_assistant_message.py |
| 4 | Member APIs are tenant/member scoped for list, mark-read, and mark-all-read | ✅ match | backend/apps/notifications/views.py; backend/tests/test_notification_center.py |
| 5 | Existing high-value surfaces route into the shared contract (approvals, decisions, follow-ups, work mentions, chat mentions, CRM mentions) | ✅ match | backend/apps/approvals/views.py; backend/apps/operations/views.py; backend/apps/work/views.py; backend/apps/messaging/services.py; backend/apps/comments/services.py; backend/apps/followups/services.py |
| 6 | /notifications exists as a member-facing route in the personal sidebar | ✅ match | frontend/src/pages/NotificationCenterPage.tsx; frontend/src/components/Sidebar.tsx; frontend/src/routes/routeTree.ts; frontend/src/App.tsx |
| 7 | Notification center handles loading, empty, populated, and list-load-error states | ✅ match | frontend/src/pages/NotificationCenterPage.tsx; frontend/src/test/NotificationCenterPage.test.tsx |
| 8 | End-to-end browser flow proves event creation, in-page mark-read, and route navigation back into decisions | ✅ match | frontend/tests/e2e/lumi.spec.ts (decision alert appears in /notifications...) |
| 9 | Preference spine remains shared with F35/F36 under /settings/account | ✅ match | frontend/src/pages/settings/AccountPage.tsx; backend/apps/notifications/views.py |
| 10 | Email-only user-facing member events (brief_digest, billing_receipt, billing_grace) mirror into the ledger; password reset and invitation email remain transport-only by design | ✅ match | backend/apps/notifications/tasks.py; backend/tests/test_email_notifications.py |
| 11 | A floating CommandDock pod carries the live unread-notification count and opens NotificationsPanel (unread-first launcher, mark-read + doorway routing, mark-all, open-full link) on every app screen | ✅ match | frontend/src/components/dock/CommandDock.tsx; frontend/src/components/dock/NotificationsPanel.tsx; frontend/src/test/CommandDock.test.tsx; frontend/src/test/NotificationsPanel.test.tsx |
| 12 | Shared KMP Notification Center screen (NotificationsScreen) mounts on both Android and iOS via the factory pattern; NotificationsScreenModel uses LoadState<NotificationFeedResponse> (fixes native Android isLoading: Boolean violation); focused model and route-action tests cover all state transitions and retry/navigation recovery | ✅ match | android/shared/…/ui/notifications/NotificationsScreen.kt; android/shared/…/presentation/notifications/NotificationsScreenModel.kt; android/shared/…/commonTest/…/NotificationsScreenModelTest.kt; android/shared/…/commonTest/…/NotificationsRouteActionTest.kt; android/shared/…/iosMain/…/MainViewController.kt (makeNotificationsViewController) |
| 13 | Per-channel notification mode has a tenant-scoped model, active-member API, push gate, and native dialog with explicit load/save error states | ✅ match | backend/apps/notifications/models.py; backend/apps/messaging/views.py; backend/apps/notifications/push.py; android/shared/…/MessagesScreenModelChannelNotifications.kt; android/shared/…/ChannelNotificationSettingsDialog.kt |
| 14 | Message and mention routes retain the exact message context and web/native shells resolve message ids before channel fallback; conversation-only routes resolve to typed DM/group-DM screens; native shells map canonical /notifications and legacy /settings/notifications to one typed notification-center destination | ✅ match | backend/apps/messaging/message_notifications.py; frontend/src/pages/connect/MessagesPage.tsx; frontend/src/components/messaging/MessageThread.tsx; android/app/src/main/kotlin/com/locuno/lumi/android/ui/LumiAppRoutes.kt; android/app/src/main/kotlin/com/locuno/lumi/android/navigation/MessageConversationNavKey.kt; android/app/src/main/kotlin/com/locuno/lumi/android/push/NotificationRoute.kt; android/app/src/main/kotlin/com/locuno/lumi/android/push/LumiFirebaseMessagingService.kt; android/app/src/main/kotlin/com/locuno/lumi/android/MainActivity.kt; android/app/src/main/kotlin/com/locuno/lumi/android/ui/LumiAppV2.kt; ios/Lumi/UI/MainTabRoutes.swift; focused backend/web/Android/iOS route tests; managed Android emulator cold-start + warm onNewIntent Maestro proof. Provider delivery, named-device, and production push-tap evidence remain open. |
12. Open Questions / Decisions
- Decision: in-app notifications are canonical and not user-disableable in v1. Preferences govern fanout channels, not whether the ledger exists.
- Decision: route links are stored as app-relative paths so web/mobile can converge on one event contract.
- Open question: whether billing/support/customer-facing alerts should share this exact table or a later actor-type split is needed at scale. Current answer: same table unless proven too noisy.
13. Bug / Change Log
2026-08-27 — Messages and Notifications badges use separate unread streams
Type: Bug fix / cross-platform unread truth (issue #108)
Summary:
- Ordinary channel and DM pushes previously passed through the generic notification-event creator, so the Notifications badge and feed counted messages that belong to Messages.
- Message delivery retains a route-bearing compatibility
NotificationEventand the existingPushLog; F77 filters that category from the member list/read/mark-all projection and excludes it from the inbox aggregate. Channel and conversation UUIDv7 read watermarks remain the source of truth for Messages unread state, with migration0014reconciling old aggregates. - Android and iOS top rails now sum channel and conversation unread projections independently. Foreground message/mention callbacks request a coalesced badge refresh, and FCM/APNs plus the Android foreground notification builder request the default notification sound.
Evidence:
backend/tests/notifications/test_notification_center.pyproves message-row exclusion, mark-all/read isolation, and legacy aggregate behavior.backend/tests/notifications/test_push_assistant_message.pyproves ordinary channel/DM pushes still deliver to the recipient while retaining their hidden route-bearing event; mention pushes retain their canonical event.backend/tests/notifications/test_fcm_delivery.pyandbackend/tests/notifications/test_notification_coverage.pyprove the native sound payload.android/app/src/test/.../MainActivityViewModelTest.ktandandroid/app/src/test/.../NotificationsSourceTest.ktprove the independent Messages badge;ios/UnitTests/Hardening/HardeningShellTests.swiftguards the iOS channel+DM aggregation and throws bridge.
Provider delivery, named-device sound, store binaries, and production observation remain separate evidence dimensions.
2026-08-05 — iOS Messages lifecycle proves channel notification and exact-route recovery
Type: Runtime evidence / Native notification context (F77)
The current-main approved iOS simulator lifecycle now covers the native channel notification choice (including saved-mode reconciliation), group-DM entry, and the exact message notification route in the same fixture-owned run as the roster paths. This confirms the registered iOS interaction boundary without claiming provider delivery or named-device push-tap behavior.
Evidence:
- Flows
141_messages_channel_notifications,142_messages_group_dm, and143_ios_notifications_message_routepassed together with the other three F29 Messages flows in one Maestro process on build185, installed SHA-2567391f2b40350f21017916ab6d275bc677980c786b7be30509a1966259d59b495, approved simulator0A8C774B-C1D3-4A43-816C-81D3D3D849D8. - The complete custody and boundary are retained in the 2026-08-05 iOS runtime record.
Provider delivery, named-device push taps, store binaries, and production transport remain explicit gaps.
2026-08-05 — Web notification Open reconciles unread state
Type: UX/reliability bug fix / Web Notifications
Summary:
- The full notification center’s
Openaction navigated to a valid destination but did not mark an unread event read. Members could therefore follow the alert into the right module and still see the same alert waiting in the unread feed. - Opening an unread routed event now submits the existing mark-read mutation and navigates immediately. A failed mutation uses the existing localized error toast; the destination is still reachable, matching the dock’s responsive doorway behavior.
Evidence:
NotificationCenterPage.test.tsxproves both the ordinary mark-read control and the open-first path invoke the scoped mutation and preserve the resolved route.- Browser runtime and production notification delivery remain separate evidence dimensions.
2026-08-05 — Native channel notification choices use one accessible action
Type: UX/accessibility bug fix / Native Messages
Summary:
- Each channel notification option previously made both its row and nested
RadioButtonindependently clickable. A TalkBack user could encounter duplicate radio semantics, and a touch on the control could dispatch the same save twice. - The option row now owns one
selectableradio target with an explicitRole.RadioButton; the nested visualRadioButtonis state-only (onClick = null). Loading, immediate-save, and exact Retry behavior are unchanged.
Evidence:
MessagesNotificationModeSourceTest.notificationModeDialogExposesOneSelectableRadioTargetPerOptionprotects the single-action source contract.- The 2026-08-05 guarded Android F29 Messages roster lifecycle passed 5/5 flows in one Maestro
process on
emulator-5554with APK SHA-256dfa8f4870a39cd2f9c0297fe647e95d0a8b23ba0d6e43b8917bcd1a433e8cf7d, including opening the dialog, changing a mode, and reconciling the saved response. Named-device accessibility-service and provider-delivery evidence remain separate gaps.
2026-08-04 — Native channel notification routes retain the topic
Type: Bug fix / Android-iOS navigation correctness
Summary:
- A valid
/hub/messages?channel=<id>&topic=<name>doorway was reduced to a channel id by the native shells. The shared Messages model then selected itsgeneraldefault, so a member could tap a channel notification and land in the wrong topic when the message id was absent. - Android’s typed
MessageThreadNavKeyand iOS’sSharedLaneRoutenow carry the topic, while ordinary project/team channel entry points retain the explicitgeneralfallback. The shared KMP host passes the same topic into the initial channel selection on both platforms. - The Android route runner adds an authenticated channel-only injection with a
non-default
launchfixture and asserts both the message and the selected topic context at the composer. Message-id, DM, provider-delivery, named-device, and production push evidence remain separate dimensions.
Evidence:
- Android route and shared model unit tests, the full iOS
UnitTestslifecycle (428 executed, 7 skipped, 0 failures on the approved simulator), Contract Graph validation, and the guarded Android Messages lifecycle (5/5 flows, APK SHA-256e09a9b42d259723620c964081bb53145e6ee10684fa3806802858ed67481cf73) including.maestro/critical/146_android_notifications_channel_topic_route.yaml.
2026-08-04 — Web notification doorways normalize legacy detail routes
Type: Bug fix / Web navigation reliability
Summary:
- Assistant and CRM comment producers emit app-relative paths such as
/crm/companies/<id>/and/crm/deals/<id>/, but the web shell owns the corresponding detail panes through query state on/companies,/deals, and/contacts. Navigating the producer path directly hit TanStack Router’s catch-all and silently landed on Today. - The web center and dock now pass every notification route through one explicit allowlist. Legacy CRM and task detail paths become canonical selected-pane routes; registered message/project routes remain intact; unknown or external paths keep the notification readable but do not render a dead Open action.
Evidence:
frontend/src/test/connect/NotificationRoute.test.tscovers CRM/task mapping, message-context preservation, aliases, and fail-closed external/unknown paths.NotificationCenterPage.test.tsxandNotificationsPanel.test.tsxexercise the same normalization at both web doorways. Browser runtime and production notification delivery remain separate evidence dimensions.
2026-08-04 — Native notification rows use compact icon doorways
Type: UX refinement / Native density
Summary:
- Native notification rows previously rendered a text
Openaction below the body while the entire routed card was already clickable. The duplicate doorway increased row height and made the primary action ambiguous on narrow screens. - Routed rows now keep the full-card tap behavior for the shortest path to the exact destination, and surface a compact arrow icon as the visual affordance. Unread rows use a compact check icon for the housekeeping read action; both controls retain 48 dp touch targets and localized accessibility labels.
Evidence:
NotificationsSourceTestprotects the icon/action wiring and stable test tags.- The shared route-action/model tests still prove allowlist gating and exact retry-before-navigation behavior; named-device and provider push-tap evidence remain separate gaps.
2026-08-04 — Per-channel notification Retry replays the failed save
Type: Bug fix / Native reliability
Summary:
- A failed
all/mentions/mutedsave left the previous successful preference inLoadState.Success. The visible Retry action therefore called the loader, which correctly skipped a fresh request for the cached channel and silently did nothing. - Native state now retains the exact requested mode only after a failed save. Retry replays that one tenant-scoped mutation; success clears the retry intent and reconciles the server response.
Evidence:
MessagesScreenModelChannelNotificationsTest.failedModeUpdateRetainsTheExactModeForRetryproves the previous mode remains visible during failure, the exact requested mode is retained, and one Retry produces the saved response.MessagesNotificationModeSourceTest.failedModeSaveRetainsAnExactRetryIntentprotects the UI/model wiring. Controlled provider outage, named-device, and production push evidence remain separate gaps.
2026-08-04 — Native notification read actions are retryable
Type: Bug fix / Native reliability
Summary:
- The native notification model previously retained only a raw action-error string. When a member’s mark-read or mark-all request failed, the screen had no way to replay the exact mutation, so the user had to leave and return or accept a stale unread state.
- Read mutations now carry a typed action identity (
notification idormark all) through the failure state. The shared screen renders a compact localized Retry action, suppresses duplicate read taps while a request is in flight, and clears the error only after the retry succeeds and the selected feed is reloaded.
Evidence:
- Shared KMP regression coverage proves both exact action variants, localized error/retry copy, retry success, badge-owner callback behavior, and routed-row navigation only after success. Source-contract coverage protects the UI wiring.
- Named-device, provider-delivery, and production notification evidence remain separate gaps.
2026-08-04 — iOS simulator proves notification doorway opens exact message context
Type: Runtime proof / Mobile navigation UX
Summary:
- The shared notification center already carried the server-authorized message route, but the roster lifecycle did not tap a notification row and assert the destination. That left the most important user promise — “open this alert where it happened” — unproven on the native shell.
- Added one flow to the existing single-simulator iOS Messages lifecycle. It opens the seeded notification feed, switches to the all view, taps the routed doorway, and asserts the historical message-context label, exact seeded message, and jump-to-latest affordance.
- Routed notification cards now declare an explicit Button role and localized click label for assistive technologies while preserving the visible Open action.
Evidence boundary:
- This is commit-bound iOS simulator + isolated backend evidence, not named-iPhone, provider delivery, or production push-tap evidence. Those gaps remain explicit.
2026-08-04 — Android cold-start and warm-intent route custody
Type: Runtime proof / Mobile navigation reliability
Summary:
- Android source and JVM tests already proved the allowlist and pending-route state, but they did not prove that a route survives authentication or replaces an existing thread when a second notification arrives.
- The F29 Messages lifecycle now seeds one private channel message and one DM message, injects the
first route before login, and injects the second with
FLAG_ACTIVITY_SINGLE_TOPafter the authenticated shell is visible. Two exact Maestro assertions require the historical channel message and then the conversation message; opening the Messages directory is not accepted. - On 2026-08-04, both route flows passed together with the five existing roster/DM/notification
flows on
emulator-5554in one 4m39s lifecycle; the built and installed prodDebug APK matched SHA-25647a8a39b77220731936a22a06deaac2c40a092918e903d2870f994827b24f27c.
Evidence boundary:
- This is managed-emulator + isolated-backend ADB/Activity handoff evidence with exact APK custody. It models the notification Intent boundary but does not prove Firebase delivery, notification-shade rendering, named-device behavior, or production push authorization.
2026-08-03 — Native notification rows no longer advertise dead destinations
Type: UX fix / Mobile navigation safety
Summary:
- The shared native notification row previously treated any non-empty server route as an actionable Open button. A route could be syntactically present but absent from the active Android or iOS allowlist, leaving the user on the same screen with no explanation after a tap.
- Android and iOS now pass their typed route resolvers into the shared screen. The row shows Open only when that resolver accepts the route; otherwise it keeps the notification readable and renders a localized “no destination” state. Resolver failures also fail closed.
Tests:
- Added shared JVM coverage for blank, accepted, rejected, missing, and throwing resolvers.
- Added Android/iOS source-contract coverage for resolver wiring and the no-destination state.
- The existing guarded iOS Messages lifecycle remains green; provider-delivery and named-device notification-tap proof remain separate gaps.
2026-08-03 — Conversation-only notification routes open the exact DM thread
Type: Bug fix / Native navigation reliability
Summary:
- Message notifications can carry
conversation=<id>without a message id when the event is a conversation-level doorway. Android and iOS previously recognized only message and channel context, so a valid DM/group-DM notification could fall back to the Messages directory instead of opening the intended conversation. - Both native shells now map conversation-only routes to typed route destinations. The shared Messages model resolves the id against the authenticated conversation roster and either opens that exact thread or shows the existing unavailable/back state without guessing another DM.
Tests:
- Added Android and iOS route proofs for conversation-only notification paths.
- Added shared KMP model coverage for exact conversation selection and unavailable-context safety.
- Named-device provider delivery/tap proof and the registered iOS XCTest run remain explicit gaps; source and JVM tests do not claim those runtime states.
2026-08-03 — Android push tap route handoff and notification identity
Type: Bug fix / Mobile reliability
Summary:
- Android’s Firebase service placed the server-owned
/hub/messages?...route on the notificationPendingIntent, butMainActivityignored that extra. Tapping a message notification could therefore launch the app without opening the channel, conversation, or exact message. - Added one shared route-extra/parser, kept the route in Activity state through cold
start or
onNewIntent, and consumed it only after the authenticated V2 shell mounted; consumption clears both Compose state and the backingIntentextra so Activity recreation cannot replay a tap. The shell still uses the existing typed allowlist, so blank or unknown routes are safe no-ops rather than arbitrary URL launches. - Notification ids now prefer the canonical route (then provider push id, then a deterministic fallback), preventing two routed messages with the same localized title/body from overwriting one another.
Tests:
- Added JVM route/identity tests and source-contract tests for the Firebase → Activity → authenticated shell handoff.
- Named-device push delivery/tap proof is still required. A guarded Android Messages lifecycle on 2026-08-03 booted/build-installed the approved emulator and retained non-zero failure artifacts; it did not inject or tap an F77 push, so no notification-route runtime success is inferred here.
2026-08-03 — Native notification filter stale-response guard
Type: Bug fix / Native reliability
Summary:
- The shared notification model cancelled the previous feed job when a member switched between unread and all, but a non-cooperative transport could still complete that cancelled request. Its late response could then replace the visible feed for the newly selected filter.
- Feed loads now carry a monotonically increasing request version and only the latest version for the current status may update the state. Refreshes use the same guard, while cancellation remains structured for cooperative clients.
Tests:
- Added a JVM regression that completes the all response first and the cancelled unread response second; the selected all feed remains visible.
- The existing native notification runtime and provider-delivery gaps remain explicit; the F29 Messages lifecycle does not exercise this notification center race.
2026-08-03 — Canonical notification-center route alias
Type: Bug fix / Mobile navigation
Summary:
- The member-facing notification center is canonical at
/notifications, but the Android and iOS notification route resolvers only recognized the older/settings/notificationsalias. - A valid notification-center doorway could therefore launch the shell and then stop without selecting the notification screen.
- Both native shells now resolve
/notificationsand/settings/notificationsto the same typed notification destination; focused Android and iOS route regressions keep the aliases in lockstep.
Evidence:
- Android:
FieldExecutionRoutingTests.notificationCenterRootRouteUsesCanonicalAndLegacyAliases. - iOS:
WorkspaceTests.test_notificationCenterRootRouteUsesCanonicalAndLegacyAliases. - The exact Android
:app:testProdDebugUnitTest --tests com.locuno.lumi.android.FieldExecutionRoutingTestsrun passed; the guarded iOSUnitTests/WorkBreadthTestsrun passed 23/23 on the single approved simulator after rebuilding the current shared XCFramework. - Named-device/provider notification-tap evidence remains a separate runtime gap.
2026-08-02 UTC — Fail-closed notification route queries
Type: Defensive mobile reliability hardening
Summary:
- The native query parser now rejects malformed percent-encoding, empty query pairs, whitespace-only queries, and decoded-blank keys before allowlist resolution. Invalid push input is a safe no-op instead of silently degrading to the Messages root.
Tests:
- Added JVM regressions covering malformed percent-encoding, empty query pairs, whitespace-only queries, and decoded-blank keys.
2026-08-03 — Native notification route parsers converge on strict ambiguity handling
Type: Defensive mobile reliability hardening
Summary:
- Android and iOS now share the same fail-closed boundary for notification routes: absolute URLs and authority-prefixed paths are ignored, duplicate query keys (including percent-encoded aliases) are rejected, and key-only or blank query values never fall back to a broad directory screen.
- Valid percent-encoded ids still decode to the same typed message destination, preserving exact message/channel/conversation navigation while removing parser disagreement between platforms.
Tests:
- Added Android JVM and iOS XCTest regressions for duplicate/encoded duplicate keys, blank and key-only values, external routes, and percent-decoded message ids.
- Named-device provider delivery/tap and live notification runtime evidence remain explicit gaps;
a guarded generic iOS
build-for-testingcompile passed after the parser fix, but CoreSimulatorService remains unavailable on this host, so no iOS simulator/runtime pass is claimed here.
2026-07-16 — Contract Graph surface identity reconciliation
The shipped read mutation is now declared with the exact registry path
POST /api/notifications/{notification_id}/read/, matching
backend/apps/notifications/urls.py and mark_notification_read. This is an inventory-ownership
correction only. The registered backend proof remains collection-only in this checkout, and browser,
named-device, live-channel, Redis, Sentry, and production evidence remain explicit gaps.
2026-07-16 — Mobile: add deterministic populated-feed parity proof (COMM-06)
Type: Mobile verification hardening
Summary:
- Added stable shared Compose selectors for the unread/all filters, row anatomy, category, title/body, mark-read, mark-all-read, and doorway actions.
- Added
.maestro/critical/66_notifications_populated_feed.yaml, which asserts the current seeded demo feed’s two message events and one decision event, scrolling each loaded feed when a required row is below the fold, then switches to the all-feed view without mutating shared data. - The reusable bell-navigation flow must accept the localized
Notifications/Thông báolauncher and title before the populated-feed flow reaches locale-independent selectors.
CRUD boundary:
- The notification ledger is server-owned and append-first. Mobile intentionally
has no create or delete controls; its write obligation is the existing
mark-one/mark-all-read mutation. This flow leaves that mutation to
critical_62_notification_badge_reconciliation.yaml.
Honest gate:
- Commit-bound Android emulator proof passed 1/1 in 36 seconds on
Lumi_Pixel7_API35from fix commitb238150f780c12879906489d11634855b818708b. The exact 127,183,908-byte dev-debug APK was built and installed with matching SHA-256f2a074ce0ccdb4600b88c8e9686c6aed9086d200fd92c11bf0ce85c6afdd23cb. Durable commands, log, JUnit, and screenshot evidence:/Users/james/.maestro/tests/2026-07-16_150646. - Visual review confirmed the Vietnamese all-feed view shows the unread badge
count of 3, localized message and decision categories, timestamps, bodies,
and the decision row’s
Mởdoorway after below-fold traversal. - This remains emulator UI evidence only. The named Android floor device,
iPhone 12, and PM scoring remain open;
docs/tests/mobile-screen-audit.mdis unchanged. The non-mutating flow does not replace T224’s separate read-state mutation proof.
2026-07-14 — Mobile: restore notification row time, category, and doorway signals (COMM-06)
Type: UX fix (shared KMP notification center)
Summary:
- Notification rows now show a compact localized relative timestamp and localized category label instead of raw backend slugs.
- Routed notifications expose an explicit localized Open action while preserving the existing mark-read and card-tap behavior.
- Added stable row tags and source assertions for the row anatomy.
Scope boundary:
- Source hardening only. COMM-06 populated-feed evidence and named-device scoring remain PM-owned mobile-audit work.
Tests:
git diff --checkPYENV_VERSION=system python3 scripts/check_docs_truth.pybash scripts/check-platform-parity.sh
2026-07-13 — Cursor feed, O(1) unread state, and decoupled storyboard ledger
The feed now traverses immutable UUIDv7 ids without OFFSET and preserves the existing
response fields while adding cursor links plus the app-wide bounded count envelope.
NotificationInboxState maintains one
transactional unread counter per member, so shell polling never runs COUNT(*) over
notification history. StoryboardSend.notification_event is a soft UUID reference,
removing the cross-partition FK between the sent-at storyboard ledger and the
organization-hash notification ledger.
2026-07-02 — §11 Row 5 evidence corrected: CRM mention push dispatched from comments/services.py
Type: Documentation drift fix
Summary:
- §11 Row 5 evidence listed
backend/apps/crm/views.pyfor CRM mentions. That file contains no push notification code. CRM mention pushes are dispatched frombackend/apps/comments/services.py, which mapstarget_type="deal"→module="crm"and callssend_push_to_member. The other five surfaces in Row 5 (approvals/operations/work/messaging/followups) were correct and unchanged.
2026-06-16 — New umbrella notification system spec
Type: Feature
Summary:
- Introduced F77 as the notification-system umbrella over F35 email and F36 push.
- Defined the canonical in-app notification ledger, API contract, user route, and first-slice implementation plan.
Reason:
- Existing email/push/Telegram pieces are transport rails, not a unified notification system.
Impact:
- Future notification work across chat, work, intel, CRM, support, billing, and mobile now has one contract.
Tests:
- Added in §7; implementation begins in the same workstream.
Notes:
- First implementation slice should create in-app events from existing push-triggered categories before broadening to every module.
2026-06-17 — F77 source completion recorded
Type: Feature
Summary:
- Added the member-visible load-error state, targeted notification-center unit coverage, a browser E2E flow for create → read → route-open, and Playwright local-runtime fixes.
- Mirrored user-facing email-only member events (
brief_digest,billing_receipt,billing_grace) intoNotificationEventso email fanout hangs off the same canonical ledger as push.
Reason:
- The feature existed in code but the spec was stale and the browser verification rail was missing.
Impact:
- The current implementation audit distinguishes historical local execution from commit-bound runtime evidence; the Contract Graph keeps runner, browser, native, and live-channel gaps explicit.
Tests:
- Current focused commands and blockers are maintained in the F77 test plan and the Contract Graph.
Notes:
- Isolated
src/test/i18n.test.tsstill fails for a pre-existing cross-localerisks.jsonparity defect (consequence_labelmissing in 54 locales); this is unrelated to F77 and does not contradict notification-center completion.
2026-06-21 — Floating notifications pod in the CommandDock
Type: Feature
Summary:
- Added a third pod to the floating
CommandDock(alongside Chat Lumi and Messages): a bell pod wearing the live unread-notification count from the shareduseUnreadCountsaggregator, opening a newNotificationsPanellauncher in place. NotificationsPanellists unread alerts unread-first; opening a row marks it read and deep-links into the raising module (the F77 doorway), with a mark-all-read action and an “open full” link to/notifications.- Moved the ambient “needs you” unread badge off the Chat Lumi pod (where it had been borrowed) onto the dedicated notifications pod; the Chat pod now carries no badge. Unified the dock’s active-pod close affordance so any open pod shows the X icon.
Reason:
- The unread-notification signal lived only behind a sidebar click (and a borrowed badge on the Chat pod). Parity with Messages/Chat — a floating, count-bearing launcher on every screen — makes “what needs me now” a one-tap habit for the CEO.
Impact:
- Notifications are now reachable and clearable from any app screen without navigating to
/notifications. No backend, route, model, or API change.
Tests:
cd frontend && ./node_modules/.bin/tsc --noEmit→ 0 errorspnpm test src/test/CommandDock.test.tsx src/test/NotificationsPanel.test.tsx src/test/i18n.test.ts→ 9004 passed (incl. full 62-locale key-parity matrix; no new locale key added)node frontend/scripts/check-design-tokens.mjs→ clean
Notes:
- The dock launcher reuses the already-translated
notificationsnamespace verbatim, so no locale fanout was needed and key parity stayed green. - Password reset and invitation email are intentionally excluded from the in-app ledger: password reset is a pure security transport flow, and invitation email often targets a person who is not yet an
OrganizationMember.
2026-06-24 — Shared KMP Notification Center screen (Android + iOS)
Type: Mobile improvement
Summary:
- Migrated the Notification Center screen to a shared KMP Compose screen in
commonMain, eliminating separate Android (NotificationsViewModel+NotificationsScreen) and iOS (NotificationCenterScreen.swift) implementations. NotificationsScreenModelusesLoadState<NotificationFeedResponse>(fixing a merge-blockingisLoading: Booleanviolation in the Android-nativeNotificationsViewModel).- Both shells now mount the shared screen: Android via
entry<NotificationsNavKey>inLumiApp.kt; iOS viamakeNotificationsViewControllerfactory inMainViewController.kt+case .notificationsinSharedScreenBridge.swift. - Unread/all tab filter, per-item mark-read, mark-all-read, pull-to-refresh, skeleton loading, empty state, and action-error banner all live in the shared screen.
Reason:
- Two native implementations with diverging state models (including a
LoadState<T>contract violation on Android) were a structural drag on every notification feature going forward.
Impact:
- Deleted 3 platform files (~350 lines); added 1 shared screen + ScreenModel + 12 unit tests.
- Both Android and iOS now use a single, tested implementation.
Tests:
cd android && ./gradlew :shared:testReleaseUnitTest— 12 newNotificationsScreenModelTestcases pass.
Notes:
- API DTOs (
NotificationFeedResponse,NotificationEventResponse) and client methods were already in shared KMP; no backend or API change needed.