partial — Contract Graph migration complete; mobile runtime and live FCM evidence remain explicit gaps

Spec: F36 — Mobile Push Notifications

Status: partial — Contract Graph migration complete; mobile runtime and live FCM evidence remain explicit gaps

F77 relationship: F36 is the push channel spec under F77 Unified notification center. Push is a fanout rail; the canonical member-visible notification ledger now lives in F77.

Contract Graph: contracts/features/F36.cue · implementation audit. Backend device, routing, quiet-hours, audit, and localization behavior is contract-backed. This does not establish named-device delivery, push-tap runtime, live FCM acceptance, production runtime verification, or Sentry operation.

1. Problem & CEO Job

Decide fast. F30 puts the control loop in the CEO’s pocket; an app they must remember to open is half a product. Push turns a waiting approval into a ten-second decision between meetings, carrying only the executive control loop, never activity noise. Every-task updates make users disable it; a push earns retention only when its recipient can unblock someone.

Restraint doctrine (binding): every push answers “what action does the recipient take?” If the answer is “none, it’s FYI,” it belongs in the Brief or a digest.

2. User Stories & Acceptance Criteria

  • As an approver, I want a push when an approval lands in my queue, so that I unblock my team in seconds.

    • Given I am the current approver and push enabled, when an ApprovalRequest enters my queue, then one push arrives with title + amount, deep-linking to the approval detail.
    • Given I act on it from elsewhere (web), when the app receives the tap later, then it shows the latest state (409-refresh rule from F30), never a stale action.
  • As a CEO, I want a push when my Brief is ready, so that the morning ritual starts itself.

    • Given push digest enabled, when the Brief generates at org brief_time, then one push arrives (“Bản tin sáng đã sẵn sàng — N mục”) deep-linking to /intel/brief; quiet days send the positive quiet state or nothing per preference.
  • As a decision requester’s CEO, I want a push for new decision requests, so that “waiting on the boss” is measured in minutes.

    • Given an open DecisionRequest assigned to me, when it is created, then one push arrives, deep-linking to decision detail.
  • As a follow-up sender, I want a push when a follow-up gets a reply, so that the chase loop closes without polling.

    • Given my FollowUpRequest receives a reply (F31), when it lands, then one push arrives, deep-linking to the originating item.
  • As a mentioned member, I want a push for @mentions in Messages, so that direct asks reach me.

    • Given an @mention of me (F29) and mentions enabled, when the message posts, then one push arrives; @channel blasts do not push (digest-only).
  • As any user, I want quiet hours and per-category control, so that Lumi respects my evenings.

    • Given quiet hours set (default 21:00–07:00 org timezone), when a push triggers inside them, then it is delivered silently-collapsed at quiet-hours end (single summary), except approvals marked urgent.
    • Given a category disabled, when its trigger fires, then no push and no error.
    • Given I log out or my token is invalidated by FCM/APNs feedback, then the device token is deactivated and never retried.

3. Scope Boundaries

  • In: device-token registration through one provider path: iOS APNs via FCM and Android FCM; approval-in-queue, decision-requested, brief-ready, follow-up-replied, message, and @mention pushes; quiet hours; F35-shared per-category preferences; delivery audit log. Ordinary message pushes retain route-bearing compatibility events for audit/deep-link correlation but remain watermark-owned by F29, while F77 owns notification-center visibility.
  • Out: task, risk, and commitment event pushes that the Brief should aggregate; marketing pushes (never); web push until demand is proven; F77’s in-app notification center; Telegram (F21); email (F35).

4. Data Model

  • DeviceToken — extends UUIDModel, organization FK, member FK, platform (ios|android), token (unique), is_active, last_seen_at. Multiple devices per member allowed; tokens deactivated on FCM/APNs invalid-token feedback and on logout.
  • NotificationPreference — F35’s shared model gains push columns: push_approvals, push_decisions, push_digest, push_followups, push_mentions, quiet_hours_start, quiet_hours_end. One spine, two channels — adding a third preference model is a spec violation.
  • PushLog — extends UUIDModel, organization FK, member, category, collapsed flag, summary_sent_at (set when quiet-hours summary processes this log), provider message id, status (queued/sent/failed/suppressed), error text. Append-only.

5. API Contract

MethodPathAuth/RoleRequestResponseErrors
POST/api/devices/register/member{platform, token}201/200 idempotent400 bad platform
POST/api/devices/unregister/authenticated user{token} plus X-Organization-ID204Idempotent; only the exact authenticated-user/org/token binding can be mutated, including when membership is unavailable but the binding is retained for late cleanup
GET/PATCH/api/notification-preferences/me/membershared with F35preference object401

Sending is server-side only (FCM HTTP v1 from Celery tasks hooked into the existing creation paths for approvals/decisions/brief/follow-ups/messages/mentions). Tenant scoping: tokens and logs filter by organization_id=request.organization_id; a member’s push for org A never fires from org B events even on the same device. Normal provider-attempt payloads carry the resolved route, category, lumi_push_log_id, and the server-authored lumi_organization_id snapshot; native alert payloads request the default sound. Receipt clients use that snapshot as their organization scope, and the API rejects a body/header mismatch. Active/non-active organization delivery remains a named-device proof gap rather than being inferred from source.

No AI output — no EvidenceSource involvement.

6. UI / UX

  • Mobile (F30 platforms): show a localized permission primer after authentication and the system prompt only after explicit continuation, never cold. A handled decision survives process recreation; Workspace › Settings keeps the operating-system notification-access route; deep links use existing navigation (Rule of Two intact; F30 §6.3 applies).
  • Web: /settings reuses F35’s notification-preferences card with a push column, so an admin can help a member fix settings.
  • i18n: push copy uses the member’s chosen language; namespaces notifications (new) on mobile, settings keys on web.
  • Earned quiet-hours summary (retention): the morning collapsed summary push must earn the open, not train the swipe-away. build_quiet_hours_summary(categories, locale) names the highest-stakes suppressed buckets — decisions (decision/approval), replies (followup), mentions (mention) — e.g. Sáng nay cần bạn / 2 quyết định · 1 trả lời chờ bạn (vi), capped to the top two buckets; when only routine items (brief) were suppressed it stays honest (Thông báo đang chờ / Bạn có N thông báo đang chờ), never manufactured urgency. Copy is rendered per recipient in their User.language via the shared notification i18n catalog (keys count.*, push.quiet.*) — never hardcoded.
  • Earned brief-ready push body (retention): the daily morning brief-ready push is the highest-frequency CEO-habit surface in the product — its body must name the day’s actual stakes, not a flat count, or the daily channel trains the CEO to swipe it away. build_brief_push_body(items, locale) names the top two non-zero buckets from the brief’s items — decisions (decision/approval), risks (risk/cash/customer), overdue (overdue/commitment) — e.g. body 2 quyết định · 1 rủi ro cần bạn (vi) / 2 decisions · 1 risk need you (en); routine-only items (opportunities, people, tasks, summaries, deals) stay honest ({N} mục cập nhật); empty preserves the “nothing today” copy. The title stays Bản tin sáng đã sẵn sàng / Your morning brief is ready (the channel-identifying hook the CEO learned to recognize) — only the body changes so the channel keeps its identity while the open is earned. Rendered per recipient in their User.language via the shared catalog (keys count.*, push.brief.*). Mirrors build_brief_digest_subject (F35) and the body half of build_quiet_hours_summary (this spec) — together those three are the brief-channel earned- framing trifecta across digest email, quiet-hours collapsed summary, and live brief-ready push.

7. Test Plan

