shipped in source; contract status partial pending runtime evidence

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 general only when omitted) and a conversation-only route opens the exact authorized DM/group-DM thread.
  • 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.
  • 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, or muted) for active message-channel memberships. This is a bounded delivery override, not a general per-object subscription engine.

4. Data Model

  • NotificationPreference remains 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, channel
    • mode: all | mentions | muted (default all)
    • organization + member + channel is unique; channel membership remains the authorization source.
  • NotificationEvent — new tenant-owned model in apps.notifications:
    • extends UUIDModel
    • organization FK
    • member FK
    • category: approval | decision | brief | followup | mention | work | crm | support | billing | system
    • module: intel | work | crm | connect | hub | support | settings | billing
    • title, body
    • route (frontend deep-link path)
    • payload_json (small structured context, optional)
    • dedupe_key (optional string; used to collapse obvious retries)
    • read_at nullable timestamp
  • Ordinary message rows 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

MethodPathAuth/RoleRequestResponseErrors
GET/api/notifications/member?status=unread|all&limit=50&cursor=<opaque>{results, unread_count, next, previous}; immutable UUIDv7 -id cursor400 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/memberexisting F35/F36 contractexisting preference objectunchanged
GET/PATCH/api/messages/channels/{channel_id}/notification-preference/active channel member`{mode: allmentionsmuted}` 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 (UnreadBadge dot, shared useUnreadCounts); opening it reveals NotificationsPanel — 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 uses CommandDock, NotificationsPanel, UnreadBadge.
  • i18n namespace: notifications for page copy; sidebar gains notifications. The dock launcher reuses the existing notifications namespace 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/notifications alias 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

#CaseTypeWhere
1Push-triggered alert creates one in-app NotificationEvent even with multiple device tokensbackendbackend/tests/test_push_notifications.py
2Quiet-hours-suppressed push still creates unread in-app notificationbackendsame
3GET /api/notifications/ returns only current member rows and unread countbackendbackend/tests/test_notification_center.py
4POST /api/notifications/<id>/read/ marks own row read; cross-member/cross-tenant returns 404backendsame
5POST /api/notifications/mark-all-read/ marks only current member unread rowsbackendsame
6/notifications renders unread feed, marks an item read in place, and routes back into the target modulefrontend/unit + E2Efrontend/src/test/NotificationCenterPage.test.tsx; frontend/tests/e2e/lumi.spec.ts
7CommandDock renders the notifications pod, surfaces the unread count in its aria label, and opens NotificationsPanelfrontend/unitfrontend/src/test/CommandDock.test.tsx
8NotificationsPanel shows the caught-up empty state, marks a row read + routes through its doorway on open, and clears all from mark-allfrontend/unitfrontend/src/test/NotificationsPanel.test.tsx
9Active member reads/updates only their own channel mode; invalid modes and non-members fail closedbackendbackend/tests/notifications/test_messaging_channel_notifications.py
10Muted/mentions-only modes suppress the correct push fan-out while keeping Messages watermark state and direct mention events separatebackendsame
11Native channel header opens the three-mode dialog and reconciles a successful update or retryable errorshared KMP unit/sourceandroid/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)
12Message 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 threadbackend + web/Android/iOS unitbackend/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
13Native unread/all filter switches keep only the latest response when an earlier request returns lateshared KMP unitandroid/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/notifications/NotificationsScreenModelTest.kt
14Native 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 ownershared KMP unit + source contractandroid/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/notifications/NotificationsScreenModelTest.kt; scripts/tests/test_f77_notification_source.py
15Ordinary message pushes stay out of the notification center; Android/iOS Messages badges sum channel + DM unread watermarks; native provider payloads request the default soundbackend + Android/iOS unit/sourceF77.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; message rows are excluded from the member feed and unread aggregate.
  • Frontend: /notifications route 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/account still owns email/push fanout via NotificationPreference; in-app visibility remains always-on.
  • Channel delivery: the selected channel’s native header owns a per-member all / mentions / muted push 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/notifications remains 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_error toast.
  • 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_error toast.
  • 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 NotificationEvent to ER diagram and extend notification flow
  • frontend/DIAGRAMS.md: add /notifications route
  • F35 and F36: they become channel specs under F77’s umbrella, not standalone system definitions

11. Implementation Audit

