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
ApprovalRequestenters 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.
- Given I am the current approver and push enabled, when an
-
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.
- Given push digest enabled, when the Brief generates at org
-
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
DecisionRequestassigned to me, when it is created, then one push arrives, deep-linking to decision detail.
- Given an open
-
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
FollowUpRequestreceives a reply (F31), when it lands, then one push arrives, deep-linking to the originating item.
- Given my
-
As a mentioned member, I want a push for @mentions in Messages, so that direct asks reach me.
- Given an
@mentionof me (F29) and mentions enabled, when the message posts, then one push arrives;@channelblasts do not push (digest-only).
- Given an
-
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— extendsUUIDModel,organizationFK,memberFK,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— extendsUUIDModel,organizationFK, 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
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| POST | /api/devices/register/ | member | {platform, token} | 201/200 idempotent | 400 bad platform |
| POST | /api/devices/unregister/ | authenticated user | {token} plus X-Organization-ID | 204 | Idempotent; 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/ | member | shared with F35 | preference object | 401 |
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:
/settingsreuses 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,settingskeys 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 theirUser.languagevia the shared notification i18n catalog (keyscount.*,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. body2 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 staysBả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 theirUser.languagevia the shared catalog (keyscount.*,push.brief.*). Mirrorsbuild_brief_digest_subject(F35) and the body half ofbuild_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
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Token register/unregister idempotency; own-tokens-only; cross-tenant non-enumerating 204 | backend | backend/tests/notifications/test_push_tokens.py + backend/tests/notifications/test_device_registration.py |
| 2 | Each category trigger fires exactly one push to active tokens (FCM mocked) | backend | same |
| 3 | Disabled category → suppressed, logged, no error | backend | same |
| 4 | Quiet hours: collapse to single summary at window end; urgent approval bypasses | backend | same |
| 4b | Earned 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 count | backend | same |
| 5 | Invalid-token feedback deactivates DeviceToken, no retry | backend | same |
| 8b | Earned 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) | backend | same |
| 6 | @channel does not push; @mention does | backend | same |
| 7 | Push tap deep-links to correct screen with org re-scope | platform UI | iOS + Android test targets (F30 §7 style) |
| 7b | Assistant action notification resolves one unambiguous tenant-owned executed result; foreign, pending, or multi-destination turns fall back to the exact originating thread | backend + frontend | TestAssistantNotificationRoutes · NotificationCenterPage.test.tsx |
| 7c | Device payload carries the same canonical route/category as the in-app event | backend | test_assistant_push_carries_resolved_route_to_device |
| 8 | Preferences grid on web settings | e2e | frontend/tests/e2e/notifications.spec.ts (shared with F35) |
| 9 | Fresh iOS launch requests the FCM token only after the APNs registration callback binds the device token | iOS unit | PushNotificationCoordinatorTests.testFCMTokenLookupWaitsForAPNSRegistrationCallback |
| 10 | Native alert payloads request the default sound while preserving route and receipt correlation | backend + Android/iOS source | F77.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=failedwith 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
didRegisterForRemoteNotificationsWithDeviceTokenbinds that APNs token to Firebase; normal startup must not emit Firebasecom.google.fcmcode 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_JSONenv. - GLOSSARY:
DeviceToken,PushLog, quiet hours.
11. Implementation Audit (2026-06-12, updated 2026-06-13)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | DeviceToken, PushLog, NotificationPreference (shared F35 spine) in apps/notifications/ | ✅ match | 9 source files confirmed; 3 migrations; test_device_registration.py + test_push_tokens.py |
| 2 | Register/unregister endpoints + multi-device-per-member support | ✅ match | §13 2026-06-12: UniqueConstraint(org, member, token); 13 tests in test_device_registration.py |
| 3 | Brief-ready push wired into generate_daily_briefs_for_time | ✅ match | apps/briefs/tasks.py; test_generate_daily_briefs_triggers_digest_email_and_push |
| 4 | Per-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) |
| 5 | Real 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 |
| 6 | Quiet-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 | ✅ match | apps/messaging/services.py; F36-20–F36-27 |
| 8 | Web preferences card (shared with F35) in /settings | ✅ match | §13 2026-06-12; E2E notifications.spec.ts (shared with F35) |
| 9 | Mobile client: iOS/Android FCM registration, deep links, onboarding screen 5 primer | ⚠️ partial | Prod 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 |
| 10 | Quiet hours spanning midnight correctly suppress local interval when workers run UTC | ✅ match | E3 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(thejq --arg fcm_jsonbuilder — verified: real key → valid task-def JSON →json.loadsround-trips, private key intact). - Latent bug fixed:
FCM_SERVICE_ACCOUNT_JSONnow defined insettings/base.py(from env). Previouslypush.py’sgetattr(settings, …)was alwaysNone, 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
PushLogrow goessent, notfailed— 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-ratebeat (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_MISMATCHresponses 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-1Seven 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_tokenasserts deactivation, diagnosticPushLog.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_DEVcompile-time flag.
Reason:
- Sentry
LUMI-IOS-Trecorded five production info events from dev build1.0.1 (199)where the expected Firebase resource was not selected, disabling push bootstrap for two users.
Impact:
- Dev archives use
GoogleService-Info-dev.plistforcom.locuno.lumi.dev; production archives continue to useGoogleService-Info.plistforcom.locuno.lumi.ios. No token or tenant data is changed.
Tests:
PushNotificationCoordinatorTests.testFirebaseConfigurationIsSelectedPerReleaseIdentitycovers 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 taskfor the quiet-hours summary, email-failure-rate, and push-failure-rate beat entries. The implementations are decorated inapps.notifications.health_tasks;apps.notifications.tasksonly 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-workerlogged one occurrence of each unregistered task at the 2026-08-15 17:00 container-local beat tick. Container health andcelery inspect pingprove 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.ymland 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-1Srecorded 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. didRegisterForRemoteNotificationsWithDeviceTokenbinds the APNs token to Firebase; Firebase then delivers the current or refreshed FCM token throughMessagingDelegatefor 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-Rcaptured 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
routeinto 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 pushjourney 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
markReadandmarkAllReadmutations 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.ktcovers success callbacks for single and bulk read plus the no-callback-on-failure boundary.scripts/tests/test_android_notification_badge_policy.pyguards 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_badgeselector to the Android shell and the strict.maestro/critical/62_notification_badge_reconciliation.yamlflow. - 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.pypassed.- Guarded
:app:assembleDevDebugpassed. - A guarded
Lumi_Pixel7_API35run 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 withcom.lumi.aiwhile retainingcom.locuno.lumi.androidas 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.jsonfor registered packagecom.locuno.lumi.devand Firebase projectlumi-dev-5c0a0. - Added
AndroidApplicationIdSourceTestso 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.swiftandandroid/app/src/test/kotlin/com/locuno/lumi/android/FieldExecutionRoutingTests.ktcover 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 requestedPOST_NOTIFICATIONScold, 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:
MainActivityPermissionTestcovers authentication, granted, first-request, dismissal, handled-recreation, and the Settings callback.LumiSessionManagerTestproves the handled decision survives a newLumiPrefsinstance and clears with the session.scripts/run-notification-primer-proof.shholds 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()calledUNUserNotificationCenter.requestAuthorizationdirectly on the first authenticated foreground. Authentication made the cold prompt reachable but did not satisfy the contextual-primer contract in §6.
Proof boundary:
AmbientNudgePermissionTestspins 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
PushLogUUID is carried in the FCM data payload aslumi_push_log_id. - Authenticated Android receipt plumbing posts that correlation ID to
/api/notifications/push-receipts/; the tenant/member-scopedPushReceiptis idempotent and only acceptsPushLog.status=sent. - A provider
sentlog 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.pycovers 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_idnext tolumi_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_idfrom foreground, background, and tap callbacks through the tenant-scoped receipt API. FCM sets APNscontent-available=1and the app declares theremote-notificationbackground 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: trueonly 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-IDbefore 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
@Throwson both the interface and implementation so an unregister HTTP rejection crosses Kotlin/Native as a recoverable SwiftError; 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) andpush_messages(default True) with migration 0010._CATEGORY_PREF_FIELDmaps"assistant"/"message"to them; PushLog and NotificationEvent category choices extended. - Assistant reply push:
finalize_loopintask_runtime_execution.pycallssend_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_pushesinservices.pypushes to conversation members (DM) or channel members (channel post), skips sender, skips members already receiving an @mention push for the same message (dedupe_key prefixmessage-). - i18n keys:
push.assistant.title,push.assistant.body,push.message.dm.title,push.message.dm.body,push.message.channel.title,push.message.channel.bodyin bothvianden.
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.pyreadgetattr(settings, "FCM_SERVICE_ACCOUNT_JSON", None)but the setting was never defined inconfig/settings/*— so it was alwaysNoneandsend_fcm_pushalways no-op’d. Push has never actually delivered. AddedFCM_SERVICE_ACCOUNT_JSONtosettings/base.py(from env) and blanked it insettings/test.py. - Config: stored the Firebase service-account key as GitHub secrets
FCM_SERVICE_ACCOUNT_JSON_DEV/_PRODand injected them into the ECS task-def env indeploy-dev.yml+deploy-prod.ymlvia the existingjq --argbuilder (verified round-trip). Documented the env var indeploy/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-ratebeat 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. Mirrorstest_brief_push_localized_to_user_language(shipped 2026-06-22). _PARAMSfixture intest_notification_i18n.pyextended with the 16 new keys so the parametrizedtest_every_supported_locale_resolves_every_keytest covers them across everySUPPORTED_LOCALEvia 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 thoughUser.languagealready spans all ~62SUPPORTED_LOCALES. All push copy now renders in the recipient’sUser.languagevia the shared notification i18n catalog (apps/notifications/i18n.py+apps/notifications/locales/<locale>.json), the same mechanism F35 email uses, with a deterministicL → base-language → en → vifallback.build_quiet_hours_summarygained alocaleargument (defaultvi);send_quiet_hours_summarybulk-maps each member to their language in one query (no N+1) and renders per member;send_brief_pushandsend_brief_telegramrender per recipient. Rule documented in backend/AGENTS.md → Localized Notifications. Test:test_brief_push_localized_to_user_language+ the sharedtest_notification_i18n.pymatrix (every key resolves for every locale). See F35 §13 for the full catalog rationale. The earned-stakesbuild_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 F35build_brief_digest_subjectand the body half ofbuild_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 staysBả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_pushsignature:(organization_id, items: list)(was:(organization_id, item_count: int)). Caller inapps/briefs/tasks.pypasseslist(brief.items.order_by("rank")[:20])— same windowsend_brief_digest_emailsalready 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_pushcoverage 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-digestbuild_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_summarynow composes the earned(title, body)per member from that member’s suppressedPushLog.categoryrows. - 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_memberinto 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) inbackend/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_atfield toPushLogmodel 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 matchesquiet_hours_end. - Celery beat schedule: hourly check (
quiet-hours-summaryentry inCELERY_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_pushis now called fromgenerate_daily_briefs_for_timeafter 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_pushinbackend/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
NotificationPreferencespine 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
DeviceTokenmodel: removedunique=Trueontokenfield, addedUniqueConstraint(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_morecreated. - Fixed
register_deviceview: now returns{id, platform, created}instead of{registered: True}; upsert uses(organization, member, token)lookup. - Added
list_devicesview (GET /api/devices/): returns active devices for current user+org, filtered byis_active=True, tenant-scoped. - 10 tests in
backend/tests/test_device_registration.pyall 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_deviceresponse shape changed (breaking for any consumer parsingregisteredkey — none existed yet).- New
GET /api/devices/endpoint added tonotifications/urls.py.
Tests:
- F36-? (10 test methods):
backend/tests/test_device_registration.pytest_register_device_happy_path— 201 with idtest_register_device_requires_auth— 401test_register_device_duplicate_token— upsert idempotenttest_register_device_different_user_same_token— cross-org same token OKtest_re_registration_reactivates_device— unregister → register → activetest_invalid_platform_returns_400test_unregister_device— 204, is_active=Falsetest_unregister_device_requires_auth— 401test_list_devices— only active, own usertest_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_pushnow performs a real FCM HTTP v1 call: builds OAuth2 credentials viagoogle.oauth2.service_account.Credentials(scopefirebase.messaging), refreshes the token, and POSTs tohttps://fcm.googleapis.com/v1/projects/{project_id}/messages:sendwithurllib.request. Returns the FCM message ID parsed from the responsenamefield.- Raises
FCMInvalidTokenErroron a 404/HTTPErrorresponse (unregistered token), covering both the context-manager-status andHTTPErrorpaths. - Added
google-auth>=2.0,<3.0tobackend/pyproject.toml. backend/tests/test_fcm_delivery.pytests 1-3 upgraded fromxfail(strict=True)TDD red to passing green, mockingCredentialsandurllib.request.urlopen.
Reason:
send_fcm_pushpreviously raisedNotImplementedErrorunconditionally — the push notification pipeline could schedule sends but never deliver them.
Impact:
backend/apps/notifications/views.pyand beat tasks that callsend_fcm_pushnow perform real delivery whenFCM_SERVICE_ACCOUNT_JSONis 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_raisesupdated to expectgoogle.auth.exceptions.MalformedError(the oldNotImplementedErrorpath 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_pushHTTPError paths (404 → FCMInvalidTokenError, non-404 → re-raise), closing the last 3 uncovered lines inpush.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 → FCMInvalidTokenErrortest_send_fcm_push_http_non_404_raises_through— non-404 HTTPError re-raised