#CaseTypeWhere
1Token register/unregister idempotency; own-tokens-only; cross-tenant non-enumerating 204backendbackend/tests/notifications/test_push_tokens.py + backend/tests/notifications/test_device_registration.py
2Each category trigger fires exactly one push to active tokens (FCM mocked)backendsame
3Disabled category → suppressed, logged, no errorbackendsame
4Quiet hours: collapse to single summary at window end; urgent approval bypassesbackendsame
4bEarned quiet-hours summary copy: build_quiet_hours_summary leads with top-two stakes (decisions/replies/mentions), caps to two buckets, honest routine-only fallback, empty → fallback; summary push body names stakes not a flat countbackendsame
5Invalid-token feedback deactivates DeviceToken, no retrybackendsame
8bEarned brief-ready push body: build_brief_push_body leads with top-two stakes (decisions/risks/overdue), caps to two buckets, honest routine-only fallback ({N} mục cập nhật), empty → existing Hôm nay không có mục nào; brief-ready push body names stakes not a flat count; title Bản tin sáng đã sẵn sàng unchanged (channel identity preserved)backendsame
6@channel does not push; @mention doesbackendsame
7Push tap deep-links to correct screen with org re-scopeplatform UIiOS + Android test targets (F30 §7 style)
7bAssistant action notification resolves one unambiguous tenant-owned executed result; foreign, pending, or multi-destination turns fall back to the exact originating threadbackend + frontendTestAssistantNotificationRoutes · NotificationCenterPage.test.tsx
7cDevice payload carries the same canonical route/category as the in-app eventbackendtest_assistant_push_carries_resolved_route_to_device
8Preferences grid on web settingse2efrontend/tests/e2e/notifications.spec.ts (shared with F35)
9Fresh iOS launch requests the FCM token only after the APNs registration callback binds the device tokeniOS unitPushNotificationCoordinatorTests.testFCMTokenLookupWaitsForAPNSRegistrationCallback
10Native alert payloads request the default sound while preserving route and receipt correlationbackend + Android/iOS sourceF77.AC007.PUSH_SOUND; Android LumiFirebaseMessagingService source proof

Backend coverage stays ≥99%; FCM is always mocked in tests (deterministic, no network).

8. Current Behavior

Shipped. DeviceToken, PushLog, NotificationPreference (shared F35 spine) in apps/notifications/. FCM send path wired via services.py::send_push_notification. Frontend: device registration on login, notification preferences card reused from F35. On iOS, authorized startup requests APNs registration; the APNs callback binds the device token to Firebase, whose MessagingDelegate callback owns server registration of current and refreshed FCM tokens. Ordinary message pushes stay PushLog-only and use F29 channel/DM watermarks for unread state; F77 filters legacy message rows from its center. Native provider and foreground payloads request the default sound, and message/mention callbacks trigger a coalesced shell badge refresh. Backend device and push proofs live in backend/tests/notifications/test_device_registration.py and backend/tests/notifications/test_push_tokens.py.

9. Error States

  • FCM send failure: Celery retry with backoff (max 3) for transient errors; permanent errors (unregistered token) deactivate the token immediately; PushLog.status=failed with error text; triggering transaction never blocks.
  • FCM credential failure: backend deploys load the exact environment-scoped Secrets Manager value, validate the service-account fields, and acquire a Firebase Messaging OAuth token before ECS rollout. Malformed, incomplete, expired, or revoked credentials block deployment without sending a notification or exposing the secret.
  • Permission denied on device: app records it, shows no nagging — one re-ask affordance in preferences, never a launch interstitial.
  • iOS APNs/FCM bootstrap: after authorization, request APNs remote-notification registration first. Fetch the FCM token only after didRegisterForRemoteNotificationsWithDeviceToken binds that APNs token to Firebase; normal startup must not emit Firebase com.google.fcm code 505 (No APNS token specified before fetching FCM Token).
  • Quiet-hours suppression: PushLog.status=suppressed, collapsed summary at window end.
  • Logging: every attempt → PushLog; failures also to Sentry.

10. Diagrams & Docs Touched

  • backend/DIAGRAMS.md: notification flow (shared diagram with F35) + ER additions.
  • docs/specs/F30-mobile.md: §3 matrix gains a push row; P13 gains a PR for client-side registration + deep links — update in the implementing PR.
  • deploy/AGENTS.md: FCM_SERVICE_ACCOUNT_JSON env.
  • GLOSSARY: DeviceToken, PushLog, quiet hours.

11. Implementation Audit (2026-06-12, updated 2026-06-13)

#ClaimStatusEvidence
1DeviceToken, PushLog, NotificationPreference (shared F35 spine) in apps/notifications/✅ match9 source files confirmed; 3 migrations; test_device_registration.py + test_push_tokens.py
2Register/unregister endpoints + multi-device-per-member support✅ match§13 2026-06-12: UniqueConstraint(org, member, token); 13 tests in test_device_registration.py
3Brief-ready push wired into generate_daily_briefs_for_time✅ matchapps/briefs/tasks.py; test_generate_daily_briefs_triggers_digest_email_and_push
4Per-event triggers wired (approvals, follow-up replies, @mentions, decisions, signal proposals, AI decisions)✅ match§13 2026-06-12: 6 creation paths; F36-20–F36-27 (8 tests)
5Real FCM HTTP v1 delivery (send_fcm_push via OAuth2 + urllib.request)✅ match§13 2026-06-12 A-r17; test_fcm_delivery.py (3 tests); FCMInvalidTokenError on 404
6Quiet-hours collapsed summary (send_quiet_hours_summary + Celery beat hourly)✅ match§13 2026-06-12; F36-28–F36-31 + edge cases; summary_sent_at marks processed
7@channel does not push; @mention does✅ matchapps/messaging/services.py; F36-20–F36-27
8Web preferences card (shared with F35) in /settings✅ match§13 2026-06-12; E2E notifications.spec.ts (shared with F35)
9Mobile client: iOS/Android FCM registration, deep links, onboarding screen 5 primer⚠️ partialProd Android and iOS client configs point to lumi-ai-workspace; Android build types now share one stable app ID per prod/dev flavor; dev Android still uses an explicit non-live placeholder. Primer source proof plus iOS/Android server-emitted route matrix are shipped; same-day named-device delivery, permission walkthrough, and deleted/inaccessible-resource fallback remain open
10Quiet hours spanning midnight correctly suppress local interval when workers run UTC✅ matchE3 boundary proof in test_is_in_quiet_hours_spanning_midnight_boundaries; quiet_hours_end comparison done in send_quiet_hours_summary_all_orgs

Summary: 9/10 match; 1 gap (mobile client and live-delivery evidence remain open).

Edge Cases Catalog (formerly §9, 4 items): E1 multiple devices receive on each active device without resurrecting revoked tokens (✅ UniqueConstraint + is_active flag + FCMInvalidTokenError deactivates immediately); E2 provider-invalidated tokens removed immediately (✅ send_fcm_push raises FCMInvalidTokenError on 404 → deactivates); E3 quiet hours spanning midnight (✅ deterministic 21:00–07:00 boundary proof); E4 deep links to deleted/re-scoped resources degrade to safe fallback (gap — deferred to P13 mobile client work).

12. Open Questions / Decisions

◨ TASK — Configure FCM in the deployed environments (server config DONE; end-to-end verify pending)