#ClaimStatusEvidence
1Canonical NotificationEvent ledger model exists in apps.notifications✅ matchbackend/apps/notifications/models.py
2Shared event creation helper dedupes unread retries✅ matchbackend/apps/notifications/event_service.py
3Push 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 projectionbackend/apps/notifications/push.py; backend/apps/messaging/message_notifications.py; backend/tests/notifications/test_push_assistant_message.py
4Member APIs are tenant/member scoped for list, mark-read, and mark-all-read✅ matchbackend/apps/notifications/views.py; backend/tests/test_notification_center.py
5Existing high-value surfaces route into the shared contract (approvals, decisions, follow-ups, work mentions, chat mentions, CRM mentions)✅ matchbackend/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✅ matchfrontend/src/pages/NotificationCenterPage.tsx; frontend/src/components/Sidebar.tsx; frontend/src/routes/routeTree.ts; frontend/src/App.tsx
7Notification center handles loading, empty, populated, and list-load-error states✅ matchfrontend/src/pages/NotificationCenterPage.tsx; frontend/src/test/NotificationCenterPage.test.tsx
8End-to-end browser flow proves event creation, in-page mark-read, and route navigation back into decisions✅ matchfrontend/tests/e2e/lumi.spec.ts (decision alert appears in /notifications...)
9Preference spine remains shared with F35/F36 under /settings/account✅ matchfrontend/src/pages/settings/AccountPage.tsx; backend/apps/notifications/views.py
10Email-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✅ matchbackend/apps/notifications/tasks.py; backend/tests/test_email_notifications.py
11A 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✅ matchfrontend/src/components/dock/CommandDock.tsx; frontend/src/components/dock/NotificationsPanel.tsx; frontend/src/test/CommandDock.test.tsx; frontend/src/test/NotificationsPanel.test.tsx
12Shared 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✅ matchandroid/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)
13Per-channel notification mode has a tenant-scoped model, active-member API, push gate, and native dialog with explicit load/save error states✅ matchbackend/apps/notifications/models.py; backend/apps/messaging/views.py; backend/apps/notifications/push.py; android/shared/…/MessagesScreenModelChannelNotifications.kt; android/shared/…/ChannelNotificationSettingsDialog.kt
14Message 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✅ matchbackend/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 NotificationEvent and the existing PushLog; 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 migration 0014 reconciling 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.py proves message-row exclusion, mark-all/read isolation, and legacy aggregate behavior.
  • backend/tests/notifications/test_push_assistant_message.py proves 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.py and backend/tests/notifications/test_notification_coverage.py prove the native sound payload.
  • android/app/src/test/.../MainActivityViewModelTest.kt and android/app/src/test/.../NotificationsSourceTest.kt prove the independent Messages badge; ios/UnitTests/Hardening/HardeningShellTests.swift guards 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, and 143_ios_notifications_message_route passed together with the other three F29 Messages flows in one Maestro process on build 185, installed SHA-256 7391f2b40350f21017916ab6d275bc677980c786b7be30509a1966259d59b495, approved simulator 0A8C774B-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 Open action 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.tsx proves 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 RadioButton independently 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 selectable radio target with an explicit Role.RadioButton; the nested visual RadioButton is state-only (onClick = null). Loading, immediate-save, and exact Retry behavior are unchanged.

Evidence:

  • MessagesNotificationModeSourceTest.notificationModeDialogExposesOneSelectableRadioTargetPerOption protects 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-5554 with APK SHA-256 dfa8f4870a39cd2f9c0297fe647e95d0a8b23ba0d6e43b8917bcd1a433e8cf7d, 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 its general default, so a member could tap a channel notification and land in the wrong topic when the message id was absent.
  • Android’s typed MessageThreadNavKey and iOS’s SharedLaneRoute now carry the topic, while ordinary project/team channel entry points retain the explicit general fallback. 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 launch fixture 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 UnitTests lifecycle (428 executed, 7 skipped, 0 failures on the approved simulator), Contract Graph validation, and the guarded Android Messages lifecycle (5/5 flows, APK SHA-256 e09a9b42d259723620c964081bb53145e6ee10684fa3806802858ed67481cf73) 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.ts covers CRM/task mapping, message-context preservation, aliases, and fail-closed external/unknown paths.
  • NotificationCenterPage.test.tsx and NotificationsPanel.test.tsx exercise 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 Open action 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:

  • NotificationsSourceTest protects 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 / muted save left the previous successful preference in LoadState.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.failedModeUpdateRetainsTheExactModeForRetry proves the previous mode remains visible during failure, the exact requested mode is retained, and one Retry produces the saved response.
  • MessagesNotificationModeSourceTest.failedModeSaveRetainsAnExactRetryIntent protects 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 id or mark 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_TOP after 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-5554 in one 4m39s lifecycle; the built and installed prodDebug APK matched SHA-256 47a8a39b77220731936a22a06deaac2c40a092918e903d2870f994827b24f27c.

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 notification PendingIntent, but MainActivity ignored 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 backing Intent extra 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/notifications alias.
  • A valid notification-center doorway could therefore launch the shell and then stop without selecting the notification screen.
  • Both native shells now resolve /notifications and /settings/notifications to 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.FieldExecutionRoutingTests run passed; the guarded iOS UnitTests/WorkBreadthTests run 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-testing compile 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áo launcher 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_API35 from fix commit b238150f780c12879906489d11634855b818708b. The exact 127,183,908-byte dev-debug APK was built and installed with matching SHA-256 f2a074ce0ccdb4600b88c8e9686c6aed9086d200fd92c11bf0ce85c6afdd23cb. 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.md is 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 --check
  • PYENV_VERSION=system python3 scripts/check_docs_truth.py
  • bash 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.py for CRM mentions. That file contains no push notification code. CRM mention pushes are dispatched from backend/apps/comments/services.py, which maps target_type="deal" → module="crm" and calls send_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) into NotificationEvent so 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.ts still fails for a pre-existing cross-locale risks.json parity defect (consequence_label missing 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 shared useUnreadCounts aggregator, opening a new NotificationsPanel launcher in place.
  • NotificationsPanel lists 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 errors
  • pnpm 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 notifications namespace 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.
  • NotificationsScreenModel uses LoadState<NotificationFeedResponse> (fixing a merge-blocking isLoading: Boolean violation in the Android-native NotificationsViewModel).
  • Both shells now mount the shared screen: Android via entry<NotificationsNavKey> in LumiApp.kt; iOS via makeNotificationsViewController factory in MainViewController.kt + case .notifications in SharedScreenBridge.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 new NotificationsScreenModelTest cases pass.

Notes:

  • API DTOs (NotificationFeedResponse, NotificationEventResponse) and client methods were already in shared KMP; no backend or API change needed.