Spec: F94 — On-Device Ambient Nudge
A weekly, locally scheduled notification from cached data, with no server or FCM dependency. It earns one app-open with a true, useful action rather than generic encouragement. It is the client-side arm of the F91 Bet 4/F93 restraint doctrine, with no new exception.
Status: draft
1. Problem & CEO Job
F94 serves trust the signal by sustaining the daily habit. Server re-engagement requires an available backend, Celery worker, configured FCM (not yet F36), and network; any failure leaves a drifting week-2–4 user untouched. F94 instead schedules one natural-feeling weekly nudge on the device from cached data. A useful nudge earns an app-open even if the backend was unavailable all week.
2. User Stories & Acceptance Criteria
-
As a drifting member, I want one well-timed, genuinely useful weekly nudge, so that I’m invited back without being nagged.
- Given the app has been opened at least once (permission granted) and ambient nudges are enabled, when a week passes, then exactly one local notification is scheduled and fires at a random time inside the allowed window (not during quiet hours), with no network or backend call involved.
- Given the device is offline for the whole week, when the scheduled time arrives, then the nudge still fires (proof it is fully on-device).
- Given I tap the nudge, when the app opens, then it lands on the surface the nudge referenced (Today, or the Assistant), not a generic home screen.
-
As any member, I want the nudge to say something real, not filler.
- Given cached data shows a concrete signal (e.g. N open commitments, a brief unseen for D days, an active streak), when the nudge composes, then it states that specific, true fact and the action it implies (“3 commitments due this week — open to see which”).
- Given no cached signal is available, when the nudge composes, then it uses a genuinely useful fallback (one thing Lumi can do for the week), never a hollow motivational cliché. A nudge with no honest content is not sent (doctrine: “if there’s no action, don’t send”).
- Given my app language is X, when the nudge composes, then its text is localized to X.
-
As any member, I want control.
- Given I turn ambient nudges off, when the scheduler next runs (or immediately), then all pending local notifications are cancelled and none are re-scheduled until I turn it back on.
- Given quiet hours are set, when a random slot is picked, then it never lands inside quiet hours.
- Given a nudge fired this week, when the app foregrounds again, then no additional nudge is scheduled before the next week boundary (max one per 7 days).
-
Measurement (when online, best-effort — never blocks the nudge).
- Given I open the app from a nudge, when the device next reaches the server, then an
app_open/nudge_openedactivation signal is recorded (feeds F85 + gives F93 the app-presence signal its chat-reachability wanted).
- Given I open the app from a nudge, when the device next reaches the server, then an
3. Scope Boundaries
- In:
- A KMP
expect/actualon-device scheduler (:shared) that picks a weekly random slot, respects quiet hours + opt-out, and (re)schedules platform-local notifications. - Platform
actuals: iOSUNUserNotificationCenter(calendar/interval triggers), AndroidWorkManager+NotificationManagerCompat(survives reboot). - An on-device content composer (tone: blend — decided 2026-07-05): a pure function from cached state (commitments count, last-brief age, streak, pending items) → a specific, actionable line when a real signal exists (“3 commitments due this week — open to see which”); a warm, human fallback line when there’s no signal (“New week. Lumi’s ready when you are — one voice note starts it”). Localized. Never a hollow cliché, never fake urgency.
- A local cadence ledger (last-fired timestamp) + a rolling re-schedule on app foreground.
- Opt-out UI (a toggle) and quiet-hours reuse (from cached
NotificationPreference). - Best-effort
nudge_openedactivation signal emitted to the backend when next online.
- A KMP
- Out:
- Server-triggered push / FCM (that’s F36) and server orchestration (F93). F94 explicitly needs neither. It shares their restraint doctrine but is a separate mechanism.
- Rich/interactive notification actions, images, badges — plain text nudge for v1.
- Web (no local-notification story that fits; browsers differ) — F94 is native mobile (iOS + Android) only; Mac later if justified (there is existing
ios/LumiMac/Notifications/LumiNotificationCenter.swiftto learn from, not reuse). - Model-generated nudge copy — v1 uses a small on-device ruleset over cached fields (deterministic, testable, works offline). A smarter on-device/LLM composer is a later phase.
4. Data Model
Almost entirely on-device; the backend surface is deliberately tiny.
On-device (KMP :shared, persisted in the existing local settings/store):
AmbientNudgeState:enabled: Boolean(default true after first app-open),lastFiredAtEpoch: Long?,scheduledSlots: List<Long>(epoch times currently scheduled),weekSeed: Long(deterministic per ISO-week so re-scheduling on foreground doesn’t jitter the chosen slot).- Reuses cached
NotificationPreference(quiet-hours start/end) already synced to the device; no new remote read required to schedule.
Backend (minimal, additive — no new tenant-scoped table):
- New
ActivationEventevent typenudge_opened(F85 ledger; one existing pattern — soft, dedup-keyed per (member, week)). This is the only backend change strictly required, and it doubles as theapp_openpresence signal F93 §12 flagged as missing for chat-reachability. - Optional (nice-to-have, not required for v1): mirror the opt-out to
NotificationPreference.ambient_nudge_enabled(a new boolean,db_default=true) so the choice survives reinstall / syncs across a user’s devices. If added it follows the F93 opt-out rules; the device still operates purely from its local copy when offline.
No migration is needed for the event type (choices list edit). The optional pref flag would be one additive db_default column (rolling-deploy-safe).
5. API Contract
F94 needs no new endpoints to function (that’s the point). Touchpoints reuse existing APIs:
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/metrics/activation/ (or the existing emit() path F85 exposes) | member | Best-effort nudge_opened emit when the app opens from a nudge, next time online. |
| PATCH | /api/notifications/notification-preferences/me/ | member | If the optional server-mirrored ambient_nudge_enabled is added — otherwise opt-out is device-local only. |
Tenant scoping: the activation emit is scoped to request.organization_id like every other F85 event. If AI-generated output: none — nudge copy is templated from cached numeric/state facts, not model-authored; no EvidenceSource, no confidence score. (A future LLM composer would run on-device and still cite cached facts.)
6. UI / UX
- The notification itself (platform-native): a short title + one-line body in the user’s language, tap → deep-link to the referenced surface (
/todayor/intel/chat). - Settings toggle: add an “Ambient weekly nudge” row to the existing mobile notification-settings screen (the same screen that holds push/quiet-hours). Copy explains: one gentle nudge a week, at a random time, even offline; off any time.
- States: permission-not-granted (show the OS primer, don’t schedule), enabled (scheduled), disabled (cancelled), quiet-hours-active (slot avoids it).
- Design: notification copy obeys DESIGN.md/TASTE.md voice — specific, calm, no exclamation-slop, no fake urgency. The toggle uses the shared settings row component; semantic tokens only.
- i18n: new
nudgekeys (all locales via the propagate script):nudge_commitments_due,nudge_brief_unseen,nudge_streak,nudge_fallback_week,nudge_settings_title,nudge_settings_desc,nudge_toggle. - Android release retention: notification copy keys are an explicit allowlist mapped to direct
R.string.*references. Runtime name lookup is forbidden because release resource shrinking may remove resources that have no static reference. Unknown keys still return the key defensively. - Assistant path (F91 doctrine): the user can tell the assistant “stop the weekly reminders” / “how often do you nudge me?” and it explains + flips the toggle (a preference change — ceiling-safe, no money/external/delete). The settings toggle is the optional direct path. The nudge itself is a proactive-with-restraint act, so it’s assistant-philosophy-native by construction.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Slot picker returns a weekly time inside the allowed window and never inside quiet hours; deterministic per week seed | shared unit | :shared AmbientNudgeSchedulerTest (commonTest) |
| 2 | Content composer: N open commitments → the commitments line; brief unseen D days → the brief line; no signal → the honest fallback; empty/nothing-useful → not sent | shared unit | AmbientNudgeComposerTest (commonTest) |
| 3 | Cadence: at most one nudge per 7 days; a fire updates lastFiredAtEpoch; foreground re-schedule does not add a second this week | shared unit | AmbientNudgeSchedulerTest |
| 4 | Opt-out cancels all pending slots and schedules none until re-enabled | shared unit | AmbientNudgeSchedulerTest |
| 5 | Localization: composer output is in the member’s language | shared unit | AmbientNudgeComposerTest |
| 6 | nudge_opened activation emit is idempotent per (member, week) and tenant-scoped; cross-tenant isolation | backend | backend/tests/test_f94_nudge_activation.py + test_multitenancy.py |
| 7 | iOS actual schedules/cancels UNNotificationRequests (fake center) | iOS | ios/LumiTests AmbientNudgeTests |
| 8 | Android actual enqueues/cancels WorkManager work + posts via NotificationManager (robolectric/fake) | android | :shared androidUnitTest / app test |
| 9 | Android maps all five shared notification-copy keys to direct resource IDs, formats arguments, and safely falls back for unknown keys; the shrunk release artifact retains all five resources | android unit + release artifact | AndroidNudgeStringsTest + release resource-table inspection |
Shared logic (slot picking, composing, cadence) is platform-agnostic in commonTest — the bulk of the value is tested once there. Backend coverage stays ≥97%. No new E2E (native-only, exercised by mobile compile + unit gates).
8. Current Behavior
Android schedules device-local F94 notifications through WorkManager and resolves their title/body
from native string resources; iOS has the corresponding local-notification actual. Android release
builds shrink resources, so every supported notification-copy key must have a direct R.string
reference or its resource can disappear from the shipped artifact. The shared composer owns key
selection and arguments; each native shell owns localized resource resolution.
9. Error States
- Notification permission denied → do not schedule; surface the OS primer once via the settings screen; never crash, never retry aggressively.
- iOS 64-pending limit → schedule at most the next few weeks; refresh on foreground (rolling window), so the cap is never hit.
- Android exact-alarm restrictions (Android 12+) → prefer
WorkManagerperiodic (inexact, survives reboot) over exactAlarmManager; if only inexact timing is available, accept ±window drift (a nudge is not time-critical). - Reboot → WorkManager persists; if
AlarmManageris used, re-arm onBOOT_COMPLETED. - No cached data at all (fresh install, never synced) → the honest fallback line; if even that isn’t meaningful yet (day 0), skip — the F91 first_value/welcome path owns day 0, not F94.
- Activation emit fails/offline → swallow silently and retry on next online app-open; the nudge itself never depends on it. No user-visible error for any nudge path (a failed nudge is simply absent).
10. Diagrams & Docs Touched
- [android/DIAGRAMS.md] / mobile diagrams: add the on-device nudge scheduler + WorkManager/UNUserNotificationCenter path.
- [GLOSSARY.md]: Ambient Nudge, nudge slot, cadence ledger (local).
- [backend/DIAGRAMS.md] + GLOSSARY status table: add the
nudge_openedActivationEventtype. - Depends on / relates to: F30 (mobile shell), F85 (activation ledger — new event type), F93 (shares restraint doctrine; F94 supplies the
app_openpresence signal F93 §12 wanted), F91 (Bet 4 proactive-with-restraint; assistant opt-out path), F36 (the server push F94 is the offline complement to). An ADR may be warranted for “on-device scheduling & the local-vs-server restraint split” (see §12).
11. Implementation Audit (2026-07-05)
Backend done; Android in progress; iOS not started. State of the implementation:
- Shared core (#1221):
AmbientNudgeComposer(blend tone — specific-with-signal, warm fallback, null on day-0) +AmbientNudgeScheduler(deterministic weekly slot, one-per-7-days, opt-out, quiet-hours-aware) + theNudgePlatform/NudgeStore/NudgeStringsseams, all purecommonMain, 22commonTest. - Backend (#1222):
nudge_openedActivationEventtype +emit_nudge_opened()(week-idempotent, tenant-safe) — also supplies theapp_openpresence signal F93 §12 wanted. - Android actual (#1226):
WorkManagerNudgePlatform+NudgeWorker(NotificationManagerCompat channel + deep-link intent),PrefsNudgeStore,AndroidNudgeStrings,AmbientNudgeCoordinatorwired intoMainActivity.onResume;POST_NOTIFICATIONS;nudge_*strings en+vi. - Smart signal (#1240):
openCommitmentsDueThisWeekgathered from the real commitments repo at foreground (off-main, offline-safe → degrades to the warm fallback); purecountCommitmentsDueThisWeek. - iOS actual (compile-verified + simulator-runtime-verified 2026-07-05): launched on a booted
iPhone 17 Pro simulator (
xcrun simctl install+launch) — the app boots to the auth screen and stays running with zero crash reports, confirming thesceneDidBecomeActive→ scheduler construction (KMP↔Swift interop) → async permission/scheduleTaskpath is runtime-safe, not just compile-safe. (Still unverified: the notification actually firing — needs a logged-in session + elapsed time; the compose/slot/cadence logic is covered by the 22commonTest.) Source:ios/Lumi/Nudge/AmbientNudgeIos.swift—IosNudgePlatform(UNUserNotificationCentertime-interval trigger; sync-protocol↔async bridge via a cached auth flag- local scheduled-id set),
IosNudgeStore(UserDefaults),IosNudgeStrings(blend copy), andAmbientNudgeController(builds the sharedAmbientNudgeScheduler, requests permission + drives it) wired intoSceneDelegate.sceneDidBecomeActive. Built green viaxcodebuild ... EXCLUDED_ARCHS=x86_64after rebuilding the XCFramework. Conservative iOS signals for v1 (parity with the Android actual’s initial state).
- local scheduled-id set),
- Remaining:
daysSinceBriefSeen/streakWeekssignals (no clean on-device source yet), iOS commitments-due signal enrichment (parity with Android #1240), the opt-out settings toggle (mobile users can’t yet turn the nudge off — restraint gap), full in-app deep-link routing, and on-device runtime verification (compile is green on both platforms; whether the nudge actually fires needs a device/simulator run — the cells stay 🚧 until confirmed firing on a shipped shell).
Constraints that shape the design:
- Mobile is KMP: shared logic (slot picking, composing, cadence, opt-out) belongs in
:shared/commonMainwithexpect/actualonly for the platform notification API — maximizing thecommonTest-covered surface (per [[kmp-mobile-gotchas]]::sharedusesjvm()notandroid(), inject platform APIs from the app layer). - The scheduler must be idempotent and re-entrant — it runs on every app foreground; picking must be deterministic per week (seed) so re-runs don’t move the slot.
- Restraint must reuse the cached
NotificationPreferencequiet-hours the device already has; do not invent a second quiet-hours notion. - Content signals are gathered at app foreground (usually online) and degrade soft to the warm fallback if the fetch fails — so the offline guarantee lives on the firing (WorkManager fires the pre-composed notification with no network), not on signal-gathering. (Evolved from the original “cache-only, no fetch” idea in #1240: there is no synchronous on-device commitments cache, and gathering-at-foreground-with-soft-fallback preserves the offline-fires property.)
12. Open Questions / Decisions
iOS actual — scoped & de-risked (2026-07-05), ready to implement
The iOS NudgePlatform/NudgeStore/NudgeStrings actuals aren’t built yet. The exact, verified path
(so the next effort is execution, not re-discovery):
- Prerequisite — rebuild the XCFramework. The linked
android/shared/build/XCFrameworks/release/ shared.xcframeworkis stale: itsHeaders/shared.hhas noNudge*symbols, so Swift can’t see the F94 protocols until it’s rebuilt. Runcd android && ./gradlew assembleXCFramework(heavy — all slices) after any:sharedchange. This is the same “forgot to rebuild the XCFramework” trap as postmortem 2026-06-17. - Local build works (no CI Mac runner needed):
xcodebuild build -project ios/Lumi.xcodeproj -scheme Lumi -destination 'generic/platform=iOS Simulator' -configuration Debug CODE_SIGNING_ALLOWED=NO EXCLUDED_ARCHS=x86_64— the XCFramework ships arm64-sim only, so exclude x86_64. Verified BUILD SUCCEEDED. - File placement is trivial:
ios/Lumiis aPBXFileSystemSynchronizedRootGroup, so new Swift files underios/Lumi/Nudge/auto-include in the target — no.pbxprojedits. - Reference:
ios/LumiMac/Notifications/LumiNotificationCenter.swiftshows the exactUNUserNotificationCenteridiom (requestAuthorization,UNMutableNotificationContent,UNNotificationRequest,center.add). Wire the coordinator intoSceneDelegate.sceneDidBecomeActive(ios/Lumi/Core/AppDelegate.swift); compose viaAppEnvironment.bootstrap(). - The one design wrinkle: the shared
NudgePlatformprotocol is synchronous (hasPermission(),pendingIds()), butUNUserNotificationCenteris async. The actual must cache the auth status + a local scheduled-id set (refresh async on foreground before callingscheduler.onAppForeground), then serve them sync.NudgeStore→UserDefaults; nullableLong?returns map toKotlinLong?. - Runtime verification still needs a device/simulator run (does it actually fire) — same limit as the Android WorkManager actual, which was shipped compile+unit-verified.
Other
- ADR? “On-device scheduling + local/server restraint split” — worth an ADR if the cross-device opt-out mirror is added (two sources of truth for one preference). Decide when scoping the backend slice. If opt-out stays device-local for v1, no ADR needed.
- Random-slot policy: weekday-morning window vs. any-day? Default: a random weekday in a local 8:00–11:00 window minus quiet hours — feels like a natural “start of a workday” nudge. Tunable config.
Content tone: motivation vs. smart-from-data.Decided 2026-07-05 — blend: specific & actionable when a cached signal exists, a warm human fallback when it doesn’t (see §3/§6).- Content ruleset vs. on-device model: v1 is a deterministic ruleset over cached fields. Open whether a small on-device model later composes the line (still from cached facts) — deferred; noted so we don’t hard-code copy that a model couldn’t later improve.
- Server opt-out mirror: include
NotificationPreference.ambient_nudge_enablednow (cross-device durability) or keep device-local for v1? Lean device-local for v1 (true to “no server required”), add the mirror when a user with multiple devices asks.
13. Bug / Change Log
2026-08-11 — Android release keeps localized nudge copy
Type: Bug fix
Summary:
- Android previously resolved the five shared
nudge_*keys only by runtime name. Release resource shrinking could therefore remove the localized resources and expose raw keys such asnudge_titleandnudge_fallback_weekin the system notification tray. - The Android resolver now maps the bounded shared-key allowlist to direct
R.string.*references, while preserving locale-aware positional formatting and the defensive unknown-key fallback.
Proof boundary:
AndroidNudgeStringsTestcovers every allowed key, positional formatting, and unknown-key fallback; release resource-table inspection proves the five notification-copy resources survive shrinking.- Source, JVM, and artifact evidence do not prove notification firing or rendering on the reporter’s physical Android device; that remains tester verification.
2026-07-16 — iOS permission waits for authentication
Type: Bug fix
Summary:
SceneDelegate.sceneDidBecomeActivecould enter the ambient-nudge path before login, and the iOS actual requested notification authorization unconditionally.- The foreground boundary now exits for signed-out sessions and re-enters when the authenticated organization becomes available, so the OS prompt belongs to an in-context member session.
Proof boundary:
AmbientNudgePermissionTestsproves the auth gate at source level.- Simulator and source proof do not establish the named iPhone 12 choreography or move the SYS-07/F94 mobile audit score.
2026-07-16 — iOS foreground no longer cold-prompts
Type: Bug fix
Summary:
- Authentication is necessary but not sufficient to request notification permission. When authorization is undetermined, the foreground path now asks the shell to present the localized F36 primer and returns without scheduling.
- Continue durably marks the primer handled before invoking the operating-system prompt; Not now or dismissal marks it handled without prompting. Granted/provisional authorization converges the scheduler directly, while denied or deferred states remain quiet.
- The shared Settings notification-access row is now bridged to iOS, preserving the non-nagging retry route after denial or deferral.
Proof boundary:
- Pure policy and persistence tests plus bridge-source proof establish custody and callback order. Named iPhone 12 execution remains PM-owned and open.
2026-07-15 — Re-enabling immediately reconverges the schedule
Type: Bug fix
Summary:
- Added the stable
settings_ambient_nudge_togglesemantic selector. - Android and iOS now run their foreground convergence path immediately after
persisting
enabled=true; users no longer have to background and reopen Lumi before local work is recreated. - Added an APK-bound Android proof that starts with one active WorkManager row,
proves
1 → 0on disable, proves the off state after relaunch, and proves0 → 1on re-enable.
Proof boundary:
- Pixel 7 API 35 emulator execution on 2026-07-15 used exact expected/installed
APK SHA-256
14962573a104fdb342ac51fae894a51175be194b30c4b32eb7287a0ea419b2c9. - The rebuilt shared release XCFramework and full Lumi iOS simulator target compiled successfully; this is compile evidence only.
- The named Samsung A1x and iPhone 12 gates remain open. This evidence does not
change
docs/tests/mobile-screen-audit.mdor prove notification firing.
2026-07-15 — Settings opt-out wired across native shells
Type: Implementation
Summary:
- Added the F94 device-local toggle to the shared mobile Settings screen.
- Android reads and writes the existing
PrefsNudgeStorethroughAmbientNudgeCoordinator; iOS reads and writesIosNudgeStorethroughAmbientNudgeController.shared. - Disabling continues to use the scheduler’s existing immediate cancellation path.
- Re-enabling now immediately invokes each native foreground convergence path;
the earlier implementation only persisted
trueand waited for a later foreground.
Proof boundary:
- Shared scheduler and Android persistence/unit tests remain source proof.
- The named Android floor device and iPhone 12 must still verify the rendered toggle, relaunch persistence, and absence of pending local notifications.
- No mobile audit score is changed by this implementation.
2026-07-05 — Spec created
Type: Feature
Summary:
- Created F94: on-device, server-free weekly ambient nudge (KMP
:sharedscheduler + iOSUNUserNotificationCenter/ AndroidWorkManageractuals), smart content composed from cached data with an honest fallback, weekly random slot respecting quiet-hours + opt-out, best-effortnudge_openedactivation signal (which also supplies F93’s missingapp_openpresence signal).
Reason:
- Re-engagement currently depends on the whole server+FCM+network chain; a drifting user gets nothing if any link is down. A device-local nudge removes that dependency — the one nudge that can’t fail from server state. Requested by the user.
Impact:
- New native-mobile mechanism; backend change is only the additive
nudge_openedevent type (+ an optional opt-out mirror flag). Shares the restraint doctrine; adds no exception.
Tests:
- Planned per §7 (shared commonTest for slot/compose/cadence/opt-out; backend activation idempotency + tenant isolation; platform actual smoke tests).
Notes:
- Compose from cached data ONLY (offline-safe). Scheduler must be idempotent per week-seed. Reuse cached quiet-hours; never a second quiet-hours notion. Day 0 belongs to F91 welcome, not F94.