The push code path is complete and tested, and the FCM credential is now wired through CI. It takes effect on the next deploy to deploy/backend-{dev,prod}. Mechanism: this repo injects secrets as env vars at deploy time (the deploy workflow’s jq -cn --arg env builder), not via Secrets Manager — so FCM follows the same path as GEMINI_API_KEY/DATABASE_URL.

  • Firebase project lumi-ai-workspace (FCM HTTP v1); service-account key generated.
  • Key stored as GitHub secrets FCM_SERVICE_ACCOUNT_JSON_DEV / _PROD (encrypted; never committed).
  • Injected into the ECS task-def env by deploy-dev.yml + deploy-prod.yml (the jq --arg fcm_json builder — verified: real key → valid task-def JSON → json.loads round-trips, private key intact).
  • Latent bug fixed: FCM_SERVICE_ACCOUNT_JSON now defined in settings/base.py (from env). Previously push.py’s getattr(settings, …) was always None, so push never sent regardless.
  • Deploy to dev then prod (push the verified sha to deploy/backend-{dev,prod}) so ECS picks up the new task-def env. Owner/timing: user.
  • Register a real device and verify a PushLog row goes sent, not failed — this needs the iOS/Android client-side FCM registration (Audit row 9, P13/F30), still the separate deferred gap.
  • Register the shared Android Firebase app as com.lumi.ai; both backend flavors use the checked-in client config for that exact install identity.
  • Verify provider acceptance and named-device delivery after the replacement Play build is installed; source/config alignment is not delivery proof.
  • Confirm the check-push-failure-rate beat (Sentry alert ≥80% failure/30 min) is live so a future mis-config surfaces loudly (config/settings/base.py).

Env var documented in deploy/AGENTS.md → Environment Variables.

Decisions

  • iOS via FCM (one provider path) vs direct APNs: default FCM for both — one credential, one send path; revisit only if FCM’s APNs relay shows real delivery problems.
  • Urgent-approval bypass of quiet hours: default on; org-configurable later only if asked.
  • Badge counts: F77 owns the actionable notification badge; F29 owns the Messages badge from channel/conversation read watermarks. The app-icon badge remains deferred by the F36 v1 scope.

13. Bug / Change Log

2026-08-27 — Keep message delivery sound and unread ownership explicit

F36 message pushes remain a provider transport rail and now request the default native sound. Their route-bearing compatibility events remain outside the member notification-center projection; F29 channel/conversation watermarks drive the Messages badge, while F77 filters message rows from the notification feed and aggregate. See F77.AC007.* for the cross-platform proof boundary.

2026-08-23 — Keep stale FCM sender mismatches out of error telemetry

Type: Observability Fix

Summary:

  • Exact SENDER_ID_MISMATCH responses still deactivate only the stale device token, but the cleanup path now logs at warning level instead of emitting a Sentry error.

Reason:

  • After the dev Firebase credential was corrected, residual tokens from the previous sender kept reopening LUMI-BE-1S even though the code safely deactivated each token.

Impact:

  • Generic provider failures remain error-level and reportable; exact stale-token cleanup remains visible without paging/error telemetry noise.

Tests:

  • test_send_fcm_push_sender_id_mismatch_deactivates_token asserts deactivation, diagnostic PushLog.error_text, warning-level cleanup, and no error-level record.

2026-08-22 — Select iOS Firebase configuration from the archived bundle identity

Type: Bug Fix

Summary:

  • iOS push bootstrap now selects the Firebase plist from the runtime bundle identifier instead of relying only on the LUMI_DEV compile-time flag.

Reason:

  • Sentry LUMI-IOS-T recorded five production info events from dev build 1.0.1 (199) where the expected Firebase resource was not selected, disabling push bootstrap for two users.

Impact:

  • Dev archives use GoogleService-Info-dev.plist for com.locuno.lumi.dev; production archives continue to use GoogleService-Info.plist for com.locuno.lumi.ios. No token or tenant data is changed.

Tests:

  • PushNotificationCoordinatorTests.testFirebaseConfigurationIsSelectedPerReleaseIdentity covers both compile-time compatibility and runtime bundle-identity selection.

Notes:

  • The signed archive release scripts still validate plist bundle/project identity before upload.

2026-08-22 — Explicit login abandons unfinishable stale Keychain cleanup

Type: iOS authentication availability correction

Summary:

  • An explicit native or shared-auth login gives pending terminal cleanup one final attempt. If it still cannot finish, the login boundary clears the durable and in-memory stale cleanup state and commits the accepted replacement credentials instead of denying every later login.
  • The lower-level credential commit remains guarded; only the explicit user-login paths own this recovery. Session restoration and background cleanup remain fail closed.
  • This deliberately gives up future retry of the older logout record. Retrying an account-wide logout after replacement credentials exist is unsafe because the backend logout contract revokes all active refresh sessions; preserving login availability and preventing delayed revocation of the replacement session is the chosen boundary.
  • Behavioral XCTest, a registered source-policy regression, and the F01/F36 CUE truth now encode this decision so a generic fail-closed hardening pass cannot silently restore the permanent-login- denial bug.

Verification boundary:

  • Focused XCTest proves stale durable/in-memory state is cleared, accepted credentials commit, no delayed cleanup is scheduled, and no false authentication failure is published. A signed reinstall journey on the reporting iPhone remains tracked by F01.GAP010.

2026-08-15 — Restore Celery beat routing for notification health tasks

Type: Production worker reliability fix

Summary:

  • The production worker logged Received unregistered task for the quiet-hours summary, email-failure-rate, and push-failure-rate beat entries. The implementations are decorated in apps.notifications.health_tasks; apps.notifications.tasks only re-exports them for the legacy Python import path, which does not create alternate Celery task names.
  • Beat now uses the decorated apps.notifications.health_tasks.* names. A registry-vs-schedule regression test prevents this compatibility-import mistake from returning.
  • The live incident was a scheduling/observability gap: the worker stayed healthy, but those three scheduled runs were discarded by Celery. No tenant, authorization, or notification-log data was changed by the dropped health checks.

Evidence boundary:

  • On ubuntu@15.235.180.72, lumi-celery-worker logged one occurrence of each unregistered task at the 2026-08-15 17:00 container-local beat tick. Container health and celery inspect ping prove liveness only; they do not prove that every beat task name is registered.
  • Focused backend tests prove the schedule strings match the decorated task registry. Live recovery requires the merged image to roll through deploy-worker.yml and a subsequent beat tick to show no unregistered-task log.

2026-07-20 — FCM-01: block malformed Firebase credentials before deployment

Type: Production push-delivery reliability

Contract:

  • Dev and production backend deploys depend on a read-only FCM credential smoke gate.
  • The gate validates the exact AWS Secrets Manager value and proves a scoped Google OAuth exchange.
  • It never prints the service-account credential or sends a notification.

Evidence boundary:

  • Sentry issue LUMI-BE-1S recorded four recipient-level failures during one successful channel-message request because the production secret was a one-byte invalid value.
  • Restored AWS secret versions and successful OAuth exchange prove credential recovery. ECS steady state and an actual post-recycle push remain separate runtime checks.

2026-07-20 — NTF-01: serialize the iOS APNs-to-FCM token handshake

Type: iOS push registration correctness

Contract:

  • An authorized installation starts with registerForRemoteNotifications() and does not race an FCM token lookup against the asynchronous APNs registration callback.
  • didRegisterForRemoteNotificationsWithDeviceToken binds the APNs token to Firebase; Firebase then delivers the current or refreshed FCM token through MessagingDelegate for registration.
  • Token acquisition and later Firebase rotations continue through MessagingDelegate; permission prompting, server registration, receipt handling, and logout cleanup remain unchanged.

Evidence boundary:

  • Sentry issue LUMI-IOS-R captured Firebase code 505 on production build 150, 81 ms after app startup, with Firebase’s token manager reporting that no APNs token had been specified.
  • XCTest protects the callback ordering. A named-device registration and controlled delivery are still required before the existing live-delivery gap can close.

2026-07-16 — NTF-02: route iOS notification taps into the five-tab shell

