draft

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_opened activation signal is recorded (feeds F85 + gives F93 the app-presence signal its chat-reachability wanted).

3. Scope Boundaries

  • In:
    • A KMP expect/actual on-device scheduler (:shared) that picks a weekly random slot, respects quiet hours + opt-out, and (re)schedules platform-local notifications.
    • Platform actuals: iOS UNUserNotificationCenter (calendar/interval triggers), Android WorkManager + 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_opened activation signal emitted to the backend when next online.
  • 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.swift to 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 ActivationEvent event type nudge_opened (F85 ledger; one existing pattern — soft, dedup-keyed per (member, week)). This is the only backend change strictly required, and it doubles as the app_open presence 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:

MethodPathAuthPurpose
POST/api/metrics/activation/ (or the existing emit() path F85 exposes)memberBest-effort nudge_opened emit when the app opens from a nudge, next time online.
PATCH/api/notifications/notification-preferences/me/memberIf 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 (/today or /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 nudge keys (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

#CaseTypeWhere
1Slot picker returns a weekly time inside the allowed window and never inside quiet hours; deterministic per week seedshared unit:shared AmbientNudgeSchedulerTest (commonTest)
2Content composer: N open commitments → the commitments line; brief unseen D days → the brief line; no signal → the honest fallback; empty/nothing-useful → not sentshared unitAmbientNudgeComposerTest (commonTest)
3Cadence: at most one nudge per 7 days; a fire updates lastFiredAtEpoch; foreground re-schedule does not add a second this weekshared unitAmbientNudgeSchedulerTest
4Opt-out cancels all pending slots and schedules none until re-enabledshared unitAmbientNudgeSchedulerTest
5Localization: composer output is in the member’s languageshared unitAmbientNudgeComposerTest
6nudge_opened activation emit is idempotent per (member, week) and tenant-scoped; cross-tenant isolationbackendbackend/tests/test_f94_nudge_activation.py + test_multitenancy.py
7iOS actual schedules/cancels UNNotificationRequests (fake center)iOSios/LumiTests AmbientNudgeTests
8Android actual enqueues/cancels WorkManager work + posts via NotificationManager (robolectric/fake)android:shared androidUnitTest / app test
9Android 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 resourcesandroid unit + release artifactAndroidNudgeStringsTest + 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 WorkManager periodic (inexact, survives reboot) over exact AlarmManager; if only inexact timing is available, accept ±window drift (a nudge is not time-critical).
  • Reboot → WorkManager persists; if AlarmManager is used, re-arm on BOOT_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_opened ActivationEvent type.
  • Depends on / relates to: F30 (mobile shell), F85 (activation ledger — new event type), F93 (shares restraint doctrine; F94 supplies the app_open presence 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) + the NudgePlatform/NudgeStore/NudgeStrings seams, all pure commonMain, 22 commonTest.
  • Backend (#1222): nudge_opened ActivationEvent type + emit_nudge_opened() (week-idempotent, tenant-safe) — also supplies the app_open presence signal F93 §12 wanted.
  • Android actual (#1226): WorkManagerNudgePlatform + NudgeWorker (NotificationManagerCompat channel + deep-link intent), PrefsNudgeStore, AndroidNudgeStrings, AmbientNudgeCoordinator wired into MainActivity.onResume; POST_NOTIFICATIONS; nudge_* strings en+vi.
  • Smart signal (#1240): openCommitmentsDueThisWeek gathered from the real commitments repo at foreground (off-main, offline-safe → degrades to the warm fallback); pure countCommitmentsDueThisWeek.
  • 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 the sceneDidBecomeActive → scheduler construction (KMP↔Swift interop) → async permission/schedule Task path 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 22 commonTest.) Source: ios/Lumi/Nudge/AmbientNudgeIos.swift — IosNudgePlatform (UNUserNotificationCenter time-interval trigger; sync-protocol↔async bridge via a cached auth flag
    • local scheduled-id set), IosNudgeStore (UserDefaults), IosNudgeStrings (blend copy), and AmbientNudgeController (builds the shared AmbientNudgeScheduler, requests permission + drives it) wired into SceneDelegate.sceneDidBecomeActive. Built green via xcodebuild ... EXCLUDED_ARCHS=x86_64 after rebuilding the XCFramework. Conservative iOS signals for v1 (parity with the Android actual’s initial state).
  • Remaining: daysSinceBriefSeen/streakWeeks signals (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/commonMain with expect/actual only for the platform notification API — maximizing the commonTest-covered surface (per [[kmp-mobile-gotchas]]: :shared uses jvm() not android(), 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 NotificationPreference quiet-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.xcframework is stale: its Headers/shared.h has no Nudge* symbols, so Swift can’t see the F94 protocols until it’s rebuilt. Run cd android && ./gradlew assembleXCFramework (heavy — all slices) after any :shared change. 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/Lumi is a PBXFileSystemSynchronizedRootGroup, so new Swift files under ios/Lumi/Nudge/ auto-include in the target — no .pbxproj edits.
  • Reference: ios/LumiMac/Notifications/LumiNotificationCenter.swift shows the exact UNUserNotificationCenter idiom (requestAuthorization, UNMutableNotificationContent, UNNotificationRequest, center.add). Wire the coordinator into SceneDelegate.sceneDidBecomeActive (ios/Lumi/Core/AppDelegate.swift); compose via AppEnvironment.bootstrap().
  • The one design wrinkle: the shared NudgePlatform protocol is synchronous (hasPermission(), pendingIds()), but UNUserNotificationCenter is async. The actual must cache the auth status + a local scheduled-id set (refresh async on foreground before calling scheduler.onAppForeground), then serve them sync. NudgeStore → UserDefaults; nullable Long? returns map to KotlinLong?.
  • 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_enabled now (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 as nudge_title and nudge_fallback_week in 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:

  • AndroidNudgeStringsTest covers 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.sceneDidBecomeActive could 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:

  • AmbientNudgePermissionTests proves 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_toggle semantic 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 → 0 on disable, proves the off state after relaunch, and proves 0 → 1 on 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.md or 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 PrefsNudgeStore through AmbientNudgeCoordinator; iOS reads and writes IosNudgeStore through AmbientNudgeController.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 true and 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 :shared scheduler + iOS UNUserNotificationCenter / Android WorkManager actuals), smart content composed from cached data with an honest fallback, weekly random slot respecting quiet-hours + opt-out, best-effort nudge_opened activation signal (which also supplies F93’s missing app_open presence 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_opened event 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.