Type: iOS navigation correctness

Contract:

  • A notification tap records its correlated receipt and forwards a non-blank server route into the authenticated shell before returning control to iOS.
  • The shell selects the route’s owning tab and replaces that tab’s navigation path with the exact native destination. A cold-start tap remains pending until the shell mounts.
  • Missing or unknown routes are consumed as safe no-ops; they never guess a destination or crash the app.

Verification boundary:

  • XCTest covers tap dispatch, route extraction, shell-tab ownership, and unknown-route behavior.
  • A signed simulator simctl push journey may prove UI routing only. Named-device delivery, deleted/inaccessible-resource fallback, and PM scoring remain open.

2026-07-17 — NTF-02: preserve the unhyphenated follow-up route alias on Android

Android now accepts the exact /intel/followups route as well as the canonical /intel/follow-ups spelling, matching iOS and the server-emitted route family. FieldExecutionRoutingTests covers the alias. This is source-level routing proof only; notification delivery, tap execution, and PM scoring remain open.

2026-07-15 — NTF-03: reconcile the Android in-app notification badge after read

Type: Android shell correctness

Summary:

  • Successful notification markRead and markAllRead mutations now notify the Android shell owner, which refreshes the server-authoritative top-rail unread count.
  • Failed mutations do not notify the shell, so a failed read cannot make a stale badge look settled. Shared callers keep the callback optional and unchanged.

Verification:

  • android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/notifications/NotificationsScreenModelTest.kt covers success callbacks for single and bulk read plus the no-callback-on-failure boundary.
  • scripts/tests/test_android_notification_badge_policy.py guards the Android wiring.
  • Named Android floor-device read/ack reconciliation remains open; no PM score changes here.

2026-07-16 — NTF-03: add an emulator reconciliation proof path

Type: Android audit testability

Summary:

  • Added the stable notifications_badge selector to the Android shell and the strict .maestro/critical/62_notification_badge_reconciliation.yaml flow.
  • The flow requires an unread badge, marks all notifications read, verifies Unread (0), and verifies the shell badge disappears after returning to the main surface.

Verification:

  • python3 -m unittest scripts/tests/test_android_notification_badge_policy.py passed.
  • Guarded :app:assembleDevDebug passed.
  • A guarded Lumi_Pixel7_API35 run passed on 2026-07-16; Maestro evidence is retained at /Users/james/.maestro/tests/2026-07-16_103913.
  • This is emulator UI evidence only. Named-device COMM-06/NTF-03 before/after evidence and PM scoring remain open.

2026-07-15 — Assistant action notifications open the completed result (feedback #23)

Type: Bug fix (shared notification routing)

Assistant completion now resolves the current turn’s executed AssistantAction records inside the acting organization and member boundary. One unambiguous destination opens the corresponding project, task, contact, CRM, intelligence, or settings surface. No result, a pending action, a foreign action ID, or multiple different destinations falls back to the exact originating assistant thread rather than guessing or falling through to Today. The same canonical route is stored in NotificationEvent and carried in FCM data with its category, so web and native clients receive one route contract. Named-device delivery remains a separate runtime proof gate.

2026-07-15 — Consolidate Android Firebase identities by backend flavor

Type: Build configuration hardening

Summary:

  • Removed debug and benchmark application-ID suffixes. At this point every prod and temporary dev build used com.locuno.lumi.android; the accepted 2026-07-25 replacement-app cutover supersedes that install identity with com.lumi.ai while retaining com.locuno.lumi.android as the Kotlin namespace.
  • Updated Maestro and offline-proof defaults to the stable dev identity.
  • Kept one Firebase config for the temporary shared install identity. ADR-051 later adds an isolated src/dev/google-services.json for registered package com.locuno.lumi.dev and Firebase project lumi-dev-5c0a0.
  • Added AndroidApplicationIdSourceTest so build-type suffixes or stale automation IDs cannot silently return.

This is repository configuration proof only. It does not establish live provider acceptance, recipient-device delivery, or PM audit completion.

2026-07-15 — E3: prove quiet hours spanning midnight

Type: Test hardening

Summary:

  • Added deterministic boundary coverage for a 21:00–07:00 quiet-hours window at both sides of midnight and at the exact start/end boundaries.
  • The existing minute-precise implementation is now registered as executable proof rather than remaining an audit-only claim.

Verification:

  • backend/tests/notifications/test_notification_coverage.py::test_is_in_quiet_hours_spanning_midnight_boundaries
  • Focused notification suite: 70 passed.

The test proves source behavior only; it does not establish live FCM delivery or named-device restraint evidence.

2026-07-15 — NTF-02: resolve native routes emitted by notifications

Type: Mobile routing hardening

Summary:

  • iOS and Android now normalize trailing slashes and resolve the server/assistant route families for field details, CRM details, teams, pulse, inbox, import, mail, Hub, account settings, and assistant chat.
  • Unknown routes still return no destination, preserving the safe no-op boundary.

Verification:

  • ios/UnitTests/Workspace/WorkspaceTests.swift and android/app/src/test/kotlin/com/locuno/lumi/android/FieldExecutionRoutingTests.kt cover the route matrix.
  • Named-device push delivery and deleted/inaccessible-resource fallback remain PM/runtime evidence gates.

2026-08-02 — NTF-04: preserve exact message context in native notification taps

Message and mention pushes now carry the server-authorized message id in the app-relative /hub/messages route, along with channel/topic or conversation context. Web requests the server context page around that id; Android and iOS resolve it through the existing message-context destination, while older channel-only routes still open the typed channel thread. The route is allowlisted; malformed or unknown paths remain safe no-ops.

Focused backend, web, and native route tests cover channel, DM/group-DM, mention, message-context, and channel-fallback paths. Provider delivery, cold-start execution, deleted-resource fallback, and named-device evidence remain separate runtime gates.

2026-07-15 — SYS-07: explain before requesting notification permission

Type: Bug Fix

Summary:

  • Android now shows a localized, one-time primer before the operating-system notification prompt.
  • Dismissing, cancelling, or continuing persists a handled decision in the encrypted session preferences, so Activity recreation cannot repeat the primer.
  • Shared Settings exposes one localized Android-only row that opens the app’s operating-system notification controls. This is the retry path after a declined primer; no launch nag is needed.

Reason:

  • MainActivity.onResume() previously requested POST_NOTIFICATIONS cold, contradicting §6’s contextual-primer contract and SYS-07.

Impact:

  • Authenticated Android users understand the value before the OS prompt. Permission denial remains non-blocking.

Tests:

  • MainActivityPermissionTest covers authentication, granted, first-request, dismissal, handled-recreation, and the Settings callback.
  • LumiSessionManagerTest proves the handled decision survives a new LumiPrefs instance and clears with the session.
  • scripts/run-notification-primer-proof.sh holds APK custody, revokes permission through ADB, runs the first-session dismissal flow, force-stops, relaunches without granting permission, proves the primer remains absent, and opens Android notification settings from the shared Settings row.

Notes:

  • The Pixel 7 API 35 emulator lifecycle passed on 2026-07-15, but PM must still walk permission choreography on the named Android floor device. No audit score changes from emulator proof.

2026-07-16 — SYS-07: iOS permission primer and Settings retry

Type: Bug Fix

Summary:

  • An authenticated iOS session with notification authorization still undetermined presents a localized explanation before the operating-system prompt.
  • Only the member’s explicit Continue action requests authorization. Not now or any dismissal records the primer as handled in UserDefaults, so foregrounding or relaunching cannot nag again.
  • Workspace Settings forwards the shared notification-access row to the app-specific iOS notification settings page. A denied or deferred member therefore has a durable retry path without another launch prompt.

Reason:

  • AmbientNudgeController.onForeground() called UNUserNotificationCenter.requestAuthorization directly on the first authenticated foreground. Authentication made the cold prompt reachable but did not satisfy the contextual-primer contract in §6.

Proof boundary:

  • AmbientNudgePermissionTests pins signed-out, already-authorized, first-primer, decline persistence, repeat-foreground, explicit-continue, and Settings-bridge behavior.
  • XCTest and simulator UI remain implementation evidence. PM must still retain same-day named iPhone 12 choreography before changing SYS-07 or F94 scores.

2026-07-15 — NTF-01: authenticated client delivery receipts

Type: Delivery observability hardening

Summary:

  • Every provider-attempt PushLog UUID is carried in the FCM data payload as lumi_push_log_id.
  • Authenticated Android receipt plumbing posts that correlation ID to /api/notifications/push-receipts/; the tenant/member-scoped PushReceipt is idempotent and only accepts PushLog.status=sent.
  • A provider sent log without a client receipt remains unconfirmed. iOS receipt plumbing is not claimed in this slice and remains a named-device gap.

Verification:

  • backend/tests/notifications/test_push_receipts.py covers happy path, idempotency, cross-tenant/member isolation, failed-provider rejection, authentication, and payload correlation.
  • Shared API and Android receiver changes compile; no device delivery score is changed.

2026-07-17 — NTF-01: receipt organization snapshot

Type: Delivery observability hardening

Summary:

  • Normal provider-attempt payloads now include the server-authored lumi_organization_id next to lumi_push_log_id.
  • Android and iOS preserve that snapshot when posting the receipt, so a member who has switched organizations does not silently attribute an old push to the active organization. The API rejects a body/header organization mismatch and keeps the existing tenant/member and idempotency checks.

Verification boundary:

  • Backend payload, mismatch, and receipt tests plus native coordinator source tests cover the scoped plumbing. Named-device delivery after an organization switch, provider acceptance, and PM scoring remain open.

2026-07-15 — NTF-01: iOS registration and receipt seam

Type: Mobile delivery observability hardening

Summary:

  • Register an already-authorized iOS installation’s FCM token through the shared authenticated device API. This slice never cold-prompts for notification permission; SYS-07 owns the primer and OS-prompt choreography. The iOS unit gate also proves the production Firebase plist is copied into the built app, matches the host bundle ID, and carries a non-blank Google app ID.
  • Forward APNs tokens to Firebase Messaging and record the server-emitted lumi_push_log_id from foreground, background, and tap callbacks through the tenant-scoped receipt API. FCM sets APNs content-available=1 and the app declares the remote-notification background mode so the bounded background callback is reachable; this is still source/simulator proof, not device delivery.
  • On logout, preserve every exact FCM-token/organization registration across provider rotation and organization departure, then atomically invalidate the session generation, capture those bindings with the credential/current-org snapshot, persist that write-ahead cleanup record in Keychain, verify the durable write, and only then clear the live credentials. If persistence fails, logout leaves the live session intact so recovery authority is never silently discarded. A concurrent new login waits until that terminal mutation completes. Cleanup joins or performs one refresh and replaces the durable Keychain snapshot with the full successor access/refresh pair before using it; exact tenant-scoped unregister calls run sequentially before successor refresh-session revocation and remain fail-soft so local logout never waits on the network. Unregister is idempotent: an absent or another member’s token returns 204 without mutating another member’s row or leaking token presence. A 204 alone is not revocation proof: the server returns X-Lumi-Session-Revoked: true only when the submitted refresh lineage was actually revoked. A pending snapshot carrying a refresh credential clears only on that authoritative response; when no refresh credential exists, complete unregister of every exact binding is sufficient. Otherwise the encrypted snapshot remains in Keychain and retries after relaunch even when Firebase configuration is missing or invalid. Authoritative server logout deactivates every active device token for that user in the same database transaction.
  • A registration response that arrives after an organization switch is recorded as a device-only cleanup for the captured old organization. It is queued and retried immediately, including after an ambiguous registration failure, but it never takes the terminal logout path or revokes the newly active session. Terminal logout writes a durable deletion tombstone before removing credentials; relaunch suppresses session restoration until the deletion is complete and verified.
  • A durable account-wide terminal snapshot is itself a restore barrier. If the process dies after the cleanup record is written but before credential deletion, relaunch hides the still-present Keychain credentials and attempts to drain the cleanup queue. Session restoration stays blocked, but an explicit login may abandon the unfinishable stale record after that attempt so Keychain state surviving reinstall cannot deny login forever.
  • If device-only cleanup must rotate a captured refresh token, it adopts the exact successor pair into the still-owning active session before unregistering the old device binding; a changed or logged-out session is not overwritten. A persisted successor is reused only while its access token is still valid; after expiry, cleanup rotates from that immediate successor so the lineage remains recoverable rather than replaying stale access.
  • The cleanup intent is written before any registration request. If that write-ahead record cannot be verified, registration fails closed. Lower-level credential commit cannot proceed while account-wide terminal cleanup remains; the explicit login boundary must first finish or deliberately abandon that stale state because server logout revokes all active refresh sessions.
  • Give every native refresh a random Keychain-backed X-Lumi-Refresh-ID before network I/O. Logout copies an in-flight refresh ID into its durable snapshot, and refresh retry plus server logout present that same ID. IDs are keyed by refresh-token lineage so overlapping predecessor/successor work cannot overwrite one another. The backend stores only its digest and the successor JTI. If the process dies after rotation but before receiving or persisting the successor, the consumed predecessor and matching ID can still revoke that exact successor after the encrypted 60-second response cache expires; a wrong or absent ID remains a no-op. A lineage-proven signature-valid but expired refresh token remains usable for terminal cleanup only; an ordinary expired bearer is a no-op and never becomes a refresh or session-creation credential. During rolling deploys the backend dual-writes the versioned exact access / refresh pair and the prior raw-refresh cache format, so old readers retain a safe replay path. A committed native logout with the same bearer token and operation ID is acknowledged idempotently after a lost response; a different operation ID remains rejected.
  • Serialize token registration and logout-unregister behind a session generation that remains inactive until explicit session establishment. Queue reservations are synchronous, so an in-flight or late Firebase callback cannot place a new registration ahead of terminal logout-unregister. Observed/current token state and exact token/organization history are committed together under the same generation lock, so logout cannot invalidate a session between validation and either local write.
  • Treat Firebase bootstrap and token lookup failures as observable failures. A background callback gives its receipt attempt one bounded eight-second budget rather than releasing iOS while the request is still untracked.
  • Route KMP, legacy native, and logout-snapshot refresh through one token-keyed coordinator. The shared normal flight commits once through the same locked credential boundary as login and logout, then returns that result to every waiter without resurrecting a cleared session. A bounded completed-result lease lets delayed logout cleanup reuse a one-time refresh result rather than replaying the consumed credential. An authenticated request that retries after refresh replaces its rejected bearer with that returned token, and may fall back to the old bearer only while the complete credential/org snapshot remains current.
  • Guard response-derived login, restore, organization, language, and entitlement writes with one monotonic auth-session generation. Logout or a newer login invalidates older async responses before they can repopulate credentials or UI.
  • Bind every iOS KMP client and native authenticated request to the session generation and organization captured when that request starts. A later login, logout, or organization transition makes the old request terminal: it cannot borrow replacement credentials, replay a mutation, or commit a response into the new session. Shared-auth callbacks advance the same generation, root entitlement hydration uses the generation-guarded path, shared-auth callbacks attempt pending terminal cleanup and deliberately abandon unfinishable stale state before committing explicitly accepted credentials, and failed membership resync restores the prior JWT tenant, observable tenant, and entitlement state.
  • Keep callback errors fail-soft. A push callback must always return control to iOS even when registration or receipt recording is unavailable.
  • Device-token repository exports declare @Throws on both the interface and implementation so an unregister HTTP rejection crosses Kotlin/Native as a recoverable Swift Error; it must never terminate the process at the ObjC suspend bridge. The source-policy regression proves only that the declarations remain present; Apple-framework compilation and TestFlight rejection/retry remain separate platform/runtime evidence.

Verification boundary:

  • Unit and compile proof may establish payload parsing, authenticated API routing, logout ordering, and delegate wiring. It does not establish provider acceptance, banner timing, recipient-device delivery, release smoke, or an audit score.
  • Named-device receipt attribution after the user switches away from the sending organization remains an explicit proof gap; source plumbing does not substitute for delivery evidence or weaken tenant scope to hide it.
  • Quiet-hours summary sends do not currently create a distinct correlated provider-attempt row, and a blank provider message ID is not acceptance proof. Both remain explicit contract gaps rather than being presented as receipt proof.
  • Same-day named-device evidence remains required for PM scoring; simulator output is UI-only.

2026-07-09 — R03: Wire assistant reply and F29 message pushes

Type: Feature — additive, 2 migration columns, 7 new tests, zero behavioral regression.

Summary:

  • NotificationPreference gained push_assistant (default True) and push_messages (default True) with migration 0010. _CATEGORY_PREF_FIELD maps "assistant" / "message" to them; PushLog and NotificationEvent category choices extended.
  • Assistant reply push: finalize_loop in task_runtime_execution.py calls send_push_to_member(category="assistant") on success, never on failed turn, wrapped in try/except so failures can’t break the turn.
  • F29 message push: _send_message_pushes in services.py pushes to conversation members (DM) or channel members (channel post), skips sender, skips members already receiving an @mention push for the same message (dedupe_key prefix message-).
  • i18n keys: push.assistant.title, push.assistant.body, push.message.dm.title, push.message.dm.body, push.message.channel.title, push.message.channel.body in both vi and en.

Reason: Push infra shipped but two flagship events never attempted a notification. @channel now triggers message pushes (previously zero pushes; test updated).

Tests: test_push_assistant_message.py — 7 tests covering assistant complete push, preference opt-out, DM push (other member, not sender), channel post push (all members except sender), channel+mention dedup (one push), preference suppression.

2026-07-05 — Enable FCM push: wire the credential via CI + fix the settings gap

Type: Bug Fix (+ Config)

Summary:

  • Bug: push.py read getattr(settings, "FCM_SERVICE_ACCOUNT_JSON", None) but the setting was never defined in config/settings/* — so it was always None and send_fcm_push always no-op’d. Push has never actually delivered. Added FCM_SERVICE_ACCOUNT_JSON to settings/base.py (from env) and blanked it in settings/test.py.
  • Config: stored the Firebase service-account key as GitHub secrets FCM_SERVICE_ACCOUNT_JSON_DEV / _PROD and injected them into the ECS task-def env in deploy-dev.yml + deploy-prod.yml via the existing jq --arg builder (verified round-trip). Documented the env var in deploy/AGENTS.md (§10 claimed it was there — it wasn’t; drift fixed).

Reason:

  • The F36 push code path was complete but delivered nothing (settings gap + unset credential). F93’s storyboard push channel depends on it. User asked to configure FCM for dev + prod via CI.

Impact:

  • Push begins delivering on the next deploy/backend-{dev,prod} deploy, once devices register (P13/F30). Settings change is backward-safe (empty default = prior no-op behavior). Tests unaffected (blanked).

Impact:

  • No code change. Makes the “push silently no-ops” prerequisite visible and actionable.

Tests:

  • None (docs/ops only).

Notes:

  • Owner is infra/deploy; secret lives in AWS Secrets Manager, never committed. check-push-failure-rate beat already guards against a silent mis-config once devices register.

2026-07-02 — §11 Row 2 test count corrected: 13 tests in test_device_registration.py

Type: Documentation drift fix

Summary:

  • §11 Row 2 evidence claimed “10 tests in test_device_registration.py”. The 2026-07-02 snapshot had 13 (grep -c 'def test_' confirmed); the §13 2026-06-12 entry’s “10 tests” remains a historical count. Later NTF-01 hardening replaced the old inactive-membership unregister guard with explicit missing-header, departed-membership, and wrong-organization proofs; the current file has 16 tests.

2026-06-23 — Localize per-event live pushes (approvals · decisions · follow-ups · mentions · comments · signals · AI) in the recipient’s language

Type: Feature / correctness — additive, no migration, no API change, no model change.

The 2026-06-22 i18n catalog migration localized only the brief push (send_brief_push), the quiet-hours collapsed summary (build_quiet_hours_summary), and the Telegram brief header. Per-event live pushes still emitted hardcoded Vietnamese strings at every call site — apps/approvals/views.py (4 sites: new approval, approve outcome, reject outcome, next-step), apps/followups/services.py (1), apps/comments/services.py (1 covering @mentions and thread-follow), apps/messaging/services.py (1 channel/DM mention), apps/operations/views.py (1 decision), apps/signals/services.py (2 signal-proposal approval and decision), apps/ai/services.py (1 AI-extracted decision). A Malaysian / Indonesian / Saudi / Filipino / Latvian CEO opening Lumi saw their approval, mention, decision, and follow-up pushes arrive in Vietnamese — the same “Lumi speaks your language” failure the brief-push migration fixed for the morning channel, on the highest-frequency live channels in the product. The catalog-rule comment at the top of apps/notifications/i18n.py already said “no hardcoded user-facing copy in a send path — ever”; this PR finally makes the per-event live pushes hold that line.

The fix: 16 new keys added to apps/notifications/locales/{vi,en}.json (push.approval.created.title / …body / …body_signal / push.approval.next_step.body / push.approval.approved.title / push.approval.rejected.title / push.followup.replied.title / …body / push.mention.title / push.mention.body_with_channel / …body_no_channel / push.thread.title / push.decision.created.title / …body / …body_signal / …body_ai). Every call site now resolves the recipient’s locale via member.user.language (or follow_up.created_by.user.language, requester.user.language, next_approver.user.language for the approval-outcome paths) and routes both title and body through apps.notifications.i18n.translate(...). Universal "{user}: {comment_or_snippet}" bodies are kept as Python f-strings — they read naturally in any locale and need no translation. The brief/quiet-hours/Telegram i18n contract (locales auto-fallback L → base-language → en → vi, vi and en ship complete, other ~60 locales degrade gracefully via the existing chain) carries over unchanged: no propagation step needed.

Read-path performance: every queryset that loads a member just to compose a push now uses .select_related("user") so member.user.language doesn’t trigger an extra query per recipient on the operations/AI/signal-decision bulk fan-out (matches the brief-push pattern). Per-event pushes (approvals, followups, mentions) are single-recipient calls, so the extra query is at most one and a deliberate “lazy-load it when needed” trade.

Tests:

  • 10 new localization smoke tests in test_push_notifications.py — test_approval_created_push_localized_to_user_language · test_approval_outcome_push_localized · test_approval_next_step_push_localized · test_decision_created_push_localized · test_decision_ai_extraction_push_localized · test_decision_signal_proposal_push_localized · test_approval_signal_proposal_push_localized · test_followup_reply_push_localized · test_message_mention_push_localized_with_channel · test_comment_mention_push_localized — each asserts an English-language recipient gets English title + body and no Vietnamese leakage. Mirrors test_brief_push_localized_to_user_language (shipped 2026-06-22).
  • _PARAMS fixture in test_notification_i18n.py extended with the 16 new keys so the parametrized test_every_supported_locale_resolves_every_key test covers them across every SUPPORTED_LOCALE via the fallback chain.

Backwards compat: every existing test that directly calls send_push_to_member with a hardcoded Vietnamese title/body (e.g. test_approval_push_fires, test_decision_push_fires) stays green — those tests exercise the dispatcher, not the per-event call sites, and the dispatcher contract (accept arbitrary pre-rendered title/body strings) is unchanged. Vi-locale recipients keep the exact same VI strings they received yesterday; no copy regression in the wedge market.

Audit closure: the implicit gap left by the 2026-06-22 entry — “i18n catalog present, but only three of the eight live push surfaces resolve through it” — is now closed. The notification catalog’s stated invariant (“every user-facing email and push MUST be rendered in the recipient’s User.language”) holds on every live push site in the product.

2026-06-22 — Localize push notifications in the recipient’s language (i18n catalog)

Type: Feature / correctness — additive, no migration, no API change.

The brief push (send_brief_push), the quiet-hours collapsed summary (build_quiet_hours_summary

  • send_quiet_hours_summary), and the Telegram brief header (send_brief_telegram) were hardcoded in Vietnamese, even though User.language already spans all ~62 SUPPORTED_LOCALES. All push copy now renders in the recipient’s User.language via the shared notification i18n catalog (apps/notifications/i18n.py + apps/notifications/locales/<locale>.json), the same mechanism F35 email uses, with a deterministic L → base-language → en → vi fallback. build_quiet_hours_summary gained a locale argument (default vi); send_quiet_hours_summary bulk-maps each member to their language in one query (no N+1) and renders per member; send_brief_push and send_brief_telegram render per recipient. Rule documented in backend/AGENTS.md → Localized Notifications. Test: test_brief_push_localized_to_user_language + the shared test_notification_i18n.py matrix (every key resolves for every locale). See F35 §13 for the full catalog rationale. The earned-stakes build_brief_push_body (shipped in the same window) is localized too — it draws its count fragments and stakes/updates lines from the shared catalog, so the earned morning push lands in the recipient’s own language.

2026-06-22 — Growth/retention: earned brief-ready push body (complete the brief-channel earned-framing trifecta)

Type: Feature (growth / retention — opens-per-notification)

Summary:

  • The morning brief-ready push body read {N} mục cần CEO chú ý (“N items need CEO attention”) — a flat count, identical every morning, that trains the CEO to swipe away the highest-frequency daily-habit channel in the product (the morning push that opens the CEO’s day). Same “notification that trains people to mute us” failure mode #306 fixed for the brief-digest email subject and #308 fixed for the quiet-hours summary push — but on the live brief-channel, the most important surface of the three.
  • Added pure, deterministic build_brief_push_body(items) -> str (mirrors the F35 build_brief_digest_subject and the body half of build_quiet_hours_summary): leads with the two highest-stakes non-zero buckets — decisions (decision/approval), risks (risk/cash/customer), overdue (overdue/commitment) — e.g. 2 quyết định · 1 rủi ro cần bạn; routine-only items (opportunities, people, tasks, summaries, deals) stay honest ({N} mục cập nhật); empty preserves the existing copy (Hôm nay không có mục nào). The push title stays Bản tin sáng đã sẵn sàng — the daily-channel hook the CEO learned to recognize remains intact while the body earns the open.
  • send_brief_push signature: (organization_id, items: list) (was: (organization_id, item_count: int)). Caller in apps/briefs/tasks.py passes list(brief.items.order_by("rank")[:20]) — same window send_brief_digest_emails already uses on the email channel; existing brief loaded in scope, no extra query.
  • Backend-only; no migration, no new model/route/status; not an AI output (deterministic — no evidence/confidence). Copy hardcoded Vietnamese to match the module’s push-copy convention. Three existing test callers updated from item_count=N → items=[...].
  • Tests: F36-8b added to §7 (helper cases — decisions lead · risks named · caps to two buckets · routine-only fallback · empty fallback · all-three drops the 3rd; integration: brief-ready push body names stakes, not a flat count). Existing F36-8 / F36-9 / send_brief_push coverage tests updated to the new signature and stay green (fan-out and disabled-default behaviour unchanged).
  • This completes the brief-channel earned-framing trifecta: digest email subject ✅ (#306), quiet-hours summary push ✅ (#308), brief-ready push ✅ (this change). All three “morning’s first signal” channels now name the day’s stakes instead of a flat count.

2026-06-22 — Growth/retention: earned quiet-hours summary push (name the stakes, not a flat count)

Type: Feature (growth / retention — opens-per-notification)

Summary:

  • The morning quiet-hours collapsed summary push read Thông báo đang chờ / Bạn có {N} thông báo đang chờ (“Notifications waiting / You have N notifications waiting”) — a flat count, identical every morning, that trains the CEO to swipe it away (the “notification that trains people to mute us” failure) on the highest-frequency daily-habit channel.
  • Added pure, deterministic build_quiet_hours_summary(categories) -> (title, body) (mirrors the #306 brief-digest build_brief_digest_subject): leads with the two highest-stakes non-zero buckets — decisions (decision/approval), replies (followup), mentions (mention) — e.g. Sáng nay cần bạn / 2 quyết định · 1 trả lời chờ bạn; routine-only (brief) and empty stay the honest waiting line (no manufactured urgency). send_quiet_hours_summary now composes the earned (title, body) per member from that member’s suppressed PushLog.category rows.
  • Backend-only; no migration, no new model/route; not an AI output (deterministic — no evidence/confidence). Copy hardcoded Vietnamese to match the module’s push-copy convention.
  • Tests: helper cases (stakes lead · followup · mention · caps to two buckets · routine-only fallback · empty) + integration assertion that the summary push body names stakes, not a flat count. F36-4b added to §7. Existing F36-28–F36-31 quiet-hours tests stay green (one-push-per-member behaviour unchanged).

2026-06-15 — Frontend route namespaced

Frontend route namespaced: push deep-link target /brief -> /intel/brief (app-wide IA restructure; API paths unchanged).

2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)

Type: Documentation Change

Summary:

  • Absorbed §9 Edge Cases (4 items) into §11 note; §10 Error States → §9; §11 Diagrams → §10; §12 Implementation Audit (narrative) → §11 (10-row table); §13 Open Questions → §12; §14 Bug/Change Log → §13.
  • §11 audit: 10 rows, 8 ✅, 2 gaps (mobile client deferred, midnight-span quiet hours untested).

2026-06-12 — Per-event push triggers wired

Type: Feature

Summary:

  • Wired send_push_to_member into six creation paths: approvals/views.py perform_create, followups/services.py reply, messaging/services.py @mention, operations/views.py decision creation, signals/services.py signal proposals, ai/services.py AI extraction decisions. 8 new tests (F36-20 through F36-27) in backend/tests/test_push_notifications.py.
  • Remaining gap closed: §11 “per-event triggers not yet wired” bullet replaced with confirmed-wired status.

2026-08-22 — Backend route inventory proven resolvable on both native shells

Type: Contract proof + one routing repair

Summary:

  • Audited every relative route the backend emits for notification taps: push category defaults (push.py), task-assignment events (work/views_common.py), comment deeplinks (comments/services.py comment_deeplink), messaging shapes (message_notifications.py), and billing/settings routes (delivery_tasks.py). All resolved on Android and iOS except one: a comment on a Hub doc (/hub?doc={id}&comment={cid}) landed on the bare Hub root.
  • Both shells now send ?doc= to the Docs surface; the comment-id focus itself remains a web-only affordance (native Docs has no comment anchor yet — opening the right surface is the honest mobile behavior).
  • A maintained inventory test (scripts/tests/test_notification_route_inventory.py) now pins each backend literal to exhaustive route→destination assertions in both platform test suites, so a new backend route without a native mapping fails the drift guard in the same PR.

Tests:

  • FieldExecutionRoutingTests.backendEmittedNotificationRoutesResolveOnNativeShell + unknownNotificationRoutesFailClosedWithoutCrashingTheShell (Android JVM)
  • WorkBreadthTests.test_backendEmittedNotificationRoutesResolveOnNativeShell (iOS XCTest)
  • scripts/tests/test_notification_route_inventory.py

Proof boundary:

  • Android JVM and iOS XCTest structural/platform tests; no emulator/simulator tap journey or named-device evidence is claimed by this change.

2026-06-12 — Quiet-hours collapsed summary shipped

Type: Feature

Summary:

  • Added summary_sent_at field to PushLog model for tracking when suppressed pushes were summarized.
  • New send_quiet_hours_summary(organization_id) task: queries suppressed PushLogs, groups by member, sends one collapsed summary per member, marks logs as summarized.
  • New send_quiet_hours_summary_all_orgs() task: iterates all orgs, fires per-org summary when local hour matches quiet_hours_end.
  • Celery beat schedule: hourly check (quiet-hours-summary entry in CELERY_BEAT_SCHEDULE).
  • Members without active tokens still get their suppressed logs marked as summarized (idempotent).
  • Urgent approvals that bypassed quiet hours (status=“sent”) are excluded from summary.

Reason:

  • Pushes suppressed during quiet hours were logged but never summarized — the collapsed summary at window end was missing.

Impact:

  • New Celery beat task runs hourly. No API changes. No frontend changes.

Tests:

  • F36-28: 3 suppressed → 1 summary push
  • F36-29: no suppressed → no summary
  • F36-30: already-summarized → no duplicate
  • F36-31: urgent bypass excluded from summary
  • F36-28b: cross-tenant isolation
  • Edge cases: nonexistent org, no active tokens, FCM exception, matching/non-matching hour, no preference

2026-06-12 — Brief-ready push wired

Type: Feature

Summary:

  • send_brief_push is now called from generate_daily_briefs_for_time after each org’s morning Brief generates (backend/apps/briefs/tasks.py), alongside F35’s email digest. New test: test_generate_daily_briefs_triggers_digest_email_and_push in backend/tests/test_api.py.
  • Web notification-preferences card (shared with F35) shipped in /settings.
  • Remaining: approval/decision/follow-up/@mention push triggers not yet wired to real creation paths; mobile client registration deferred to P13/F30.

2026-06-12 — Initial spec

Type: Feature

Summary:

  • Drafted the push channel contract: five control-loop categories only, shared NotificationPreference spine with F35, DeviceToken + PushLog, quiet hours with collapse, FCM-for-both-platforms default.

Reason:

  • F30 mobile apps need the channel that makes the pocket control loop self-starting; requested alongside F35 email.

Impact:

  • New registry entry F36 (🔲). No code yet. F30/P13 gain client-side work when this implements.

Tests:

  • Specified in §7; none written yet.

Notes:

  • The “no FYI pushes” rule is the spec’s spine — reviewers should reject any new category that has no action verb.

2026-06-12 — Device registration API implemented

Type: Feature

Summary:

  • Fixed DeviceToken model: removed unique=True on token field, added UniqueConstraint(organization, member, token) so same FCM token can coexist across orgs/users. Added composite index (organization, member, platform, is_active).
  • Migration 0003_alter_devicetoken_token_and_more created.
  • Fixed register_device view: now returns {id, platform, created} instead of {registered: True}; upsert uses (organization, member, token) lookup.
  • Added list_devices view (GET /api/devices/): returns active devices for current user+org, filtered by is_active=True, tenant-scoped.
  • 10 tests in backend/tests/test_device_registration.py all pass.

Reason:

  • Device registration API had three bugs: wrong response format, token uniqueness preventing cross-org same-token registrations, and missing list endpoint.

Impact:

  • register_device response shape changed (breaking for any consumer parsing registered key — none existed yet).
  • New GET /api/devices/ endpoint added to notifications/urls.py.

Tests:

  • F36-? (10 test methods): backend/tests/test_device_registration.py
    • test_register_device_happy_path — 201 with id
    • test_register_device_requires_auth — 401
    • test_register_device_duplicate_token — upsert idempotent
    • test_register_device_different_user_same_token — cross-org same token OK
    • test_re_registration_reactivates_device — unregister → register → active
    • test_invalid_platform_returns_400
    • test_unregister_device — 204, is_active=False
    • test_unregister_device_requires_auth — 401
    • test_list_devices — only active, own user
    • test_cross_tenant_device_isolation — org A cannot see org B devices

2026-06-12 — Real FCM HTTP v1 delivery implemented (A-r17)

Type: Feature

Summary:

  • send_fcm_push now performs a real FCM HTTP v1 call: builds OAuth2 credentials via google.oauth2.service_account.Credentials (scope firebase.messaging), refreshes the token, and POSTs to https://fcm.googleapis.com/v1/projects/{project_id}/messages:send with urllib.request. Returns the FCM message ID parsed from the response name field.
  • Raises FCMInvalidTokenError on a 404/HTTPError response (unregistered token), covering both the context-manager-status and HTTPError paths.
  • Added google-auth>=2.0,<3.0 to backend/pyproject.toml.
  • backend/tests/test_fcm_delivery.py tests 1-3 upgraded from xfail(strict=True) TDD red to passing green, mocking Credentials and urllib.request.urlopen.

Reason:

  • send_fcm_push previously raised NotImplementedError unconditionally — the push notification pipeline could schedule sends but never deliver them.

Impact:

  • backend/apps/notifications/views.py and beat tasks that call send_fcm_push now perform real delivery when FCM_SERVICE_ACCOUNT_JSON is configured; still a no-op (returns "") when unconfigured.

Tests:

  • test_send_fcm_push_calls_correct_endpoint, test_send_fcm_push_payload_structure, test_send_fcm_push_raises_on_invalid_token — all green.
  • test_notification_coverage.py::test_send_fcm_push_with_malformed_config_raises updated to expect google.auth.exceptions.MalformedError (the old NotImplementedError path no longer exists).

2026-06-13 — A-r01: Backend spine verified shipped, coverage gap closed

Type: Documentation Change

Summary:

  • Verified all F36 backend components confirmed shipped: 9 source files (init, apps, models, push, services, tasks, views, urls, urls_auth) + 3 migrations. Push spine includes DeviceToken, PushLog, NotificationPreference (shared), FCM HTTP v1 delivery, per-event triggers (approvals, follow-up replies, @mentions, decisions, signal proposals, AI-extracted decisions), brief-ready push, quiet-hours collapsed summary + Celery beat task, register/unregister/list endpoints.
  • Added coverage tests for send_fcm_push HTTPError paths (404 → FCMInvalidTokenError, non-404 → re-raise), closing the last 3 uncovered lines in push.py (now 100%).
  • All backend ACs in spec marked [x].
  • F36 status in MVP_SCOPE.md updated to 🚧→✅ with backend column ✅, E2E remains 🚧 (mobile client deferred to P13/F30).

Reason:

  • Task T3-A-r01 required verification and documentation update.

Impact:

  • F36 registry status updated. Users see no behavioral change — backend was already shipped.

Tests:

  • test_send_fcm_push_http_404_raises_invalid_token — HTTP 404 from FCM → FCMInvalidTokenError
  • test_send_fcm_push_http_non_404_raises_through — non-404 HTTPError re-raised