Spec: F30 — Mobile (iOS + Android): Full Lumi, Phone-Shaped
Current V2 shell:
Lumi·Work·Business·Hub.
Product contract first. Implementation order lives in ../implementation/P13-mobile-assistant.md. Architecture depth lives in ../mobile/mobile-plan.md. Entry, onboarding, and paywall flows live in ../mobile/mobile-onboarding.md.
Navigation supersession (2026-08-05): this spec still owns platform breadth, gate flows, parity, and test rails, but the authoritative mobile shell / tab architecture now lives in F79. The current four-tab order is
Lumi·Work·Business·Hub;Lumiis assistant-first,Workis the short Work Graph label, and Company Hub is grouped inside Hub. Older shell terminology below is historical where it conflicts with this target IA.Android onboarding supersession (2026-07-04): F91 Pillar E replaces Android’s mandatory first-run carousel. A signed-out fresh install routes directly to Auth; after authentication the assistant conversation owns onboarding. The same five explanatory pages remain available as the optional
Hub → How Lumi workstour. iOS retains its existing first-run carousel until a separately reviewed platform change says otherwise. Older cross-platform carousel wording below is historical where it conflicts with this rule.
Status: agreed — contract upgraded 2026-06-11 (full-coverage + onboarding/paywall reversal; supersedes the narrow “control loop only” contract from earlier the same day)
Contract Graph boundary (2026-08-05): F30.cue owns the repository-level Android/iOS four-tab source-and-documentation parity claim and its executable checker proof. F79 remains authoritative for the product IA and owns named Android instrumentation and iOS XCTest runtime evidence; F30 does not infer device truth from source inspection.
⚠️ Strategy update (2026-06-15) — the entry paywall in this spec is SUPERSEDED by F72 Freemium. The “hard paywall / no free tier / land on the paywall when the org has no subscription” sections below (§2 “Entry, onboarding, and the gate”, §3 Out, the F24-billing matrix row) are no longer the contract. Under F72 a free/
noneorg enters the workspace — authenticated user + an org always reaches Today. The native paywall (PaywallView/PaywallScreen) is repurposed as an in-app upgrade surface reached when a free user opens a premium feature (advanced brief, AI extraction, CRM, approvals, internal chat), not as an entry gate. Store billing, invitation-code redemption, and server-truth entitlement all remain exactly as written; only the gate-on-entry behavior is removed. Read F72 for the authoritative gating contract; the criteria below are retained for the unchanged purchase/redeem/store-verify flows.
Proof-discipline update (2026-07-15): this spec treats lifecycle/error parity as platform-specific truth, not wishful symmetry. Android has direct
Retry-Afterproof today; iOS now has deterministic transport proof for the offline failure seam, while native429and stale-409proof remain open. T205 still requires same-day named-device offline lifecycle evidence. Invitation-link backward compatibility is now materially better: both native shells parse the canonical/auth/accept-invitation/<uuid>path plus the legacy direct path, but full device-level app-link verification is still open.Nullability crash gate (2026-07-08): production shared KMP source sets (
android/shared/src/*Main/**/*.kt) must contain zero Kotlin non-null assertions (!!). The local gate ismake check-shared-kmp-no-bang; CI KMP runs the same scanner before platform parity. This converts the iOS assistant TestFlight crash class into a lint failure instead of a reviewer-memory rule. Android performance release gate (2026-07-10): F102 adds Macrobenchmark cold/warm start tests, Baseline Profile generation, a CI regression gate with checked-in emulator baselines, and release-verdict docs. It explicitly distinguishes emulator validation (L1/L2) from physical-device truth (L3). The physical-device and Sentry crash-budget checks remain open and human-operated.
1. Problem & CEO Job
A busy, non-technical, phone-first Vietnamese SME CEO needs more than a stripped-down companion that creates desktop homework or twenty-two hamburger-menu screens that lose them in week one.
The mobile contract holds two promises at once:
- Full reach. Every feature the web product ships is reachable on the phone. No “open your laptop for that.”
- Calm core. The daily experience is four large, labeled tabs. Breadth lives behind Hub, organized so a fifty-year-old finds anything in two taps without being taught.
This is not parity theater or a feature farm; it is the same product, phone-shaped for a non-technical executive: big type, visible buttons, one primary action per screen, and no gesture-only path.
Mobile is Lumi’s commercial front door: Android opens at Auth into the assistant; iOS currently shows the five-screen story first. Both present commercial truth. Owners/admins upgrade through native billing at premium work; invited employees use their company’s code. Routes are obvious, never fake locks.
This serves all three CEO jobs:
- See clearly: all four tabs and nested surfaces are phone-first.
- Decide fast: approvals, decisions, brief actions, task/commitment/risk resolution, and follow-ups have visible actions.
- Trust the signal: the desktop backend, tenant scoping, EvidenceSource-backed objects, and “quiet means quiet” rules apply unchanged; billing/entitlement is server truth, never a local flag.
2. User Stories & Acceptance Criteria
Entry, onboarding, and the gate
-
As a first-time visitor, I want the platform’s current welcome path to explain Lumi without delaying first value, so that I understand the product before the price.
- Given a fresh Android install, when the app opens, then it routes directly to Auth; the mandatory carousel is not shown and
onboarding_seendoes not affect routing (F91 Pillar E). - Given an authenticated Android user opens
Hub → How Lumi works, then the optional five-page reference tour preserves the original ideas, exposes visible progress plus Next/Got it and Close controls, and returns to Hub without changing account or organization state. - Given a fresh iOS install, when the app opens, then exactly five onboarding screens play in order (contract in mobile-onboarding.md §3), each with one headline, one visual, and one Next button.
- Given either platform renders the shared five-page story, when a page appears, then it uses its dedicated no-text photographic asset in the upper visual field while the localized title and supporting line remain native UI below; no generated image may become a second source of copy truth.
- Given a standard phone viewport and default text scale, when an onboarding page lays out, then the photographic field occupies approximately the upper two-thirds; compact height or enlarged text may reduce the image field so copy and primary actions remain readable and reachable.
- Given any iOS onboarding screen, when I look for navigation, then Next and Skip are both visible buttons; progress is never gesture-only. (KMP
OnboardingScreen, iOS gate behavior retained.) - Given I reach screen five in the iOS first-run story, when the final action renders, then it says Get started and completes onboarding into Auth; Android’s optional reference tour separately retains Got it and Close.
- Given I have completed or skipped iOS onboarding once on this device, when I reopen the app, then onboarding never plays again (local
onboarding_seenflag is display state only, never an access gate). - Given an existing valid session, when the app cold-starts, then onboarding and paywall are both skipped if entitlement holds — straight to the assistant-first shell.
- Given a fresh Android install, when the app opens, then it routes directly to Auth; the mandatory carousel is not shown and
-
As an owner or admin of a free/lapsed org, I want the upgrade path to be obvious but not block the whole workspace, so that I can keep operating while still seeing how to unlock premium surfaces.
- Given I authenticate and my current org has no active premium subscription, when bootstrap completes, then I still land inside the workspace — entry is not blocked by a paywall.
- Given I complete first-time organization setup and the resulting subscription response says
can_manage_billing:truewithout an active or trialing plan, when bootstrap resolves, then Lumi presents the existing native pricing surface exactly once for that setup completion; dismissing it reveals the already-mounted workspace, and later launches do not replay it. - Given organization setup joins me to an active org or an org where
can_manage_billing:false, when bootstrap resolves, then Lumi enters the workspace without presenting purchase controls. - Given I am an owner/admin of a non-active org, when the shell loads, then I see the top-of-shell billing alert and the same in-app upgrade surface reachable from Subscription/premium-entry points.
- Given the upgrade surface renders, then it shows the same native-store product truth the app is configured to sell, purchasable through StoreKit 2 on iOS and Play Billing on Android.
- Given Google Play returns a nominally successful billing response without any supported
PendingIntent, when Android creates the Billing Library proxy activity, then Lumi reports a retryable billing error and returns to pricing instead of crashing the process; valid and restored billing flows remain owned by Play Billing. - Given a completed store purchase, when the receipt is verified by the backend (§5 store-verify endpoint), then a real
Subscriptionrow activates for my org and premium access refreshes from server truth — entitlement is never asserted locally. - Given the upgrade surface, when I tap “I have an invitation code,” then I can type a short human code (e.g.
LUMI-7K3M), join the inviting org, and proceed with that org’s entitlement state. - Given I am a member (not owner) of an org whose subscription lapsed, when I authenticate, then I still enter the workspace but only see owner-contact upgrade messaging on premium surfaces, never a purchase screen for an org I do not own.
-
As an invited employee, I want the code my CEO sent me over Zalo to be typeable, so that joining doesn’t require a link that got buried in chat.
- Given a valid short invitation code, when I enter it on the paywall or in Settings → Join organization, then membership is created against the same backend invitation object as the web link flow (one invitation, two redemption shapes).
- Given an expired/used/invalid code, when I submit it, then I get a plain-language error and my typed code stays in the field.
-
As a signed-out user who requested a password reset, I want the email link to open the native confirmation screen with its token already supplied, so that recovery does not require copying a UUID.
- Given
https://runlumi.app/auth/password-reset/confirm?token=<token>, when Android or iOS receives the URL, then the native shell routes to the shared password-reset confirmation screen and prefills<token>. - Given an invitation, unrelated, malformed, non-HTTPS, or unsupported-host URL, when the shell parses it, then it cannot be misclassified as a password reset; reset and invitation routes are path-scoped before token extraction.
- Given the app is already authenticated, when a reset URL arrives, then the confirmation route temporarily takes precedence without silently deleting the existing session; leaving or completing recovery returns to the state the session actually permits.
- Given a user taps the email link on a named Android floor device or iPhone 12, then the operating system opens Lumi directly. This remains blocked until
runlumi.appserves valid Android Digital Asset Links and Apple App Site Association documents for the production signing identities; native source, explicit ADB launches, simulator runs, and an iOS associated-domain entitlement do not prove this deployment boundary.
- Given
-
As a tester or support user, I want the installed artifact identity visible before and after login, so that every report can name the exact mobile build without guesswork.
- Given the shared login or Settings screen on Android or iOS, when build metadata is available, then Lumi shows
v<version> (<build>) · <YYYY-MM-DD HH:mm UTC>from that packaged artifact. - Given the build identity is visible, when I tap it, then the complete identity is copied and the localized control reports that copying succeeded; the interaction has a platform-appropriate minimum target and accessible button semantics.
- Android stamps build time into
BuildConfigduring Gradle configuration, while iOS stamps the built app’sInfo.plistbefore signing; neither surface calls a network service for identity data.
- Given the shared login or Settings screen on Android or iOS, when build metadata is available, then Lumi shows
The calm core
-
As a CEO opening the app between meetings, I want the default screen to be my action queue, so that ten seconds of attention clears real work.
-
⚠️ Landing/home superseded by F91 (2026-07-02): The cold-start post-login surface is re-scoped by F91 Pillar F: the AI assistant (
/intel/chat) is the post-login default surface at F91 P5 (behind a per-user setting until P5 ships). Pages — including Today — become urgency surfaces the assistant surfaces and deep-links into, not the cold-start default. The content contract below (pinned announcement, approvals/decisions/risks/tasks/commitments) remains the Today surface (Work Graph lane / F13), which the assistant renders on demand. F91 is the authority on the post-login landing; the AC below describes what Today shows when navigated to, not what opens on cold start after F91 P5. -
Given a valid session and entitlement, when the app opens, then the landing screen is Today: at most one pinned-announcement banner, then the “need your action” queue (open approvals where I’m approver + open decision requests), then risks, overdue/in-progress tasks, open commitments — same urgency rules as desktop F13/F26/F28.[Superseded by F91 Pillar F — see note above. Cold-start landing is the assistant-first surface; Pulse is F79 tab 2. Today content remains the urgency surface rendered from Pulse / Work Graph.] - Given a quiet org, when a Today section is empty, then it renders a positive empty state (“Không có gì cần bạn — tốt.”) instead of disappearing.
-
-
As a fifty-year-old non-technical user, I want the app to never assume I know mobile conventions, so that I am never stuck.
- Given any screen, when it renders, then the bottom tab bar (four tabs, icon and text label each) is visible — tabs never hide on scroll.
- Given any action a swipe could perform, when I look at the row, then a visible button performs the same action — swipe is an accelerator, never the only path.
- Given system font scaling at 200%, when any core screen renders, then no text truncates into ellipsis on a primary action and no control drops below 48×48dp/44×44pt.
- Given any destructive action, when I trigger it, then I get one plain-language confirm; given any reversible action, then I get an undo affordance instead of a confirm.
- Given any screen, when I look for “back,” then a labeled back affordance exists at top-left; Android hardware back always does the same thing the visible control does.
- Given Android authentication completes, when the main Navigation3 shell first renders,
then its host supplies a
NavigationEventDispatcherOwnerand the app does not crash while installing the back handler.
-
As a user of any of the product’s features, I want every web feature to have a phone home, so that the phone is the whole product.
- Given the Hub directory, when it opens, then every feature in the §3 matrix marked
Hubis present as a large labeled row, grouped exactly as §6.2 specifies, with live count badges where defined. - Given any feature in the matrix, when I navigate from a cold start, then it is reachable in at most two taps (tab → row) — measured and tested, not aspirational.
- Given the Hub directory, when it opens, then every feature in the §3 matrix marked
Feature behavior parity (binding by reference)
- Brief: ranking, 0-item quiet state,
ceo_action_suggestion, assign (creates real task with provenance), dismiss (records feedback, no source mutation) — per F12/F25. Stale items refresh to latest state instead of offering dead actions. - Pulse: same six questions and validation as F07; failure preserves typed answers; submitted state shows answers + processing status; form-or-readback, never both.
- Approvals/Decisions: queue, detail, approve/reject/cancel/decide with server permission gating per F26/F11; request-create allowed; policy builder is mobile backlog (not yet built).
- Tasks/Projects/Recurring: full list/detail/create/edit/complete/reopen per F05/F04/F06, phone-shaped (§6). Project detail also owns member add/remove, owner transfer, linked-channel list/create, and one-tap thread navigation; no web handoff.
- Field execution: field-task milestones, reportable worklogs, latest execution badges, task/project day digests, and Brief-linked field exceptions inside the same mobile task/project flows per F90. No web-only fallback for in-scope users.
- Commitments/Risks: list/detail/resolve/dismiss per F09/F10.
- Horizon: 14-day deadline list per F23 — a list, not a calendar grid, which is exactly right for phones.
- Ask Lumi: F14 threads; empty answers stay honest; never conflated with F29.
- Messages: F29 channels/topics/threads, send/reply/DM, sender-only message edit/delete, manager-gated channel edit/delete, reversible manager-gated channel archive, and parent-context entry from team/project details. Linked-channel membership is inherited and therefore has no Join/Leave affordance.
- Hub: read, search, ack per F28; announcement/doc create/edit/delete shipped.
- CRM: companies/deals/contacts full CRUD + stage move per F27; pipeline analytics is mobile backlog (not yet built).
- Teams/Org: view teams, org switch, member list, member role-change/remove, team create/add-remove/delete, linked-channel list/create, and one-tap thread navigation; invite-send allowed (it generates the same Invitation object). New-org creation returns directly to a ready workspace containing the eight F02 standard teams and channels; there is no department setup wizard. Custom-role capability-matrix administration is mobile backlog (not yet built).
- Settings: account, language (8 locales today, vi first; expands to 13 when F69 ships), org switch + join-by-code, notifications, subscription status (read + manage via store), logout.
- Follow-ups / Signal Inbox / Sources (F31/F32/F33): integrate into Today/Brief/detail flows and Hub rows when present on the backend the app talks to; no fifth permanent destination.
Container-owned workspaces (binding F02/F03/F04/F29 expansion)
- Given a project owner opens project detail, when owner/member/channel requests succeed, then the shared Android/iOS screen shows the owner, roster, and related channels with server-derived action capabilities.
- Given the owner adds/removes a project member or transfers ownership, while the request is in flight the previous committed state remains visible; success appears only after the server response and failure preserves prior state with an actionable retry.
- Given project capabilities, then update/delete/member/channel/transfer controls appear only for the project owner; an organization owner may see only the separately confirmed/audited transfer override when not already project owner.
- Given an authorized user opens team detail, then linked channels appear below the roster and can be created without re-selecting team members.
- Given any linked-channel row on a team/project detail, when tapped, then Android and iOS open the canonical F29 channel thread with the existing Messages navigation semantics.
- Given a linked channel appears in the global Messages directory, then it shows its parent, opens the same thread, and exposes no Join/Leave or direct edit/archive/delete control; governance routes to the parent detail.
- Given one detail subrequest fails, then loaded summary/roster/channel sections remain usable and only the failed section offers retry.
- Given the server omits an object capability, then no matching mutation control renders even if a cached client role label would normally allow it.
- Given 200% font scale and Vietnamese copy, owner, member, channel, transfer, confirm, and retry controls remain readable with 48dp/44pt targets and labeled accessibility semantics.
Session and tenancy (unchanged, binding)
- Mobile proof is now split by surface and remains incomplete. Android has direct unit coverage for “attach auth + org headers,” “refresh once on 401 then retry,” “clear persisted session on failed refresh,” and “respect
Retry-After” inandroid/core/network/src/test/kotlin/com/locuno/lumi/android/core/network/AuthInterceptorTest.kt. - Shared KMP request-header coverage exists in
android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/api/LumiApiClientTest.kt, which proves tenant-owned requests carryX-Organization-IDwhen the shared client is given an org id. - iOS now has focused native refresh/coalescing tests in
ios/UnitTests/Networking/TokenRefreshCoordinatorTests.swift, covering successful refresh persistence, failed refresh safety, concurrent caller coalescing, andJWTTokenProviderbridge refresh behavior. On this host we verified them up toxcodebuild build-for-testing; a fullxcodebuild testrun is still subject to simulator availability, and we still do not have full cross-platform stale-data + entitlement-recheck proof after an org switch. Treat §7 cases 6-7 as partially closed, not done.
3. Scope Boundaries
3.1 Mobile support matrix
Four tiers (Desktop-handoff tier retired 2026-07-12 per owner directive). Core tab = one of the four tabs. Work/Business/Hub row = a labeled row inside that primary destination. Detail flow = reached from Pulse/Work/Business/Hub lists. Context-only = appears inside other screens. Mobile gap (not yet built) = entry point and read-state render on mobile, but full native authoring/action surface is required — each gap is a temporary build item that closes with a real mobile affordance, never a permanent read-only or handoff screen. Source connections (F33), policy builder, custom-role capability-matrix administration, and pipeline analytics are tracked as mobile gaps.
| Feature | Mobile surface | Tier | Notes |
|---|---|---|---|
| F01 Auth | Auth shell + no-org setup gate | Core infra | login/register/restore/invite accept (link and code), then create org or join with code when memberships are empty |
| F02 Orgs & roles | Settings + Hub › Company | Hub | org create, org switch, member list, invite-send, join-by-code, leave org, delete org with confirmation; member role-change/remove shipped; custom-role capability-matrix administration mobile backlog (not yet built) |
| F03 Teams | Hub › Company › Teams | Hub | view + member list; team create/add-remove/delete shipped |
| F04 Projects | Work › Projects | Work | list/detail/create/edit |
| F05 Tasks | Work › Tasks (+ Pulse/Brief) | Work | full lifecycle, phone-shaped |
| F06 Recurring | Work › Recurring | Work | list/create/pause/run-now |
| F07 Daily Pulse | Pulse surface | Context-only | full surface |
| F08 AI extraction | — | Backend-only | consumed via F07/F12/F14/F25 |
| F09 Commitments | Work / Pulse context | Context-only | list/detail/resolve |
| F10 Risks | Work / Pulse context | Context-only | list/detail/resolve/dismiss |
| F11 Decisions | Work / Pulse context | Context-only | list/detail/decide/request |
| F12 CEO Brief | Lumi / Pulse context | Context-only | full reading + action surface |
| F13 Today | Pulse / Work Graph context | Context-only | banner + queue + sections |
| F14 Ask Lumi | Lumi | Core tab | F14 only |
| F15 Feedback | inline on AI surfaces | Context-only | same affordances as desktop |
| F16 Audit log | iOS + Android: Hub › Settings › Audit Log | Hub | owner/admin read-only list in mobile; admin HQ cross-org view stays desktop |
| F18 i18n | all surfaces | Core infra | 8 locales (vi first); 13 after F69 ships |
| F19 Design system | all surfaces | Core infra | native expression of the same product language |
| F20 Rate limiting | — | Backend-only | client respects Retry-After |
| F21 Telegram | — | Out-of-app glue | complements mobile |
| F22 Landing | — | Excluded | onboarding carousel is mobile’s landing |
| F23 Horizon | Work › Horizon | Work | 14-day list |
| F24 Billing | Paywall + Hub › Subscription | Core infra | store billing + invite-code door; gateway checkout (Sepay/Stripe) stays web |
| F25 Brief actions | Pulse context | Context-only | assign + dismiss |
| F26 Approvals | Work / Pulse context | Context-only | queue/act/create; policy builder mobile backlog (not yet built) |
| F27 CRM | Business › Customers | Business | companies/deals/contacts full CRUD + stage move shipped; pipeline analytics mobile backlog (not yet built) |
| F28 Hub | Hub › Company Hub (+ Pulse banner) | Hub | read/search/ack; announcement/doc create/edit/delete shipped |
| F29 Messages | Hub › Company Hub › Messages | Hub | send/reply/DM/own-message edit/delete and manager-gated channel edit/delete/archive shipped; archived channels are hidden from the active list |
| F31 Follow-ups | Pulse/Work/detail + Hub › Intelligence | Context-only | when backend present |
| F32 Signal Inbox | Hub › Intelligence + quick-capture | Hub | when backend present |
| F33 Sources | Hub › Account | Mobile gap (not yet built) | source-connection administration requires a native mobile surface |
| F90 Field execution ledger | Work › Tasks / Projects detail (+ Pulse/Brief deep links) | Context-only | field-job-only milestones, worklogs, latest-state badges, and task/project day digests; no separate field destination |
3.2 Hard scope lines
- In: everything above; five onboarding screens; native upgrade/paywall surfaces (store billing); invitation-code redemption; no-org setup gate. Mobile-gap surfaces (policy builder, source admin, custom-role capability-matrix administration, pipeline analytics) remain temporary build items — each requires a real native surface and cannot close as a handoff or read-only screen.
- Out: hard entry gating for free/lapsed orgs; web-gateway checkout inside the native app (App Store / Play policy); any fifth permanent destination; offline-first local database as product truth.
- One mode. There is no “simple mode” or “elderly mode” toggle. The default design must already pass the §6.3 senior-usability invariants. A settings fork is a design failure.
4. Data Model
No new mobile-side business models. Mobile is an API client over existing product models. Two backend deltas are required and owned by their home features (tracked here, implemented under F02/F24 with this spec as the driving requirement):
- F02 — short invitation code + org lifecycle endpoints.
Invitationgains a unique, human-typeableshort_code(formatLUMI-XXXX, 4 chars from an unambiguous alphabet — no0/O/1/I), generated alongside the existing UUIDtoken. F02 also owns no-org setup support (POST /api/organizations/with slug auto-generation,POST /api/members/leave/,POST /api/organizations/current/delete/). - F24 — store receipt verification. New models or fields as F24 decides (e.g.
StoreReceiptkeyed toPaymentIntent/Subscription) so that an Apple/Google purchase produces the sameSubscriptiontruth as web gateway payments. No store-specific entitlement logic on the client.
Local persisted state (allowed)
- access token, refresh token (secure storage only)
- current organization id
- chosen language
onboarding_seen: Bool— display state only; must never gate access for an authenticated, entitled user
If Android cannot authenticate this encrypted store because the ciphertext and Android Keystore master key no longer match, it deletes only the unreadable secure session store and recreates it. The app starts signed out so the user can authenticate again; no plaintext fallback is permitted.
Local persisted state (forbidden)
- entitlement/subscription flags as product truth (server decides on every bootstrap)
- invitation-code redemption state beyond the in-flight form
- offline business-object cache as product truth; local-only mirrors of backend objects
5. API Contract
Mobile reuses the shipped desktop API exactly, plus the two declared deltas.
| Surface | Endpoints | Notes |
|---|---|---|
| Auth/session | POST /api/auth/login/, POST /api/auth/register/, POST /api/auth/refresh/, GET /api/auth/me/, GET /api/auth/me/orgs/, PATCH /api/auth/me/language/ | register creates user; org setup happens after auth when org list is empty |
| Organization lifecycle | POST /api/organizations/, GET /api/organizations/current/, POST /api/organizations/current/delete/, POST /api/members/leave/ | create org from no-org state; delete/leave from Settings or Hub |
| Invitation | POST /api/organizations/accept-invitation/ | accepts {token} or {short_code} after the F02 delta ships |
| Entitlement | GET /api/billing/subscription/ | the single source of billing/upgrade truth, evaluated per current org on every bootstrap and org switch |
| Store billing | POST /api/billing/store/verify/ (F24 delta, refreshed r-billing-iap) | body: platform, product id (one of the 8 `lumi_ai_pro_{5 |
| Today | GET /api/approvals/?queue=me, GET /api/decisions/?status=open, GET /api/risks/?status=open, `GET /api/tasks/?status=todo | in_progress, GET /api/commitments/?status=open, GET /api/announcements/?pinned=true&unread=me` |
| Brief | GET /api/briefs/today/, POST /api/briefs/generate/, POST /api/briefs/items/<id>/assign/, POST /api/briefs/items/<id>/dismiss/ | F12/F25 rules |
| Pulse | GET /api/pulses/today/, POST /api/pulses/submit/ | F07 contract |
| Ask Lumi | GET /api/chat/threads/, POST /api/chat/threads/create/, GET /api/chat/threads/<id>/messages/, POST /api/chat/threads/<id>/messages/send/ | F14 only |
| Approvals/Decisions | GET/POST /api/approvals/, POST /api/approvals/<id>/{approve,reject,cancel}/, GET /api/decisions/, POST /api/decisions/<id>/decide/ | policy CRUD excluded |
| Work | GET/POST/PATCH /api/projects/, /api/tasks/ (+ complete/reopen), /api/recurring-tasks/ (+ run-now), Horizon via due_after/due_before params, F90 task/project execution endpoints | F04/F05/F06/F23/F90 |
| Intelligence | GET /api/commitments/ + resolve, GET /api/risks/ + resolve/dismiss | F09/F10 |
| CRM | GET/POST/PATCH /api/companies/, /api/deals/ (+ stage transition) | F27 read/write subset |
| Hub | GET /api/announcements/, POST /api/announcements/<id>/ack/, GET /api/hub-docs/, GET /api/hub-docs/search/?q= | read/ack |
| Messages | F29 channel/topic/message endpoints, read + post + manager channel archived-list/archive/unarchive subset | SSE with polling fallback, as web |
| Feedback | POST /api/feedback/ | same shape as desktop |
Transport invariants
Authorization: Bearer <access_token>on every protected request;X-Organization-IDon every tenant-owned request- Silent refresh once on
401; clear session on refresh failure - Respect
Retry-Afteron429; bounded retry only - No mobile-only shadow endpoints; no client-side entitlement invention
6. UI / UX
6.1 Navigation contract — “Calm Core, Full Reach”
- Four fixed bottom tabs on both platforms:
Lumi·Work·Business·Hub.Lumiis the assistant-first home,Workis the short display label for Work Graph, and Company Hub is a Hub grouping. Icon + text label on every tab. - Persistent nav chrome: the top rail and four-tab bar remain visible while scrolling; the bar is omitted only while the IME owns the bottom viewport. This is one shell-level mechanism, not a per-screen auto-hide convention. The iOS source implementation is aligned; its managed-runtime proof gap remains explicitly tracked in F79.
- Hub directory is the directory of everything else: a single scrollable screen of grouped, large rows (≥56dp tall, icon + label + optional live count badge). No grid of tiny tiles, no horizontal carousels.
- Rule of Two: every feature in the §3 matrix is reachable in ≤2 taps from cold start. This is a tested invariant (§7 case 12), not a slogan.
- Push-only depth: Hub row → list → detail. Maximum stack depth of 3 before an action completes. No modals-on-modals.
- Search sits at the top of Hub, querying across tasks, projects, docs, companies, deals, and messages (client-orchestrated against existing list/search endpoints).
6.2 Hub directory grouping (fixed order, fixed names)
- Công việc / Work: Tasks · Projects · Recurring · Horizon
- Điều hành / Intelligence: Approvals · Decisions · Commitments · Risks (· Follow-ups · Signal Inbox when present)
- Công ty / Company: Hub · Messages · Teams · Customers (CRM)
- Tài khoản / Account: Settings · Subscription · Switch organization · Join with code · Create organization · Leave organization / Delete organization
6.3 Senior-usability invariants (the only mode)
These are merge-blocking, testable rules — not guidelines:
- Body text ≥17pt/sp; supports OS font scaling to 200% with no truncated primary actions.
- Touch targets ≥48×48dp / 44×44pt, ≥8dp spacing between adjacent targets.
- Every non-standard icon ships with a visible text label. The composer mic/send pair and the conventional top-left Back affordance may be icon-only when they expose a localized accessible name, use an auto-mirrored directional glyph, and retain the full platform touch target.
- One primary action per screen, bottom-anchored, full-width, high-contrast.
- Every swipe/long-press capability has a visible button equivalent.
- Reversible actions get undo (snackbar, ≥5s); only destructive actions get a confirm; never both.
- Back is always visible top-left and unambiguous: use either a visible label or the conventional auto-mirrored Back icon with a localized accessible name; Android hardware back mirrors it exactly.
- Contrast ≥4.5:1 for text, ≥3:1 for UI components; both platforms pass their native accessibility audits (VoiceOver/TalkBack labels on every interactive element).
- Forms: one question per screen region, large inputs, inline plain-language validation; never lose typed input on failure.
- Plain language: no jargon, no “sync,” no “OAuth,” no error codes in user-facing copy. Vietnamese first, written for a person, not a developer.
Onboarding visual contract. The shared F30 story owns exactly five photographic assets in the same fixed order as its five title/body pairs. Each asset is decorative support for the adjacent localized copy, not a screenshot and not an alternate copy surface. At default text scale the image receives 64% of the available page height, close to the intended upper two-thirds. Below 700dp of available height or at 130%+ font scale, the image yields space to copy and controls. Progress and primary/exit actions remain pinned and visible; visual ambition never outranks the senior-usability invariants above.
6.4 Screen contracts (core tabs)
- Today: one pinned banner max → action queue → risks → tasks → commitments. Every row: object title, one-line context, one primary action button. (Per-row inline primary action shipped 2026-06-16 — see §13: Approval→“Phê duyệt”, Risk→“Đã xử lý”, Task→“Hoàn thành”, Commitment→“Đã xong”; Decision row navigates to detail because deciding requires choosing an option, so a one-tap inline decide is not safe.)
- Brief: single-column reading; each item shows rank, evidence link, and two buttons (Giao việc / Bỏ qua).
- Pulse: form-or-readback, never both; six questions, one visible at a time on small screens, progress shown as “3/6.”
- Ask Lumi: thread list + thread; composer with mic (system dictation) + send; honest empty answers.
- Hub directory: per §6.2.
- Paywall: plan cards with monthly/annual toggle, one purchase button, one “Tôi có mã mời” door, restore-purchases link, legal links. Nothing else.
- No-org setup: one plain-language decision: “Create organization” or “Join with code.” No paywall, no Today, no empty Hub until one of those paths succeeds.
- Organization management (Hub › Account › Manage organisation): four tabs — Organizations (“Danh sách tổ chức”) · Join · Create · Manage. The first tab is named by what it shows, never by an action (lumi-fb #63 — “Switch”/“Đổi” read as a verb the tab does not perform). Above the list, one hint line states the interaction: “Tap an organization to switch to it.” / “Chạm để chuyển sang tổ chức bạn muốn quản lý.” Every row carries a radio indicator showing which organization is active, alongside the “Current” badge on the active row. Selection is the action: tapping another row switches immediately — switching organization is reversible, so there is no confirmation dialog and no separate Apply button (lumi-fb #64). Binding on Android and iOS from the same shared KMP screen.
6.4.1 Team and project detail contract
- Project detail order is summary → owner → members → channels → work. Team detail keeps summary → roster → channels. These are sections in one scroll surface, not nested setup screens.
- One visible Manage members action opens a bottom sheet with active organization members not already selected. Transfer ownership is a separate labeled consequence action, never a hidden swipe or overloaded role selector.
- Remove and transfer require a plain-language confirmation. Add and create-channel are reversible through later removal/archive and therefore do not add a second confirmation.
- Channel rows show name plus unread count and use the canonical thread route. The default linked channel is labeled as the main conversation but does not expose archive/delete.
- Empty channels are not a normal state for a new container: the server guarantees one. If a legacy/repaired record has none, show “No conversation is available” with retry/reporting; never fabricate a local channel.
- All mutations are server-confirmed. There is no optimistic owner, roster, channel, or access state.
6.5 Desktop-handoff pattern
⚠️ SUPERSEDED — Owner directive 2026-07-11: “all features can be used on mobile, no desktop needed.” The Desktop-handoff tier is being retired. Mobile must be a complete product: every feature that has a desktop authoring/edit/admin surface gets a real mobile affordance — no “Chỉnh sửa trên máy tính / Edit on desktop” hand-offs. This reverses the support-tier narrowing below.
Surface-by-surface truth (2026-07-11 audit): see desktop-handoff-audit-2026-07-11.md. The tier list below turned out to be stale for most surfaces — they were built after this spec was written. Actual state:
- Already built + nav-wired (F30 label stale): CRM deal/company/recurring edit (#1867 — the deal dialog lacked an Edit button; iOS company/recurring
onRowSelectedno-oped), Hub authoring (announcement + docs create/edit), Teams member admin (add-member sheet + remove-confirm), Messages compose/reply (channel + DM). These need device-verify to clear (†), then the “edit on desktop” copy removed where any lingers.- Previously genuine gaps: audit log (shipped 2026-07-12, ACCT-05 — read-only org activity feed in
auditlog/AuditLogScreen.kt, owner/admin-gated, reachable via iOS: Hub → Settings → Audit Log; Android: Hub → Settings → Audit Log). Remaining genuine gaps: pipeline analytics (/api/crm/pipelines/), approval-policy builder (/api/approval-policies/), source connections (/api/sources/). Build order and per-surface contract in the audit doc.Until a surface’s build lands, it may still show read state — but the hand-off copy is a bug to be removed, not a feature. The tier descriptions below are retained as the migration checklist, not as the target design.
Where the matrix lists a mobile gap, mobile shows the surface’s entry point with its data (never a blank wall). Dead-end screens are banned; gap screens still show read state where the API allows. Each gap requires a real native surface — it cannot close as a handoff or read-only screen — and is tracked as a verified mobile backlog item, not a permanent deferred-by-design state.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Fresh install plays exactly 5 onboarding screens, Skip works, never replays after completion | platform UI | both platforms |
| 2 | Authenticated + entitled cold start lands on Today; onboarding/paywall not shown | platform UI | both platforms |
| 3 | Authenticated + no active subscription still enters the workspace; owners/admins can open the in-app upgrade surface, complete purchase, and receive server-verified entitlement without leaving the shell | platform integration (store sandbox) | both platforms |
| 4 | Invitation code: valid code joins org and proceeds; invalid/expired shows plain error, preserves input | platform UI/integration | both platforms |
| 5 | Member of lapsed org still enters the workspace; premium surfaces show owner-contact upgrade messaging instead of an entry wall | platform UI | both platforms |
| 6 | Token refresh-once-and-retry; refresh failure clears session → auth | platform unit/integration | both platforms |
| 7 | Org switch changes headers, data, and re-evaluates entitlement | platform integration | both platforms |
| 8 | Today: banner + queue + stable empty states match desktop rules | platform UI | both platforms |
| 9 | Brief assign/dismiss; Pulse submit/failure-preserves-input; Ask Lumi send/empty-answer | platform UI/integration | both platforms |
| 10 | Approvals approve/reject + decision decide enforce role gating; 409 refreshes to latest truth | platform integration | both platforms |
| 11 | Hub directory: every §3 matrix row present, grouped per §6.2, counts live | platform UI | both platforms |
| 12 | Rule of Two: scripted nav audit proves ≤2 taps to every matrix feature | platform UI audit | both platforms |
| 13 | Senior invariants: 200% font scale, 48dp targets, label-on-every-icon, visible-button-per-swipe — automated where possible, checklist otherwise | accessibility audit | both platforms |
| 14 | Mobile gap screens show read data, no dead ends; condition logged as temporary build item requiring native surface (never a permanent handoff) | platform UI | both platforms |
| 15 | Backend deltas: short-code redemption maps to same Invitation; store verify activates same Subscription truth as gateway payment | backend tests | backend/tests/ (F02/F24 suites) |
| 16 | Negative audit: no local entitlement flag gates access; no web-gateway checkout reachable in-app | repo audit | rg over both platforms in the implementation PR |
| 17 | Authenticated user with zero orgs lands in org setup; create/join works on both platforms | platform UI/integration | iOS + Android gate tests |
| 18 | Settings / Hub let user create org, switch org, leave org, and delete org with confirmation | platform UI/integration | iOS + Android Hub/account tests |
| 19 | iOS live E2E (gated LUMI_E2E=1): API-layer flow register→me/orgs→task create/list/complete/reopen→pulse submit/today, plus XCUITest cold-launch→sign-up→main-tabs→pulse-submit against api-dev | platform E2E (live backend) | iOS UnitTests/E2E/LiveBackendE2ETests, UITests/* |
| 20 | Android E2E fake-API gate (app/src/androidTest/…/e2e/): sign-up→create-org, soft-entitlement entry, direct-to-auth first run, four-tab V2 lane reachability, and action flows against FakeLumiBackend | platform UI | Android e2e/ test classes (deterministic, no backend) |
| 21 | Android live smoke (gated @LiveBackend): API-layer login→me→myOrgs→billingSubscription against api-dev.runlumi.app | platform E2E (live backend) | Android e2e/smoke/LiveLoginSmokeTest.kt |
| 22 | F90 mobile parity: field task detail supports milestone capture, worklog capture, timeline, and task day digest for member+ roles without leaving the mobile shell | shared + platform UI | KMP common tests + iOS/Android detail-flow tests |
| 23 | F90 mobile manager parity: project/task day digest and latest execution badges are visible in Work Graph task/project flows with manager_only visibility enforced | shared + platform UI | KMP common tests + iOS/Android role-gated tests |
| 24 | F90 deep-link parity: Pulse, Brief, and notifications all open the same mobile field task/project detail surface instead of a web fallback or duplicate route | platform navigation | iOS + Android navigation tests |
| 25 | Selecting an organization member opens one bounded actions dialog that owns role selection and eligible removal instead of appending controls below the management scroll body | shared source/JVM + platform UI | OrgManagementRolePickerPlacementSourceTest + OrgManagementMemberRemovalPlacementSourceTest + named Android/iOS verification |
| 26 | Project detail loads owner/members/channels independently and add/remove/transfer never render optimistic state | shared JVM | P60 project screen-model tests |
| 27 | Team detail lists/creates linked channels and recovers one failed section without blanking the detail | shared JVM | P60 team screen-model tests |
| 28 | Team/project channel tap resolves the canonical Messages route on Android and iOS | shared + shell policy | P60 navigation tests |
| 29 | Container actions preserve 200% text, Vietnamese/English parity, labeled semantics, and target sizes | source/unit + runtime | P60 accessibility suite |
| 30 | Complete project and team lifecycle runs on one approved mobile runtime at a time | Maestro | P60 lifecycle-owning runner |
8. Current Behavior
Honest state as of 2026-08-10:
- Both platforms now branch through the mobile gate sequence instead of a raw auth → main shortcut: onboarding-once, auth, zero-org setup, and an entitlement check before the main shell.
- iOS has the fuller gate/runtime proof today:
RootContainerroutes through onboarding, auth, org setup, andMainTabView, with billing carried into the shell via a top-of-shell alert plus in-app prompts instead of an entry wall; Android now matches that same soft-gate doctrine inMainActivity, and both native paywalls back Hub › Subscription plus the top-of-shell billing CTA via the same restore / invite-code upgrade surfaces. Real store-sandbox proof is still outstanding on both shells. - iOS paywall recovery is now materially safer than the older audit implied: repeated post-charge verify failures keep the paid purchase payload, expose a retry CTA immediately, and escalate to a concrete
support@runlumi.appcontact path after three consecutive activation failures instead of looping a generic error forever. - Android no longer fail-opens into the app from a local paywall callback: invitation-code join updates session state and then re-checks server entitlement; purchase launch no longer grants access without verification.
- Android now matches iOS on the reconciled assistant-first shell:
Lumi/Work/Business/Hub; Company Hub remains a Hub grouping and Pulse remains a nested operating surface rather than a fifth bottom-rail destination. - Shared organization management opens organization-name editing from Settings in
EditOrgDialog; selecting a member opensMemberActionsDialog, which owns role selection and eligible non-owner removal confirmation. Organization owners never receive a destructive removal action, and none of these controls is appended below the full management scroll body. - The four-lane shell migration is materially further than before: both platforms now ship the top-rail search trigger + notifications bell/ledger and mount shared F79 lane-root screens. The remaining gap is breadth/detail ownership and verification, not missing lane chrome.
- The backend deltas are shipped: invitation short codes (
LUMI-XXXX) andPOST /api/billing/store/verify/both exist, so the remaining mobile work is client-side integration and breadth parity, not placeholder backend invention. - Shared org-management is also stricter than the older docs implied: create/join/leave/delete now refresh membership in-app, backend governance failures like last-owner leave / wrong delete slug survive into mobile UX as real error copy instead of generic 400s, Android now has direct
MainActivityViewModelproof for preferred-org reload plus no-org fallback safety, and iOS now has directAPIClient.syncOrganizationMembership(preferredOrgId:)proof for those same shell-level transitions. What is still missing is broader live shell/runtime proof, not the shared error contract. - Shared auth no longer collapses field-validation failures into a fake transport problem: the KMP auth layer now preserves DRF field errors like duplicate email, invalid email, and password-too-short on both login and registration instead of rendering a generic
Request failed400 banner. - Shared KMP UI no longer uses non-null assertions in the assistant-home render path: chat thread title fallback and proposal owner/due chips now degrade gracefully on partial production payloads, and a JVM guard test fails if
!!reappears anywhere underandroid/shared/src/commonMain/.../ui. - F90 backend/web is now real, and the mobile app now has the field-execution product shape in code: shared KMP DTO/API support, task/project detail milestones, worklogs, latest-state badges, task/project day digests, shared task create/edit
field_jobmode, upload-backed field logging, and Pulse/Brief/notification/Work Graph convergence onto the same mobile task/project surfaces are all landed. - F90 mobile parity is now unit-proven on both native shells: shared role matrix, iOS route/breadth/host XCTest, and Android route/breadth unit suites all converge on the same task/project field-execution surfaces. Remaining mobile gaps now live outside F90.
- Therefore F30 is still not shipped; the gate and lane shell are much truer than before, and Android no longer dead-ends the Subscription surface, but broad native verification and remaining breadth/detail parity still remain outstanding in P13/P32.
- As of 2026-07-30, shared Android/iOS team and project details load independent owner, roster, and linked-channel sections; server capabilities own project governance and channel creation; committed responses own visible mutations; and each native shell routes the exact selected channel id into the canonical Messages thread with parent-aware Back order. The lifecycle runner also proves eight standard teams plus E01–E08 on the approved Android emulator and iOS simulator. Web, macOS, named-device, native TalkBack/VoiceOver service, and store proof remain open, so this is not full shipped parity.
9. Error States
401expired access: silent refresh once, replay; on refresh failure clear session → auth.- Entitlement check fails (network): show retry state; never fail-open into the product, never fail-closed into a purchase screen for a previously-entitled session that merely lost connectivity (use last server-verified entitlement for the current process lifetime only).
- Store purchase fails/cancels: return to paywall with state intact; no partial entitlement.
- Receipt verify fails after successful store charge: show “payment received, activating…” retry state with support contact after 3 failures; never strand a charged user silently.
- Invite code invalid/expired/used: plain-language inline error, input preserved.
- Lapsed-org member: enters the workspace; premium surfaces show owner-contact upgrade messaging, and the shell never blocks core navigation at entry.
403/404tenant mismatch: “item not available”; stale action buttons removed on reload.409conflicts: refresh object, show latest truth.- Network failure: retry affordance; preserve typed input (pulse, comments, composer, invite code).
429: respectRetry-After, bounded retry, no user-visible error by default.
10. Diagrams & Docs Touched
- ../mobile/mobile-plan.md — architecture appendix and surface map
- ../mobile/mobile-onboarding.md — onboarding/paywall/entry contract
- ../mobile/ios-features.md, ../mobile/android-features.md — platform inventories
- ../implementation/P13-mobile-assistant.md — execution order
- F02-organizations.md, F24-billing.md — owners of the two backend deltas
- ../../ios/AGENTS.md, ../../android/AGENTS.md — platform instructions
11. Implementation Audit (2026-06-11, updated 2026-06-12)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Mobile gate sequence: onboarding-once → auth → zero-org setup → entitlement check, both platforms | ✅ match | §8: both platforms now follow the soft-gate sequence into the main shell; remaining work is sandbox/runtime proof and breadth verification, not a divergent entry contract |
| 2 | Invitation short codes (LUMI-XXXX) backend delta | ✅ match | §13: 19 short_code tests verified ✅ (test_multitenancy.py + test_billing.py) |
| 3 | POST /api/billing/store/verify/ backend delta | ✅ match | §8: “backend deltas are shipped” |
| 3a | iOS StoreKit 2 purchase→verify→entitlement flow wired (paywall completion) | ✅ match | §13 2026-06-16 + 2026-07-01: StoreKitManager.swift now carries load/purchase/restore/retry plus 3-failure support escalation, PaywallView.swift renders localized StoreKit prices with /api/plans/ fallback and a concrete support path, SubscriptionScreen.swift remains the upgrade door, and APIClient.verifyStorePurchase/refreshEntitlement stays the server-truth entitlement rail. Build/test proof is unit-level; real exercise still needs App Store Connect product config and the named-device run in docs/mobile/store-sandbox-validation.md |
| 4 | Android no longer fail-opens via local paywall bypass | ✅ match | §13 2026-06-12 entry: invitation-code join refreshes session + re-checks entitlement |
| 5 | Four-tab navigation contract migrates to F79 lane ownership | ✅ match | §8 + F79 §8: both platforms now ship the F79 lane order, shell-level top rail (search + notifications), and shared lane-root screens. Remaining F79 work is richer operating-home composition and verification, not missing shell ownership |
| 6 | Senior-usability invariants (§6.3): 200% font, 48dp targets, label-on-every-icon | ⚠️ partial (iOS automatable parts proven; L64 undo snackbar now implemented) | §13 2026-06-16 SU-iOS: automated — RuleOfTwoTests.test_metrics_touchTargetsMeet44ptMinimum asserts LumiMetrics targets ≥44pt (tap target 48), rows ≥56dp, buttons/fields ≥44pt; manifest test asserts a non-empty text label on every Hub directory row/section (§6.3-3). Grep-audit of the recently-shipped screens (AccountScreens, CompanyGroupDetailScreens, IntelligenceInboxScreens, Billing/*) found no icon-only interactive control — every Button/LumiButton carries Text/Label or an explicit .accessibilityLabel; no fixes needed. Checklist-pending (device-only): 200% Dynamic Type visual, VoiceOver rotor sweep — per-screen steps in §13 SU-iOS checklist; Android untouched. L64 (§6.3 rule 6 — reversible action undo): implemented 2026-07-02 — TodayScreenModel.completeTask + TasksScreenModel.completeTask now set undoTaskId on success; both screens show a Material3 SnackbarHost (≥5s via SnackbarDuration.Long) with an “Undo” action that calls reopenTask; 6 new commonTest unit tests cover optimistic-apply → undo-reverts and no-undo-persists paths. Pending KMP build verification on a build-capable host |
| 7 | Rule of Two: every §3 matrix feature reachable in ≤2 taps | ✅ match (iOS + Android unit-proven) | iOS: §13 2026-06-16 R2-iOS RuleOfTwoTests proves the fixed Hub directory inventory from the rendered manifest. Android: 2026-06-29 WorkspaceDirectoryTests, WorkBreadthTests, and FieldExecutionRoutingTests now lock the four Hub groups, full shipped row inventory, Work Graph roots, and no-duplicate-field-surface routing contract |
| 8 | Full feature breadth (all §3 matrix features accessible) | ⚠️ partial | iOS Hub Account + Company + Work + Intelligence groups are shipped and now have concrete unit route/breadth proof; Android unit proof locks Work / Intelligence / Company / Account inventory plus F90 route convergence inside existing task/project roots, shared org management covers org create/switch/join/leave/delete/invite-send from the same KMP surface, and the Android paywall now boots Play Billing, loads the canonical 8-product `lumi_ai_pro_{5 |
| 8a | Invitation UUID deep links still enter invite acceptance from either auth or signed-in shell | ⚠️ partial | iOS SystemEventsHandler.swift + AppEnvironment.swift route into KMPComposeView(screen: .acceptInvite(token:)) (shared KMP) + DeepLinksHandlerTests.swift proves parser shapes. Android MainActivity.kt routes into shared AcceptInviteScreen (KMP commonMain). Both shells now share AcceptInviteScreenModel (commonTest-covered) + DefaultAcceptInviteRepository. Native AcceptInviteView (iOS) and private AcceptInviteScreen composable (Android) deleted. Remaining gap: full live email/app-link runtime verification on physical/device simulators |
| 8b | Password-reset email links route to the shared native confirmation screen with strict reset/invitation separation | ⚠️ partial | Android and iOS parsers, shell destinations, Android manifest path, iOS universal-link callback, and iOS associated-domain entitlement are repository-owned and unit-testable. Production OS handoff remains open until runlumi.app serves valid association documents for the production signing identities and the PM captures same-day named Android floor-device plus iPhone 12 evidence. Emulator/simulator or explicit intent launch is UI-only. |
| 12 | iOS Intelligence group breadth: Approvals · Decisions · Commitments · Risks · Follow-ups (F31) · Signal Inbox (F32) all reachable with real actions (§6.2) | ✅ match | §13 2026-06-16: IntelligenceInboxScreens.swift adds F31 list/detail/reply + F32 list/detail/accept-reject-ignore; build verified |
| 9 | iOS Hub › Account group is real, not stubbed (§6.2 fixed rows) | ✅ match | §13 2026-06-16: ios/Lumi/UI/Workspace/AccountScreens.swift — Subscription reads /api/billing/subscription/; Switch/Join/Create/Leave/Delete reuse existing APIClient org methods |
| 10 | iOS Company group has detail + actions, not list-only (§6.2) | ✅ match | §13 2026-06-16 CG-detail: 4 detail screens shipped; Hub ack against /api/announcements/<id>/ack/, Teams members against /api/teams/<id>/members/; build verified |
| 11 | iOS Work group is full (Tasks/Projects/Recurring/Horizon) | ✅ match | §13 2026-06-16 WRK-iOS: dedicated Tasks list + create/complete/reopen; Recurring detail + Run now; Horizon read detail + handoff; build verified |
Summary: 10 audit rows now match; 4 remain partial (gate/commercial flow closeout, full breadth/runtime proof, invite-link runtime closeout, senior-usability). The stale “Android mostly pending” picture is no longer true: both platforms now have the F79 shell, top rail, strong unit proof for Hub/Work Graph reachability, in-app org lifecycle refresh wiring, F90 inside the real mobile task/project flow, explicit negative-audit proof that native paywalls do not leak to forbidden web checkout providers, Android’s paywall now has direct canonical-product-catalog proof rather than only indirect state-flow evidence, and both shells now retain UUID invite-link compatibility in their real routing layer. F30 is still not shipped because real store-runtime closeout and broader actor-flow verification remain incomplete.
Edge Cases Catalog (formerly §9, 4 items): Expired access during background resume → one refresh then auth (covered by §9 ES 401 rule); multi-org members resume into last explicit tenant choice (no confirmed test); draft work (pulse responses, comments, invite codes) survives transient network drop (no confirmed test); invite-accept deep links now have parser/shell proof on both platforms, but push deep links into deleted/inaccessible resources still lack safe-fallback routing proof.
12. Open Questions / Decisions
Locked by this spec (F72 v3 supersedes the v2 gate, see F72 §13 ¶ “2026-06-29” for the full changelog):
- Soft paywall (Gmail approach, F72 v3). No hard paywall, no trial, no freemium gate, no “later” prompt. A free/lapsed/expired/grace org always enters the workspace. Owners/admins of a non-active org see one dismissable top-of-shell
<BillingAlertBanner>; members see nothing extra. The F24read_only403 is softened to advisory only — it informs the banner level but does not block writes. - Real App Store Connect product IDs. The paywall loads 8 products from App Store Connect in the
lumi_ai_pro_{5|10|20|50}_{monthly|annual}shape (5/10/20/50 = AI seats, AI Pro plan, monthly/annual). 5 seats × $5/mo = $25/mo; 50 seats = $250/mo; annual = 12×monthly with the 40% AI Pro discount. The iOSStoreKitManager.productIDsMUST match this set; the legacycom.runlumi.{starter|team|business}_{monthly|annual}IDs are retired. - Store billing only in-app. Sepay/Stripe/PayPal/Airwallex checkout stays on the web; the app never links to external checkout in violation of store policy. Web-paid orgs are entitled identically — entitlement is server truth, and the web
POST /api/billing/pay/body gains aseatsfield that is the source of truth for per-seat × seats pricing on the per-AI-user model. - One mode. Senior-usability invariants are the default and only design; no accessibility fork.
- Four tabs forever (this contract): breadth grows inside Hub, never as a fifth permanent destination.
- F14 ≠ F29 naming stands: “Ask Lumi / Hỏi Lumi” vs “Messages / Tin nhắn.”
Open (non-blocking):
- Annual-plan store products: ship monthly-only first if store product review drags; the paywall toggle hides when only one period exists. (v3 makes this truer: 4 monthly SKUs ship, the 4 annual SKUs follow when ASC clears them; the toggle is hidden until the annual set is complete.)
- Workspace search federation order and result caps — tune with usage.
- Seat-count overage handling on iOS: App Store Connect does not natively support plan upgrades with seat-count changes mid-period; the v3 scope is the initial purchase (5/10/20/50), with
changePlanin F74 owning the upgrade path.
13. Bug / Change Log
2026-08-10 — iOS persistent navigation aligns with the F79 shell contract
The iOS shell no longer tracks scroll direction or translates the system tab bar. A focused source contract guards the persistent appearance modifier, and registered Maestro flow 155 scrolls the Hub directory before checking all four tab identities. Two clean signed simulator builds passed, but both bounded runtime attempts stopped at the shared login subflow with a rendered connection error before reaching Hub. F79 retains the managed-runtime and named-device proof gap, so the product-wide acceptance criterion above remains unchecked.
2026-08-04 — Auth doorway uses native iOS text input for AutoFill and keyboard stability
The shared F01 auth fields now opt into Compose Multiplatform 1.11’s native iOS text-input
implementation and declare explicit username/email/password content semantics in the common field
primitive. This requests the platform Password AutoFill bridge for email/password pairs and removes
the legacy hidden-input path associated with keyboard-height jumps; Android/JVM keep their existing
native input actuals, while the common autofill semantics are shared. Shared IME ownership and
SwiftUI host safe-area ownership remain unchanged. F01 source,
XCTest, and Maestro audit contracts are registered; signed simulator rendering and named-iPhone
AutoFill/save behavior remain the explicit F01.GAP014 proof boundary.
2026-08-02 — Retry transient gateway failures on Android reads
Type: Production resilience / Android / KMP / HTTP transport
Summary:
- The shared KMP client retries one 502/503/504 response for idempotent
GETrequests, covering the read-only startup and lane calls reported by Sentry (tasks,projects,follow-ups, pulse rollup, decisions, risks, andauth/me). - Persistent gateway failures still return the final
ApiExceptionand remain reportable; POST, PATCH, and DELETE requests are never replayed. - Regression tests cover recovery, persistent failure, and the no-write-replay boundary.
Verification: scripts/wait-for-resources.sh --label 'shared KMP test suite' -- zsh -fc 'cd android && ./gradlew :shared:jvmTest' (the full task is required because this repository’s
DTO-contract gate rejects filtered test invocations).
2026-08-01 — Recover Android from unreadable encrypted session preferences
Type: Production crash / Android secure-session bootstrap
Summary:
- Android now retries
LumiPrefscreation after an encrypted-preference or Android Keystore integrity failure by deleting onlylumi_secure_prefsand its default master-key alias, then recreating the encrypted store. - The recovery intentionally discards local session state and routes the user through sign-in; it never copies tokens to plaintext or treats unverifiable ciphertext as authenticated.
- The next Play artifact advances the checked-in
versionCodefrom 29 to 30 because Play’s live maximum is 29. JVM regression coverage exercises directAEADBadTagException, a wrapped Keystore integrity failure, and the non-cryptographic failure path. Release-bound named-device non-recurrence remains an explicit proof gap, and the reportedcom.locuno.lumi.android@1.0.0+19listing cannot be upgraded in place by the replacementcom.lumi.aiPlay identity.
Verification: scripts/wait-for-resources.sh -- ./android/gradlew -p android :core:network:testDebugUnitTest --tests com.locuno.lumi.android.core.network.LumiPrefsRecoveryTest --no-daemon; Contract Graph
format, validation, generation, and generated-drift checks.
2026-08-03 — Exercise encrypted-session recovery through the real Android activity
Type: Reliability proof / Android secure-session bootstrap
The JVM recovery tests proved the exception classifier and reset-and-retry boundary, but they did
not construct the real app Hilt graph. Added LumiPrefsRecoveryStartupTest, which seeds the
real encrypted session store, deletes only its Android Keystore alias, launches MainActivity, and
asserts that the recreated store is empty and the activity is signed out. The accompanying runner
reuses exactly one selected Android target, binds Gradle to that serial, runs only the prodDebug
instrumentation test under the production package identity, and compares the installed APK hash
with the tested artifact. Because the proof intentionally invalidates the secure-session key and
signs out that target, the runner requires explicit session-reset acknowledgement. It never boots
or kills an unowned runtime.
The runner is a stronger commit/artifact-bound proof path, not production evidence: named Android
floor execution, Play identity/readback, and release-bound Sentry non-recurrence remain
F30.GAP032 until they are actually collected.
Verification: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts.tests.test_android_secure_session_recovery_policy; bash -n scripts/run-android-secure-session-recovery-proof.sh; the guarded Android runner remains
environment-bound when no approved adb target is connected.
2026-07-30 — Specify complete mobile container membership and conversations
Type: Behavior Change
Summary:
- Android and iOS project detail gain owner, member, transfer, and linked-channel flows.
- Team detail gains linked-channel list/create and canonical Messages navigation.
- New-organization bootstrap needs no extra mobile setup.
- All object actions follow server capabilities and committed responses, with section-level recovery.
Reason:
- A phone-first product cannot require desktop or a second global directory to manage the people and conversation attached to work.
Impact:
- Shared KMP owns state and UI; native shells only map the opaque channel id into the existing allowlisted Messages route. No sixth tab or duplicate thread screen is introduced.
Tests:
- Planned in
docs/test-plan/project-team-channels.mdcases M01–M20 and E01–E08.
2026-07-29 — iOS explains and retries incomplete sign-in (lumi-fb #68)
Type: iOS authentication-bootstrap feedback fix (F01.AC016)
Summary:
- iOS previously accepted credentials, then silently returned the authentication form to idle when the follow-up user or organization hydration request remained transiently unavailable after one retry.
- The shell now preserves valid credentials and presents a localized Retry/Cancel alert. Retry repeats hydration and continues entitlement routing only after session context is complete.
- Session hydration also retains the authenticated profile name and passes it through the Apple bridge to the conversational organization-setup greeting. The bridge keeps the empty-name fallback and its exported logout callback for source/binary compatibility, although the redesigned screen no longer renders a logout action.
- Explicit unauthorized responses still clear the invalid session. No backend, Android, or shared authentication behavior changed.
Verification: focused XCTest for failure, credential custody, and recovery; source policy for
alert wiring and English/Vietnamese localization. Reporter-device rendering remains open in
F01.GAP010.
2026-08-15 — Organization creation dialog owns Android IME clearance (lumi-fb #98)
Type: Shared mobile organization-management keyboard regression
Summary:
- The shared
LumiDialogoverlay now consumes the platform-awarelumiImePadding()before centering its card, so the final overlay is measured in the keyboard-reduced viewport. TheSwitchOrgDialogkeeps its bounded scroll container for Join/Create, and the single overlay inset owner keeps its field and action reachable rather than relying on the underlying route’s inset. - The create field and action expose stable semantics targets for artifact-custodied runtime proof.
- This is a layout-only correction; organization authorization, tenant binding, validation, and server mutation behavior are unchanged.
Verification: OrgManagementKeyboardInsetTest source proof plus the F30.AC016 contract graph;
exact dev APK keyboard-open evidence and named-device/reporter confirmation remain distinct.
2026-08-10 — Organization management actions move into bounded dialogs
Type: Shared mobile organization-management UI contract reconciliation (F30.AC028, F30.AC029, F30.AC032, F30.AC035)
Summary:
- The human-approved organization-management redesign opens Rename organization from Settings in
EditOrgDialogand opens oneMemberActionsDialogwhen a member row is selected. - The dialogs retain the existing role, owner-removal denial, confirmation, cancel, pending, validation, and server-error behavior while preventing controls from being appended after the management scroll body.
- Organization switching remains immediate in
SwitchOrgDialog; its hint and selected-row indicator are unchanged. - This entry supersedes the current placement contract only. The July entries remain the history of the defects and earlier correction.
Verification: focused shared JVM source-contract tests plus Contract Graph validation and generated-drift checks. Named Android/iPhone rendering remains open in the existing F30 gaps.
2026-07-29 — The organization list says what it is and how to switch (lumi-fb #63, #64)
Type: Shared mobile organization-management UX fix (ACCT-03, F30.AC035)
Summary:
- The organization-list tab was labelled with a verb — “Switch” / “Đổi” — for a tab that only lists organizations (lumi-fb #63). It is now named by its content: “Organizations” / “Danh sách tổ chức”.
- The list gave no instruction and no selection affordance, so testers could not tell how to change organization (lumi-fb #64). A single hint line now sits directly above the rows — “Tap an organization to switch to it.” / “Chạm để chuyển sang tổ chức bạn muốn quản lý.” — and every row carries a radio indicator marking the active organization.
- Switching stays immediate: a row tap rebinds the tenant with no confirmation dialog and no Apply button, because switching organization is reversible. Selection is the action.
- Shared KMP screen, so Android and iOS change together. Maestro flows 44, 60, 65, and actor 01 were updated to the new label.
- Web (
SettingsPageSections.tsx) was intentionally left unchanged: its equivalent surface is a card titled “Chuyển tổ chức” with an explicit per-row Switch button and an existing hint, so neither reported failure applies there. - The five partial mobile locales (
values-ar|fil|id|lv|ms) carry noorg_management_*strings at all and fall back to the default resources; translating two keys there would render a mixed-language screen, so English and Vietnamese are the shipped translations.
Verification: scripts/wait-for-resources.sh -- ./android/gradlew -p android :shared:jvmTest
(OrgManagementSwitchClaritySourceTest, 4/4 green); Contract Graph format, validation, generation,
and generated-drift checks. Named-device Android/iPhone rendering and a Maestro flow 44 run against
the updated label remain open as F30.GAP030.
2026-07-28 — iOS unread refresh owns KMP bridge tasks through background cancellation
The shell unread refresh no longer binds two direct Kotlin suspend calls to SwiftUI’s cancellable view task. It starts owned unstructured child tasks, awaits both bridge calls, and discards the result when the parent view task has been cancelled. This prevents a late KMP completion from resuming an already-cancelled Objective-C export continuation while ensuring stale badge counts are never written. XCTest covers cancellation ownership; named-iPhone background execution and release-bound Sentry non-recurrence remain open.
2026-08-25 — iOS unread refresh exposes recoverable KMP bridge failures
Sentry event LUMI-IOS-N showed a 502 response from /api/notifications/ still reaching
terminateWithUnhandledException on a dev TestFlight build. The unread badge and message-count
callers already used Swift try?, but the concrete Kotlin repository methods crossed the
Objective-C suspend bridge without @Throws(Exception::class) metadata, so Swift never received
an Error to catch. NotificationsRepository/DefaultNotificationsRepository now annotate
feed and read mutations, and MessagesRepository/DefaultMessagesRepository annotate the
channel-count read used by the shell badge. A source regression proves every interface and
concrete export boundary retains the throwable contract. Named-device runtime and release-bound
Sentry non-recurrence remain open under F30.GAP029.
2026-07-24 — Organization name editing stays inside its management card
Type: Shared mobile organization-management UX fix (ACCT-03)
Summary:
- Owner/admin organization-name editing now expands in the same card that displayed the organization name and Edit action instead of opening a detached editor composed after the remaining management sections.
- Save, cancel, loading, blank-name validation, and server-error behavior remain explicit.
- Source regression proof locks the editor to that card before invitation-link and member content on both Android and iOS shared KMP hosts. Fresh named-device placement proof remains open.
Verification: cd android && ./gradlew :shared:jvmTest --tests com.locuno.lumi.shared.ui.OrgManagementEditPlacementSourceTest --tests com.locuno.lumi.shared.api.DtoContractTest --no-daemon; Contract Graph format, validation,
generation, and generated-drift checks.
2026-07-25 — Migrate the Play/Firebase application identity
Type: Android distribution identity
- Android
applicationId, release tooling, and current automation defaults now target the replacement Play appcom.lumi.ai. - The Kotlin namespace remains
com.locuno.lumi.android; source package names and manifest component classes are implementation identifiers, not Play listing identifiers, and do not require a risky mechanical rename. - Production remains on
com.lumi.ai; the internal-testing dev flavor uses the isolatedcom.locuno.lumi.devinstall identity and Firebase projectlumi-dev-5c0a0so both apps can coexist on a tester device. - The dedicated dev publisher is hard-bound to the Play internal track and
api-dev.runlumi.app; production remains hard-bound tocom.lumi.aiandapi.runlumi.app. AndroidApplicationIdSourceTestfails if Gradle, Firebase client registration, or active automation defaults drift apart.- All 38 shared locale sources that contain App Store subscription or EULA copy now provide a Play-specific description, so the all-locale publisher can preserve localized product copy without sending Apple-only metadata to Play.
- Play API access and live maximum
versionCode = 0were verified before release. Upload, review submission, approval, and store availability remain separate evidence states.
2026-08-02 — Isolate Android dev internal testing
Type: Android distribution identity
- Registered Firebase project
lumi-dev-5c0a0and Play packagecom.locuno.lumi.devas the dev flavor’s independent distribution identity. - Bound
devReleasetoapi-dev.runlumi.app, its source-set Firebase config, and the internal track only; wrong package/backend/track combinations fail closed before upload. - Kept
com.lumi.aias the canonical production identity under ADR-047. - Upload, committed-track readback, tester availability, and public release remain separately reported evidence states.
2026-07-25 — Repair the Android store identity
Type: Android distribution identity and listing clarity
- The replacement
com.lumi.ailisting and every legacy launcher density now use the canonical Lumi mark instead of the unrelated envelope/question-mark artwork inherited from the repository template. - Repository-owned Google Play assets include an exact 512 × 512 icon and 1024 × 500 feature graphic. A source regression requires the Play launcher icon to be byte-identical to the canonical store icon and verifies both PNG dimensions without external image tools.
versionCodeadvances to 21. Upload, review submission, Google approval, and public availability remain separately evidenced release states.
2026-07-28 — Replace mobile and store identity with the folded-L mark
Type: Cross-platform visual identity refresh
- The opaque Civic Navy folded-L icon is canonical for iOS, macOS, Android store, and legacy launcher surfaces. Android adaptive and splash resources use the same folded-L geometry against the platform-owned background.
- Play icon byte identity, exact store dimensions, feature-graphic dimensions, and legacy launcher
digests remain enforced by
F30.AC031. - Source and build proof do not establish store propagation or named-device appearance; those remain separate release/runtime states.
2026-07-31 — DataStore native counter 16 KB compatibility
Type: Bug Fix | Android release compatibility
- Google Play flagged the
libdatastore_shared_counter.socopies in the versionCode 26 bundle as potentially unsafe on 16 KB devices. AndroidX DataStore is pinned to stable 1.2.1, and direct inspection of its arm64-v8a and x86_64 counters finds 16 KBPT_LOADalignment. - The release policy now protects the minimum safe DataStore floor in addition
to inspecting every 64-bit native library in the completed AAB. The checked-in
versionCodeis 29. Its signed production AAB passed the manifest and 16 KB ELF gates, and Play production readback confirmed statuscompleted; Console technical-quality reassessment and store availability remain separate evidence states.
2026-07-23 — Android release optimization and 16 KB runtime gate
Type: Android release compatibility and performance
- Production release variants now run optimized R8 code shrinking and resource shrinking. R8 9.1.29 is pinned because the AGP-bundled shrinker could not parse Kotlin 2.4 metadata found in resolved artifacts.
- ML Kit text recognition moves from 16.0.0 to 16.0.1, replacing the only observed 64-bit native library whose load segments retained 4 KB alignment.
- Both Android release runners inspect the completed AAB and fail before upload if any arm64-v8a or x86_64 ELF load segment is aligned below 16 KB.
- Version 1.0.1 retains checked-in
versionCode20. The live Play maximum was 19 at preflight; upload, review submission, approval, and availability remain separately evidenced release states.
2026-07-20 — Five image-led onboarding story
Type: Mobile onboarding visual upgrade (shared KMP)
- Replaced the five symbolic medallions with five dedicated, no-text editorial photographs mapped one-to-one to the existing company, brief, decision, commitment, and sourced-answer pages.
- Native localized title/body copy remains the only language surface. The images are decorative to assistive technology because the adjacent copy communicates the full meaning without duplication.
- Standard phone layouts reserve 64% of available page height for the visual field. Compact height and enlarged text use a deterministic reduced-image policy so the pinned controls remain reachable.
- The source PNG masters remain in
docs/onboarding/; optimized app resources live in shared Compose resources and therefore render from the same page model on iOS and Android. - First-run and reference-tour intent are explicit. iOS uses
Next,Skip, and finalGet started; Android’s authenticated tour usesNext,Close, and finalGot it. - JVM/source proof establishes asset mapping and responsive policy only. Named-device crop, large-text, VoiceOver, TalkBack, and conversion evidence remain separate.
2026-07-19 — Bottom actions stay above native navigation
Type: Mobile shell interaction safety (Android + iOS)
- iOS 26 Liquid Glass continuity is background-only.
lumiLiquidGlassSurface()paints warm paper beneath the transparent tab bar without extending the hosted Compose controller or any interactive child into the tab bar’s safe area. - The shared destination wrapper covers every pushed route, including Companies, Create/Edit Company, and Create/Edit Task. The same helper protects Assistant and all lane roots, so FABs, composers, and pinned actions keep one system-owned bottom boundary.
- The audit also found that the bespoke iOS Task Detail host dropped its terminal
onDeletedcallback. The callback now crosses SwiftUI → Kotlin host → sharedTaskDetailScreen, restoring the Delete action and allowing disposable task journeys to clean up through the product path. - Android retains its measured
LumiPhoneScaffold: the bottom bar participates inScaffoldlayout while visible and is removed only for the IME. Routed screens therefore receive navigation clearance without per-screen padding. - Source proof inventories the reported company/task actions and locks both platform shell boundaries. Signed simulator/emulator proof remains distinct from named-device evidence.
2026-07-19 — Compact transparent top navigation
Type: Mobile shell visual consistency (Android + iOS)
- Every SwiftUI workspace navigation stack now hides the navigation-bar plane on all supported iOS versions while leaving toolbar buttons under the system’s native material treatment, including Liquid Glass where available.
- The shared Commitments title wrapper is omitted as one unit when SwiftUI owns the title. Hiding only
LumiPageTitlehad left its parent’s 32dp vertical padding as an empty band beneath the native navigation bar. - Android’s shell rail is reduced from 68dp to 56dp without shrinking its 48dp icon targets. The reusable Material top-app-bar default now keeps both resting and scrolled containers transparent.
- Source proof covers ownership and geometry. Named Android/iPhone visual verification remains open under
F30.GAP019.
2026-07-19 — iOS pushed routes preserve Liquid Glass bottom navigation
Type: Mobile shell visual consistency (iOS)
- Pushed
SharedLaneRoutedestinations now bleed under the system tab bar on iOS 26, matching the existing tab-root treatment instead of exposing an opaque safe-area strip on Messages and other routed screens. - The shell keeps the existing transparent
UIBlurEffect.systemUltraThinMaterialappearance and preserves SwiftUI keyboard ownership because the modifier ignores only the.containerbottom edge. - Added
F30.AC021source proof; named iPhone visual continuity remains an explicit contract gap.
2026-07-15 — Deliver Android session expiry through OkHttp instead of crashing
Type: Production crash / Android session lifecycle
Shipped behavior:
- Keep the existing one-refresh attempt and session clear when refresh fails.
- Emit
UnauthorizedExceptionas anIOException, allowing OkHttp to report the failure to the Retrofit/coroutine caller instead of terminatingRealCall.AsyncCall. - Preserve
MainActivityViewModel.restoreSessionas the owner of the return-to-auth decision. - Add JVM and repository contract regressions for the transport boundary. Named-device expired- session proof remains an explicit release follow-up.
2026-07-15 — Restore the Android Navigation3 dispatcher contract after login
Type: Production crash / Android platform compatibility
Shipped behavior:
- Pin the AndroidX Activity family to stable
1.13.0, which implementsNavigationEventDispatcherOwner, as required by the shipped Navigation3 host. - Move Navigation3 to patch
1.0.1, which carries its compatible NavigationEvent dispatcher fix. - Add runtime-type and repository contract regressions for the compatibility boundary. Named-device authenticated-shell proof remains an explicit release follow-up rather than inferred from JVM tests.
2026-07-15 — SYS-06: add an honest previous-build upgrade proof
Type: Runtime-proof protocol
Added scripts/run-upgrade-proof.sh, its fake-tool self-test, and the two-phase
Maestro flow under .maestro/proof/sys06-upgrade/. The runner installs a previous
artifact, prepares a session/language/draft marker, installs the current artifact
without clearing data, and verifies those markers after relaunch. Its manifest
records artifact hashes and explicitly separates emulator/simulator UI-only output
from scoreable Android-floor/iPhone-12 evidence. No registry score changes here;
the PM still owns named-device execution and scoring.
The prepare and verify phases use bilingual English/Vietnamese navigation selectors, so a selected Vietnamese account language reaches the same language and unfinished-draft assertions instead of failing during navigation.
2026-07-16 — ONB-05: add a deterministic Android accept-invite app-link fixture
Type: Test infrastructure and proof-boundary correction
Summary:
- Added
scripts/run-android-deep-link-maestro.sh, which clears only the installed app state, launches the realhttps://app.runlumi.app/auth/accept-invitation/<token>AndroidVIEWintent, and then runs one Maestro flow. The runner must bind both ADB and Maestro to the same requiredANDROID_SERIAL; a second connected target must never receive the UI proof accidentally. - Added
.maestro/critical/64_accept_invite_deep_link.yaml, which logs in through the deep-link entry and asserts the localized Accept Invite recovery action. - The Android shell must retain one
AcceptInviteScreenModelfor the active API client across recomposition so a failed redemption cannot reset to an endless verifying state before the localized recovery action renders. - The default invalid UUID is intentional: it proves routing without consuming the long-lived demo invitation or pretending that membership redemption succeeded.
Honest gate:
- The fixture is emulator UI evidence only. A real invite token, named Android floor-device, iPhone 12, email/app-link delivery, and PM scoring remain open.
Emulator UI evidence (2026-07-16):
- Source commit
598245fdd216cfbcca9caabf7cbca1a754b9323epassed the guardedLumi_Pixel7_API35journey 1/1 in 16 seconds. - The 127,411,771-byte dev APK and installed package both had SHA-256
e99a6604287c77ca5cb9fa8bdd74c6a2b868b3dfc9a778de11714318ce170276. - JUnit, command trace, log, and the inspected Vietnamese
Quay lạirecovery frame are retained in/Users/james/.maestro/tests/2026-07-16_154541. - The invalid token proves app-link entry, authenticated route retention, and localized recovery only. It does not prove successful invitation redemption.
2026-07-16 — SYS-05: bind iOS form titles to VoiceOver fields
Type: Accessibility contract repair
LumiFormField now assigns its visible title to the native TextField accessibility
label. A rendered LumiKitTests XCTest covers the SwiftUI accessibility tree. The
simulator proof is UI-only; the PM still owns the same-day VoiceOver/TalkBack
walkthrough on the named iPhone 12 and Android floor device, and no audit score
changes in this lane.
2026-07-15 — Android Maestro uses the stable dev install identity
Type: Test infrastructure bug fix
The Android product flavors intentionally reuse one temporary install identity:
com.locuno.lumi.android for both prod and dev. The root Maestro Make default,
proof runners, and test-plan examples must use that identity; build-type-specific
IDs and the deferred permanent dev package are not valid Android launch targets.
T222 expands AndroidApplicationIdSourceTest to cover the root Makefile,
Maestro README, and active journey plans. The regression must fail on the stale
value before the entry points are corrected. Historical postmortems remain
unchanged because they record the app identity observed at the time.
Verification: the focused guarded test failed at
AndroidApplicationIdSourceTest.kt guards the Makefile, proof runners, Maestro
entry points, and JRN plans against the retired dev identity. ADR-051 closes the
old deferral on 2026-08-02 with an isolated Firebase project and internal-only
Play publisher; production-oriented automation defaults remain com.lumi.ai.
T223 extends the active Android V2 shell proof from the Lumi home settings control
through the surviving Pulse Settings destination. Combined with
NavEntryUniquenessTest, the PM train checks both halves of the startup regression:
one registered destination and one reachable runtime path. The journey uses stable
IDs for the localized settings control, Pulse settings action, and daily-questions
section. scripts/run-android-shell-startup-proof.sh owns the full managed-runtime
lifecycle, fixture, exact prodDebug APK custody, and shutdown; the result is recorded
only after that fresh APK flow executes. This remains emulator UI evidence and cannot
close named-device or production proof.
2026-07-15 — Settings language picker exposes its selected state
Type: Shared KMP accessibility and device-proof repair (ACCT-02.U3)
LumiDropdownField places a full-size clickable semantics node over its
read-only visual field. The node exposed the control label but omitted the
selected display value, so screen readers and Maestro could not observe
“English” even while it was rendered. The primitive now publishes the selected
value through stateDescription, and the Settings instance owns the stable
settings_language_picker automation tag.
The language Maestro journey now waits for loaded account content, performs
an ID-centered scroll, and locates the picker by its locale-independent tag.
SettingsLanguageSourceTest is the red/green source regression for both the
shared state semantics and the Settings tag.
The centered journey then exposed the deeper Android defect: a successful PATCH
changed the picker to Tiếng Việt, but the screen remained English. The shared
view model had updated AppCompat’s requested locale before MainActivity
compared it with the server value, so the Activity skipped work even though its
configured resources were still English. Locale application now has one owner:
the view model updates the persisted auth snapshot, while MainActivity
is an AppCompatActivity, compares against resources.configuration.locales,
and applies the locale through its delegate. AppCompat 1.7.1 is the minimum
compatible stable host because it fixes the NavigationEvent owner contract used
by Navigation 3. Navigation3’s saveable back stack preserves the active route
through the configuration transition.
AndroidLanguageReconciliationSourceTest guards that ownership boundary. This
source and emulator proof does not replace PM evidence on the named Android
floor phone and iPhone 12.
The ACCT-02/JRN-05 flow now defines the reverse transition in the same live
session: English → Vietnamese → English. It uses the locale-independent picker
tag in both directions and asserts the restored English labels and EN summary.
scripts/tests/test-language-switch-flow.sh prevents the flow from regressing
to a one-way proof; it changes no audit score. A fresh runtime result for this
extension remains open because the local AVD failed its health preflight.
2026-07-15 — Android notification reads reconcile the shell badge
Type: Android shell correctness (NTF-03 / COMM-06)
The top-rail badge is owned by MainActivityViewModel, while the notification
screen owns the read mutations. Previously, the screen reloaded its local feed
but never asked the shell to refresh, so a successful read could leave the badge
showing an old unread count. The shared model now accepts an optional success
callback for single and bulk reads; the Android account entry wires it to
mainVm.refreshNotifications. Failed mutations do not invoke it.
Verification: cd android && ./gradlew :shared:jvmTest --tests com.locuno.lumi.shared.presentation.notifications.NotificationsScreenModelTest --no-daemon; python3 -m unittest scripts.tests.test_android_notification_badge_policy.
This is source/JVM proof only. Named Android floor-device reconciliation remains
open and no audit score changes from this PR.
The NTF-03 Maestro proof opens the shell notification center through the stable
notifications_button test tag rather than localized visible text. The tag is
owned by the clickable IconButton, while notifications_badge remains owned
by the badge itself, so the before/after reconciliation flow works in English
and Vietnamese. Emulator UI proof remains non-scoreable, and named-device
reconciliation remains PM-owned.
2026-07-15 — Android notification primer restraint survives process recreation
Type: Android permission lifecycle correctness (SYS-07)
The notification primer decision is now persisted in the encrypted session preferences. Continue, Not now, and dialog cancellation each mark the primer as handled, so a new Activity or process cannot turn a non-blocking choice into a repeat launch nag. Session clear removes the decision. Android Settings exposes one localized route to Lumi’s operating-system notification controls, preserving an explicit retry path without weakening the launch restraint.
Verification: MainActivityPermissionTest, LumiSessionManagerTest, and
scripts/tests/test_android_notification_permission_policy.py pin the lifecycle
and Settings wiring. scripts/run-notification-primer-proof.sh passed dismissal,
force-stop/relaunch suppression, and Settings retry on the Pixel 7 API 35 emulator
with a matching built/installed APK SHA-256. Named Samsung A1x evidence remains
PM-owned and open; emulator output does not change the audit score.
2026-07-15 — Android offline relaunch preserves the persisted session
Type: Android bootstrap/session restoration hardening (ACCT-03.U9)
When Android restored a persisted session after process relaunch, any transient
failure from /me or myOrgs called sessionManager.clear() and returned the
user to Login. The bootstrap now clears credentials only for the explicit
UnauthorizedException emitted after refresh failure. The last successful
user/org snapshot is also kept in encrypted preferences, so transport failures
restore the authenticated shell for offline-first screen state and later retry.
The Pulse screen model also rehydrates queuedOffline from the user/org-scoped
persistent queue on construction, so the pending banner remains visible after
process death until the online drain succeeds.
Verification: MainActivityViewModelTest proves a persisted session survives a
bootstrap outage. The JRN-07 runner also authenticates online before the offline
phase. Its successful manifest contains nine phases: the eight journey phases plus
the final network_restore cleanup phase. Restore-only failure is attributed to
that cleanup phase; an earlier journey failure remains primary while cleanup still
appears in the phase list. Named-device terminate/relaunch and exactly-once drain
evidence remain PM gates; emulator output is UI-only and does not change the audit
score.
2026-07-15 — SYS-03: make iOS offline transport failure deterministic
Type: Test hardening
Summary:
- Replaced the unconditional T205 iOS skip with a
URLProtocolfailure response against the real authenticatedAPIClientrequest seam. - The test asserts
APIError.networkErrorand verifies that access and organization credentials remain intact.
Verification: LifecyclePathTests.test_offline_gracefulFailure (source proof); the PM still owns same-day named-device offline→submit→kill→reconnect evidence.
2026-07-16 — SYS-03: reject ambiguous offline-proof targets
Type: Test infrastructure contract repair
The JRN-07/SYS-03 runner now rejects an Android serial or iOS UDID containing an additional colon after the platform prefix. This prevents an ambiguous target identity from reaching the network hook, Maestro, or the evidence manifest. Valid target parsing, cleanup, and PM-owned runtime gates are unchanged.
2026-07-12 — Android channel deletion closes the mobile delete path
Type: Android shared-KMP CRUD completion (F29 Messages)
Summary:
- Non-default channels now expose a localized, manager-gated delete action with one confirmation and retryable error handling.
- The client treats the existing DRF destroy endpoint as
204 No Contentand filters the deleted channel only after that response succeeds; server authorization remains the final boundary.
Verification: ./gradlew :shared:jvmTest
2026-07-12 — iOS hosted KMP screens follow the in-app language
Type: iOS shared-KMP i18n consistency fix (ACCT-02.U3)
Summary:
KMPComposeViewnow observesLanguageManager.sharedand recreates its hosted controller when the app language changes, so server-hydrated and in-app language changes reach every shared screen.- The Daily Pulse and task-detail callers no longer pin a captured locale; task-detail identity is also keyed by task and language.
Verification: swiftc -typecheck is unavailable on this host because UIKit is not exposed to the standalone compiler; the branch report records the prior bridge typecheck pass, while full xcodebuild remains blocked by the absent isolated-worktree shared.xcframework.
2026-07-16 — iOS language continuity follows signed runtime and genuine tab intent
Type: iOS authenticated-runtime and shell-continuity repair (ACCT-02.U3)
The prior source proof had two hidden assumptions. First, launched Maestro apps
were built with CODE_SIGNING_ALLOWED=NO, so the fail-closed terminal-auth
Keychain boundary correctly rejected durable session custody. Second, the shell
assumed SwiftUI’s selectedTab binding was the rendered user selection. On iOS
26, UIKit could already show Workspace while the binding still read Assistant.
Canonical iOS simulator test, Maestro, release-test, and actor artifacts are now
simulator-signed, and workflows that install an app attest its code signature. The tab shell
records genuine user intent at UITabBarControllerDelegate.shouldSelect, restores
replacement coordinators from that durable intent, and moves KVO when the UIKit
controller identity changes. The language snapshot reads this intent instead of
the potentially stale SwiftUI binding. T226–T228 pin those three boundaries.
The critical Maestro journey now covers English → Vietnamese live redraw,
process termination without state or Keychain clearing, authenticated Vietnamese
restoration, and Vietnamese → English reversal from Workspace Settings. This is
custody-bound simulator evidence from build 146, installed executable SHA-256
27ad989f0f64a90881c0d5dad71b4e134bffda37628a84fc6bc5a12e96bf89ec, not the
named iPhone 12 result required for a PM score change.
2026-07-14 — iOS language changes preserve the active shell tab
Type: iOS shell continuity hardening (ACCT-02.U3)
Language changes can tear down and recreate hosted KMP controllers. UIKit may
temporarily report Assistant as the selected tab during that rebuild, after the
language-change observer has already lost the prior selection. The shell now
captures the selected non-Assistant tab from LanguageManager.objectWillChange
and restores that snapshot only when the post-change value is the spurious
Assistant reset. A user who was already on Assistant is never redirected to a
stale older tab.
Verification: SessionLanguageTests covers capture, reset detection, and
intentional-selection precedence. Named-device English/Vietnamese route
continuity remains a PM evidence gate; no audit score changes from source or
simulator evidence.
2026-07-14 — iOS org-switch sessions survive transient relaunch failures
Type: iOS session restoration hardening (ACCT-03.U9)
After an organization switch, the active access and refresh tokens remain the
same session credentials. A relaunch must not erase them merely because /me
or myOrgs is temporarily unavailable. loadSession() now retries one
non-unauthorized restore failure and preserves credentials when both attempts
fail. It clears credentials only for an explicit unauthorized response, while
the existing single-org and failed-switch paths remain unchanged.
Verification: LifecyclePathTests covers transient restore retry and the
existing expired-session refresh-failure path. Named-device terminate/relaunch,
forced switch failure, and single-org evidence remain PM gates; no audit score
changes from source or simulator evidence.
2026-07-14 — iOS company-detail delete callback reaches the KMP screen
Type: iOS bridge callback hardening (CRM-03.U9)
The iOS shell already supplied a company-detail onDeleted callback, but the
final KMPComposeControllerView bridge passed the unrelated onCreated
callback into makeCompanyDetailViewController. Because the detail route has
no create callback, the shared screen received nil and intentionally omitted
Delete. The bridge now forwards only the dedicated delete callback, with a
focused regression protecting the routing boundary.
Verification: CompanyDetailBridgeTests covers dedicated callback invocation
and the nil/no-fallback case. Named-device populated detail, confirmation,
delete, refresh, and error evidence remain PM gates; no audit score changes
from source or simulator evidence.
2026-07-16 — Android company deletion reconciles the directory
Type: Android CRM lifecycle correction (CRM-03.U9)
Current behavior: the shared detail model can delete a company and Android pops
the detail route, but the Companies entry owns a separate remembered
CrmScreenModel. The returned directory can therefore keep rendering the
deleted company until the user manually refreshes it.
Required behavior: one organization-scoped CRM model must own the Companies and Deals entries plus the company-detail deletion callback. A successful confirmed delete reloads that model before returning to the directory. Stable, locale-independent automation tags must cover the complete route and the destructive confirmation control.
Verification contract: a source invariant proves model custody and selectors;
a seeded Android Maestro flow confirms deletion and waits for ABC Corp to
disappear from the returned directory. Emulator output is UI/mutation evidence
only. Named Android floor-device, iPhone 12, bilingual error/refresh walks, and
PM scoring remain open.
Runtime evidence: the destructive flow passed 1/1 in 43 seconds on the sole
Lumi_Pixel7_API35 emulator. The APK was built from 6fbd0c3a9, the
locale-stable flow ran from 35abda36b, and built/installed SHA-256 matched at
c69d989c5c16a01f4e4508b3e14af8bd18b5d336dcb401c801d557b85cad38f0.
The returned directory omitted ABC Corp; the isolated backend independently
changed from one matching row to zero. Evidence is retained at
/Users/james/.maestro/tests/20260716T103429Z_crm03_company_delete. This does not
replace named-device proof or authorize a PM score change.
2026-07-12 — Android Settings exposes notification restraint controls
Type: Android shared-KMP mobile completion (F36/F77/F93)
Summary:
- Settings now reads and partially updates the tenant-scoped notification preference spine: approval/decision/brief/follow-up/mention/message/Lumi-nudge toggles plus quiet-hours start and end.
- Every change is confirmable from the same screen, keeps other preference fields intact, uses localized English/Vietnamese copy, and surfaces a retryable authored error when PATCH fails.
- The backend preference serializer now includes the existing assistant/message push categories so mobile controls are not dead switches.
Verification: cd android && ./gradlew :shared:jvmTest :app:compileProdDebugKotlin; cd backend && .venv/bin/python -m pytest tests/notifications/test_email_preferences.py -q
2026-07-12 — Android Settings makes assistant memory inspectable
Type: Android shared-KMP trust-surface completion (F91 P5 / ADR-035)
Summary:
- Settings now lists tenant-visible memory facts with provenance, scope, and a humanized learned date.
- Members can edit their own facts and forget them with one confirmation; the server remains the permission and propagation boundary.
- Empty, loading, and failed update states remain authored rather than exposing raw API data.
Verification: cd android && ./gradlew :shared:jvmTest :app:compileProdDebugKotlin; cd backend && .venv/bin/python -m pytest tests/assistant/test_f91_p5_memory_api.py tests/security/test_multitenancy_assistant_memory.py -q
2026-07-12 — Android Settings exposes standing mission controls
Type: Android shared-KMP trust-surface completion (F91 P3/P6)
Summary:
- Owner/admin Settings now loads the three durable missions, keeps built-in missions toggle-only, and avoids requesting the owner/admin endpoint for ordinary members.
- Custom missions can be created with the server safe-list’s
goal_prompt, edited, toggled, and deleted only after confirmation; failures remain retryable and localized in English/Vietnamese. - Android, iOS, and macOS shared Settings construction now carries the effective owner/admin role, and Android Settings passes the role into the screen so memory and mission controls cannot silently fail closed for owners.
Verification: cd android && ./gradlew :shared:jvmTest :app:compileProdDebugKotlin
2026-07-12 — Android Settings surfaces the Lumi Constitution
Type: Android shared-KMP trust-surface completion (F91 Pillar B)
Summary:
- Settings now presents a localized, read-only summary of the versioned Lumi Constitution: the five things Lumi will never do and the five things it always does.
- The summary is deliberately informational; the canonical full English/Vietnamese documents remain the source artifact and no autonomy or permission behavior is changed by this surface.
Verification: cd android && ./gradlew :shared:jvmTest :app:compileProdDebugKotlin :shared:compileKotlinIosSimulatorArm64
2026-07-13 — Mobile Settings adds self-scoped profile and password controls
Type: Mobile personal-account completion (F75 / F30 account breadth)
Summary:
- Shared Settings now reads and updates the authenticated user’s display name through
PATCH /api/auth/me/; the model keeps the returned server value and preserves the existing name on failure. - A current-password/new-password/confirmation form now calls
POST /api/auth/me/change-password/, clears sensitive fields after success, and keeps mismatch/server-error states explicit and retryable. - The KMP client, Settings repository/model, English/Vietnamese resources, and API/model tests cover both self-scoped contracts. Avatar picking/upload remains a separate follow-up because it needs a platform picker callback and fresh runtime proof.
Verification: cd android && ./gradlew :shared:jvmTest :app:compileProdDebugKotlin :shared:compileKotlinIosSimulatorArm64; bash scripts/check-platform-parity.sh
2026-07-14 — Mobile Settings adds self-scoped avatar upload
Type: Mobile personal-account completion (F75 / F30 account breadth)
Summary:
- Shared Settings now exposes a localized profile-photo affordance and renders the returned avatar URL with an initials fallback.
- Android uses the system image picker; iOS uses the native document picker. Both feed the same F34 presign/upload/complete pipeline before persisting
avatar_urlthrough the self-scopedPATCH /api/auth/me/contract. - Unsupported formats, empty/unreadable selections, oversize images, and upload failures remain explicit and retryable; no numeric audit score changes are claimed without fresh device evidence.
Verification: cd android && ./gradlew :shared:jvmTest --no-daemon; cd android && ./gradlew :app:testProdDebugUnitTest --tests com.locuno.lumi.android.ui.SettingsAvatarSourceTest :app:compileProdDebugKotlin :shared:compileKotlinIosSimulatorArm64 --no-daemon; bash scripts/check-platform-parity.sh
2026-07-13 — Android session restore honors the server language
Type: Android mobile i18n consistency fix (ACCT-02/ACCT-05.U3)
Summary:
- Android now persists the language returned by login/session restore and reconciles AppCompat’s process locale before hosted KMP screens render, so an English device does not silently override a Vietnamese account.
- A successful Settings language change updates both the persisted preference and the in-memory authenticated user, preventing the next Activity recreation from reverting to the previous server snapshot. The session copy also preserves the profile name.
- This closes the source-level state split; fresh-device bilingual proof remains an audit gate and is not claimed by this change.
Verification: cd android && ./gradlew :shared:jvmTest :app:testProdDebugUnitTest --tests com.locuno.lumi.android.LocaleTests :app:compileProdDebugKotlin; git diff --check
2026-07-13 — Android CRM and org management rebuild across org switches
Type: Android tenant-boundary and mobile lifecycle fix (ACCT-03 / F50)
Summary:
- Companies and Deals now receive a fresh
CrmScreenModelkeyed by the active organization, so the old org’s cached rows cannot remain visible after a switch. - Organization management receives the same org-scoped rebuild, keeping its selected organization and member state aligned with the new tenant rather than the previous model instance.
- The existing dynamic
X-Organization-IDprovider remains the request boundary; this change makes the UI state boundary obey it as well.
Verification: cd android && ./gradlew :shared:jvmTest :app:testProdDebugUnitTest --tests com.locuno.lumi.android.ui.LumiAppOrgScopeTest :app:compileProdDebugKotlin; bash scripts/check-platform-parity.sh
2026-07-13 — Android pulse attachments start the native parity slice
Type: Android shared-KMP parity slice (F104)
Summary:
- The shared pulse DTO and submit contract now carry F104
attachments[]and optionalattachment_ids; Android uploads through the existing F34 rail rather than inventing a second upload mechanism. - The Android pulse form exposes multi-file selection and direct camera capture, with removable/retryable chips, a ten-file cap, and attachment ids retained for offline submit retries.
- iOS host/picker wiring, native thumbnail/open behavior, and fresh device proof remain open; this slice does not change the audit score.
Verification: cd android && ./gradlew :shared:jvmTest :app:compileProdDebugKotlin; bash scripts/check-platform-parity.sh
2026-07-12 — iOS pulse attachments use the native host and picker
Type: iOS shared-KMP parity slice (F104)
Summary:
- The iOS daily pulse route now uses
PulseViewControllerHost, exposing the shared F34 upload rail to SwiftUI instead of leaving the attachment callbacks unreachable. - Multiple file import and direct camera capture feed shared
UploadSelectionvalues; the shared model remains the source of truth for MIME, size, cap, retry, and submit IDs. - Thumbnail/open rendering and fresh device proof remain open; the audit score is unchanged.
Verification: cd android && ./gradlew :shared:jvmTest :shared:compileKotlinIosSimulatorArm64; focused iOS simulator Swift type-check against the generated shared framework. Full xcodebuild/device proof remains environment-blocked.
2026-07-13 — F103 native team reports source path
Type: Android + iOS + macOS shared-KMP read parity (F103)
Summary:
- Added the shared leader-only pulse rollup and member-detail read contracts, with Android Reports navigation, grouped missing-state cards, refresh/error states, and attachment open actions.
- iOS and macOS now register the same report controllers from their workspace bridges; iOS member selection pushes the shared detail path.
- Contract and ScreenModel tests cover the rollup/detail payloads, refresh, and failure states.
Honest gate: F103 is read-only, so CRUD is not applicable. Native thumbnail rendering, named-device reachability/screenshots, and score evidence remain open; this source slice does not change audit scores.
Verification: cd android && ./gradlew :shared:jvmTest :app:compileProdDebugKotlin :shared:compileKotlinIosSimulatorArm64; bash scripts/check-platform-parity.sh
2026-07-12 — Desktop-handoff tier retired per owner directive
Type: Documentation Change — scope consistency pass
Summary:
- Desktop-handoff removed as an accepted product tier. Every surface must have a real mobile affordance, tracked as “Mobile gap (not yet built)” — never deferred by design solely because it is desk/admin work.
- Former handoff surfaces were removed from Must-not-ship/Out. Shipped Hub authoring, Team/member administration, Audit Log, and CRM CRUD are recorded as shipped; genuine gaps remain explicit mobile backlog.
- “desktop role gating” wording corrected to “server permission gating” throughout.
- F33 source connections: stale “deferred by design” justification replaced with honest mobile-gap tracking.
- Mobile plan, Android inventory, and Android AGENTS.md updated to match.
2026-07-12 — Pulse-library delete reaches both mobile shells
Type: Android + iOS KMP mobile completion (F83)
Summary:
- Owners and admins can now remove existing custom Pulse roles and company questions from the shared Pulse Config screen with a destructive confirmation. The shared KMP surface supplies the behavior to Android and iOS; the server remains the authorization boundary.
- This closes the mobile delete path for the F83 custom-library APIs. Creation and editing remain a separate mobile-authoring scope, not implied by this deletion slice.
2026-07-12 — Pulse-library delete honors effective roles.manage
Type: Bug Fix · Android + iOS KMP permission parity (F83)
Summary:
- Pulse Config now loads custom roles/questions and exposes delete actions from the effective
roles.managecapability, including custom roles. Capability lookup failures fail closed. - Preset role strings remain shell compatibility data only; server-side authorization remains the final boundary.
2026-07-12 — Hub-document delete completes mobile authoring parity
Type: Android + iOS KMP mobile completion (F28 Hub)
Summary:
- The shared Hub-document editor now provides an owner/admin-only, visible delete action with one confirmation and localized error recovery. It returns to the document list only after the existing server-side delete succeeds.
- The client gate is deliberately narrower than general manager access: Hub document write/delete routes are owner/admin only on the backend, so managers do not see a doomed action.
Verification: :shared:jvmTest Hub-document regression coverage and bash scripts/check-platform-parity.sh.
2026-07-12 — Hub-document delete honors effective roles.manage
Type: Bug Fix · Android + iOS KMP permission parity (F28)
Summary:
- Hub document edit/delete now reads the effective
roles.managecapability, including custom roles, and fails closed when capability lookup fails. - The server remains the final authorization boundary; role strings are retained only for shell compatibility.
2026-07-12 — Shared message unsend meets the visible-action and tombstone contract
Type: Android + iOS KMP mobile completion (F29 Messages)
Summary:
- The shared message thread now exposes a visible, localized delete action for an author’s own non-deleted message, while retaining long-press as an accelerator. The action has one confirmation and is absent for other members and existing tombstones, satisfying §6.3 rules 5, 6, and 8.
- The returned server tombstone replaces the matching thread row locally. It renders as a muted localized placeholder and never leaks old text. Deletion failures use localized plain-language UI copy rather than backend or exception text.
Verification:
./gradlew --max-workers=2 :shared:jvmTest --rerun-tasksbash scripts/check-platform-parity.sh
2026-07-14 — Native message edit and DM delete close the sender CRUD gap
Type: Android + iOS KMP mobile CRUD completion (F29 Messages)
Summary:
- Shared channel and DM threads now expose a localized Edit action for the sender and route edits through the existing
POST /api/messages/<id>/edit/contract; the server remains authoritative for ownership and the 15-minute edit window. - The shared DM thread now exposes the same sender-only Delete action and confirmation already used by channel threads, with returned tombstones replacing the local row and retryable failure copy.
- The edit dialog preserves the draft while saving or after a failure, and never exposes backend exception text.
Verification: cd android && ./gradlew :shared:jvmTest --no-daemon; bash scripts/check-platform-parity.sh; git diff --check
2026-07-14 — Native manager channel edit closes the remaining channel CRUD gap
Type: Android + iOS KMP mobile CRUD completion (F29 Messages)
Summary:
- Shared channel lists now expose a manager-only Edit action with localized name/description fields, draft-preserving retry behavior, and replacement of the selected/list channel after a successful PATCH.
- The client now uses the live
/api/messages/router for every native channel and DM list/create/read/send/update operation; stale/api/messaging/...paths are removed from the native messaging contract.
Verification: cd android && ./gradlew :shared:jvmTest --no-daemon; cd android && ./gradlew :app:compileProdDebugKotlin :shared:compileKotlinIosSimulatorArm64 --no-daemon; bash scripts/check-platform-parity.sh; git diff --check
2026-07-14 — Native manager channel archive closes the remaining channel lifecycle gap
Type: Android + iOS KMP mobile CRUD completion (F29 Messages)
Summary:
- Shared channel lists now expose a manager-only, localized Archive action for non-default channels with one confirmation. Successful archive removes the channel from the active list and clears an open selection; failures preserve state and offer retry/dismiss actions.
- The backend owns reversible
POST /api/messages/channels/<id>/archive/and/unarchive/actions, scopes both by organization, denies members, and protects the default channel. Destructive channel delete remains a distinct action.
Verification: cd android && ./gradlew :shared:jvmTest --no-daemon; bash scripts/check-platform-parity.sh; git diff --check
2026-07-14 — Native archived-channel recovery closes the reversible lifecycle
Type: Android + iOS KMP mobile CRUD completion (F29 Messages)
Summary:
- Managers can inspect archived channels in a localized Archived channels section and restore one to the active list.
- The client uses the manager-only
GET /api/messages/channels/archived/endpoint; restore failures preserve the archived row and expose retry/dismiss actions.
Verification: cd android && ./gradlew :shared:jvmTest --no-daemon; bash scripts/check-platform-parity.sh; git diff --check
2026-07-10 — iOS + Android: auto-hide + translucent nav chrome (scroll-down hides, scroll-up reveals; F57 / #57)
Superseded on both platforms (Android 2026-07-18; iOS 2026-08-10): F79 is the authoritative shell contract and requires the four labeled destinations to remain visible during ordinary scroll. Android uses
LumiPhoneScaffoldwithout shell-level scroll tracking, translation, or fading and omits the bar only while the IME is visible. iOS usesPersistentTabBarAppearanceModifierwithout scroll tracking or translation. The bullets below record implementation history, not current behavior.
- iOS:
ScrollVisibilityState(state machine),AutoHidingTabBarModifier(scroll tracking + tab bar animation),BarVisibilityManager(ObservableObject). Ultraviolet material (UIBlurEffect.ultraThinMaterial) on all iOS versions viaUITabBarAppearance. Reduced-motion:UIView.animateskipped whenUIAccessibility.isReduceMotionEnabled. - Android:
ScrollVisibilityState(state machine),LumiAutoHidingPhoneScaffold(NestedScrollConnection+animateFloatAsState). TranslucentLumiNavigationBar. Reduced-motion: snap animation. - Spec: §6.1 nav contract updated (threshold + accumulator description). Tests:
ScrollVisibilityTests.swift(iOS, 16 assertions — added jitter/accumulator/reset tests),ScrollVisibilityStateTest.kt(Android, 12 assertions — added jitter/accumulator/reset tests). Registry F30 row updated.
2026-07-09 — iOS: shared KMP screens honor the in-app language, not just the system locale
Type: Mobile i18n bug fix (P0, design audit — systemically mixed-language screens)
Summary:
- The iOS shell applied the in-app language to SwiftUI only (
.environment(\.locale, …)inApp.swift); the hosted KMP Compose screens kept resolvingstringResource(Res.string…)against the device locale. App-language=vi on an English device rendered English KMP chrome under Vietnamese SwiftUI chrome, and vice versa. - Compose Multiplatform 1.10.3 exposes no public per-composition locale override —
ComposeEnvironment/LocalComposeEnvironment/ResourceEnvironment’s constructor are allinternal(verified against the v1.10.3 sources). Both CMP resolution paths (composablestringResourceviaLocale.current’s darwin delegate, and suspendgetStringviagetSystemEnvironment()) readNSLocale.preferredLanguages, which is driven by theAppleLanguagesuser default — iOS’s own per-app-language mechanism.lumiComposeViewController(…, locale:)now normalizes the tag and puts it first inAppleLanguages(no-op when the device already resolves to that language), so everystringResourcein every hosted screen resolves against the app language;nullkeeps system behavior. - New shared
normalizeAppLanguageTag()(commonMaini18n/AppLanguageTag.kt): canonicalizes shell language values to BCP-47 (“pt_br” → “pt-BR”, “zh-hans” → “zh-Hans”), null for garbage — covered byAppLanguageTagTest(jvmTest). - Every iOS
make*ViewControllerfactory (Work/Business/Workspace/MainViewController.kt) gainedlocale: String? = null, forwarded to the composition root;KMPComposeView.localenow defaults toLanguageManager.shared.languageand passes it to every factory, so all hosted screens get the app language at creation. - Language-switch freshness: all KMP
.id(…)identities inMainTabView.swift(assistant chat, lane roots, tasks/projects, lane detail routes, and now Settings itself) are keyed byLanguageManager.shared.language, so switching language in Settings recreates hosted controllers in the new language — the iOS equivalent of Android’s activity recreation. - Android unchanged and already correct:
MainActivity.onCreate+SharedKmpViewModelcallAppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(lang)), which updates the configuration Compose resources read and recreates the activity on switch.
Tests:
cd android && ./gradlew :shared:jvmTest— newAppLanguageTagTest(8 cases: pass-through, trimming, underscore separators, script titlecasing, region uppercasing, all shell-supported codes, blank→null, malformed→null).cd android && ./gradlew :shared:compileKotlinIosSimulatorArm64— green.cd ios && xcodebuild build -scheme Lumiagainst the rebuiltshared.xcframework— green.
2026-07-09 — Pulse check-in card: processed status re-opened the card (“Submit Pulse doesn’t work”)
Type: Mobile bug fix (owner-reported, TestFlight) + E2E coverage
Summary:
- The backend pulse lifecycle is
draft → submitted → processed(AI extraction done) orfailed. The Pulse-lane check-in card only recognisedsubmitted/failed, so a processed pulse rendered “Not submitted” + a Submit Pulse button that dead-ended on the already-submitted form. NewnormalizePulseStatus()inPulseHomeScreenModelcollapses the lifecycle once for all consumers; the lane UI also acceptsprocesseddirectly. - Shared pulse form gains stable test hooks (
pulse_input,pulse_submit_button,pulse_submitted_view). - New Maestro flow
.maestro/critical/50_pulse_form_submit.yaml— the first E2E that actually types and submits a pulse, then asserts the lane card reads exactly “Submitted” after refresh. - Tasks list: rows now sit as card sheets with 16dp side margins and 10dp gaps instead of an edge-to-edge stitched list (owner-reported “too tight”).
Reason:
- Postmortem:
docs/postmortems/2026-07-09-pulse-processed-status-reopens-checkin.md.
Tests:
cd android && ./gradlew :shared:jvmTest— processed→submitted mapping tests (home + form models),normalizePulseStatustable test.- Maestro:
critical/50_pulse_form_submit.yaml(fresh-day submit and repeat-run convergence).
2026-07-20 — iOS Pulse submit proof follows the keyboard-safe product contract
Type: Mobile test-contract repair (no product behavior change)
Summary:
- A current-source iOS Maestro run reached Daily Pulse, entered the full check-in, and showed both the typed value and pinned Submit Pulse action above the open system keyboard. The journey then failed before submission because it called Maestro’s generic
hideKeyboard, which is not reliable for a KMP Compose multiline editor on iOS. - The deterministic flow now proves the user-facing invariant directly:
pulse_submit_buttonremains visible while the editor owns focus, then the flow taps that action without an artificial dismissal step. MobileKeyboardReleasePolicyTests.test_pulse_submit_flow_keeps_the_keyboard_open_for_the_reachable_ctaprevents the stalehideKeyboardstep or a missing keyboard-open reachability assertion from returning.
Evidence boundary:
- This repairs simulator mechanics evidence only. It does not claim the named-device keyboard score or replace the existing 24-point geometry gate.
2026-07-09 — iOS shell polish batch 2: rail badges, pill primitives, banner corners, money formatting
Type: Mobile visual QC fixes (owner-reported from screenshots + agent QC tour)
Summary:
- Shared
LumiTheme(newLumiLightColorScheme, mirrors the Android design-system scheme) is now applied at the iOS composition root: previously no MaterialTheme wrapped the hosted screens, so everycolorScheme.*reference — ”+ New thread”, “Back”, “Retry”, ripples, text-field cursors — rendered Material3’s stock purple#6750A4, a color not in DESIGN.md. Zero hardcoded hex colors exist in shared UI outside the token file. - The chat ”+ New thread” affordance upgraded from a bare text button to the lit soft-blue
LumiInlineActionpill (DESIGN.md lit-token recipe). - Top-rail unread badges: the bell’s red-capsule badge clipped against the iOS 26 Liquid Glass toolbar capsule; counts now render as a bold red superscript beside the icon (no background, cannot clip), and the Messages icon gains its own unread counter (sum of
unread_countacross/api/messages/channels/, via newAPIClient.fetchMessageUnreadCount()). LumiFilterChip/LumiInlineActionpills stretched to a forced 48dp min-height with a top-pinned label (broken lozenges on Notifications, Tasks filters, chat hint chips). The pill now wraps its label;minimumInteractiveComponentSize()supplies the invisible 48dp senior tap target (F30 §6.3 kept).- The billing alert banner’s Compose host
UIViewControllerwas opaque, painting pale squares behind the banner’s rounded corners on the paper background.lumiComposeViewControllergains anopaqueparameter; the banner passes false. - New shared
formatMoneyAmount(): deal amounts render15 000 000 VND(no-break-space grouping, zero fraction dropped) instead of the raw15000000.00 VNDserializer string — applied on Deals list/detail/status screens, unit-tested (LumiMoneyTest). - Work Graph / Business lanes dropped the section header that repeated the lane name (nav bar + summary card already carry it).
Tests:
cd android && ./gradlew :shared:jvmTest— all green,LumiMoneyTestadded.- Visual verification via
.maestro/qc/visual_qc_tour.yamlscreenshots on iPhone 17 Pro (iOS 26.4).
2026-07-09 — iOS shell: single title owner, Liquid Glass on every tab, chat thread-row overflow
Type: Mobile shell UX fix (owner-reported, TestFlight)
Summary:
- Chat thread rows no longer render a full raw-markdown message as the row title:
plainTextPreview()flattens markdown (emphasis, bullets, links, code) for previews, andThreadRowclamps the title to 1 line / subtitle to 2 with ellipsis. - Screen titles now have a single owner on iOS. New
LocalNativeNavTitleHostCompositionLocal: when the SwiftUI shell titles the nav bar for a screen, its iOS factory passeslumiComposeViewController(nativeTitleHost = true)and the sharedLumiPageTitlerenders a weight-preserving spacer instead of a duplicate in-content header. Android is unchanged (its shell has no top bar). Dynamic-title detail screens (task/project/company/deal/team/announcement) keep their KMP header; the Pulse lane keeps its greeting. - Every pushed
SharedLaneRoutenow sets a static inline SwiftUI nav title (previously only tab roots were titled). - The Assistant home tab gets the same iOS 26 Liquid Glass bottom bleed as the lane tabs (
.ignoresSafeArea(.bottom)+ chat list/composer re-inset vialaneScrollBottomInset()), so the tab bar stays glass on the first tab instead of flipping opaque. - Superseded 2026-07-19: the workspace navigation plane is now hidden on every supported iOS version; native toolbar controls retain the system material treatment, including Liquid Glass where available.
Reason:
- Owner-reported (2026-07-09 TestFlight screenshot): the first-run assistant home rendered the entire markdown onboarding welcome as one unbounded thread-row title; every screen showed its name twice; the tab bar lost transparency only on the home tab. Postmortem:
docs/postmortems/2026-07-09-ios-chat-thread-row-raw-markdown-overflow.md.
Impact:
- The default tab — the first screen every new iOS user sees — reads as one calm surface: one title, glass bars, clamped previews.
Tests:
cd android && ./gradlew :shared:jvmTest—ChatUiNullSafetyTestgains markdown-flattening preview cases (raw**/bullets never reach thread rows).- Visual QC tour:
.maestro/qc/visual_qc_tour.yaml(login → screenshot every tab + key pushed screens).
2026-07-08 — iOS assistant-home crash hardening for partial chat payloads
Type: Mobile crash hardening + regression guard
Summary:
- Removed shared KMP UI non-null assertions from the assistant chat render path (
ChatScreenthread-title fallback andProposalCardowner/due chips), so partial production payloads no longer abort iOS render. - Removed the remaining shared UI
!!usage inAcceptInviteScreenandPulseScreenwhile touching the same crash-safety surface. - Added
ChatUiNullSafetyTestfor blank/null thread and proposal payloads plusSharedUiBangGuardTest, which fails if!!re-entersandroid/shared/src/commonMain/.../ui.
Reason:
- TestFlight build
141crashed on authenticated cold start with a Kotlin/Native / Compose UIKit abort (LUMI-IOS-Q). The raw crash did not preserve the thrown Kotlin exception, but symbolication and code audit identified shared UI null assertions on the assistant-home path as the highest-confidence crash surface.
Impact:
- The assistant home is now resilient to partial backend / AI payloads instead of depending on demo-grade completeness.
- The new guard test turns a future shared-ui
!!regression into CI failure rather than another device crash.
Tests:
cd android && ./gradlew :shared:jvmTest --tests "com.locuno.lumi.shared.ui.chat.ChatUiNullSafetyTest" --tests "com.locuno.lumi.shared.ui.SharedUiBangGuardTest" --tests "com.locuno.lumi.shared.presentation.chat.ChatScreenModelBasicTest"
2026-07-08 — Shared mobile auth preserves real field-validation errors instead of generic 400s
Type: Mobile auth UX bug fix
Summary:
- The shared KMP auth error parser now extracts the first user-facing message from DRF field-error bodies such as
{"email":["Enter a valid email address."]}and{"password":["Ensure this field has at least 10 characters."]}. - Login and registration on mobile therefore surface actionable copy for duplicate email, invalid email, and password-policy failures instead of collapsing every validation miss into a generic
Request failedbanner. - Added shared regression coverage in
ErrorExtTestandAuthScreenModelTestso both register and login keep the real backend validation message.
Reason:
- App-review style auth attempts can fail for ordinary validation reasons even when the transport is healthy. The old shared parser treated DRF field maps as opaque JSON and threw away the useful message, which made mobile auth look broken instead of invalid.
Impact:
- No backend contract changed. Mobile auth now tells the truth about validation failures, which materially reduces false “signup/login is broken” reports and lets users recover in place.
Tests:
cd android && ./gradlew :shared:jvmTest --tests "com.locuno.lumi.shared.presentation.common.ErrorExtTest" --tests "com.locuno.lumi.shared.presentation.auth.AuthScreenModelTest"
2026-07-02 — Landing/home alignment: §2 “calm core” AC superseded by F91 assistant-first (Pillar F)
Type: Documentation Change — landing/home scope alignment
Summary:
- The §2 “calm core” AC (“when the app opens, then the landing screen is Today: …”) is superseded by F91 Pillar F. Per F91, the AI assistant (
/intel/chat) is the post-login default surface at F91 P5 (behind a per-user setting until the P5 gate proves users prefer it). Today is an urgency surface within the workspace, surfaced and deep-linked into by the assistant — not the cold-start default. - The Today content contract (pinned announcement, action queue, risks, tasks, commitments) remains fully valid for the Today urgency surface (Work Graph lane / F13); only the “cold-start landing” claim is re-scoped.
- F91 is WIP (P1 built; assistant-as-home default lands at P5). Interim cold-start default is Pulse (F79 tab 1).
- The superseded AC is struck through with an explanatory note rather than deleted (per changelog policy: supersede, never falsify).
Reason:
- F91 is the product-level authority on the post-login surface. This spec was written before F91 existed and named Today as the app landing; leaving that claim uncorrected creates a spec conflict that misleads implementers.
Impact:
- Documentation only. No code, tests, or behavior changed.
2026-07-02 — §6.3 L64: reversible-action undo snackbar on Today/Tasks (KMP shared, pending build verification)
Type: Feature gap close (§6.3 rule 6 — “reversible actions get undo ≥5s, not a confirm”)
Summary:
TodayRepository— addedreopenTask(id: String)to the interface andDefaultTodayRepository(forwards toapi.reopenTask).TodayUiState— addedundoTaskId: String?field (null = no pending undo; non-null = show snackbar).TodayScreenModel—completeTasknow setsundoTaskId = idon API success (instead of just reloading). NewundoCompleteTask(id)callsreopenTaskthen clearsundoTaskId. NewclearUndoTask()clearsundoTaskIdwithout an API call (snackbar timeout path).TodayScreen— addedLaunchedEffect(undoTaskId)that callsSnackbarHostState.showSnackbar(duration=Long ≈10s, actionLabel="Undo");ActionPerformed→undoCompleteTask;Dismissed→clearUndoTask.SnackbarHostadded inside a wrappingBox.TasksUiState— addedundoTaskId: String?.TasksScreenModel—completeTasksetsundoTaskId = idon success. NewundoCompleteTask/clearUndoTaskfollow the same pattern.TasksScreen— sameLaunchedEffect+SnackbarHostpattern; snackbar sits above the FAB (padding(bottom = 80.dp)).- i18n —
action_undo/undo_task_completedadded to bothvalues/strings.xml(EN) andvalues-vi/strings.xml(VI). No hardcoded strings. - Tests — 3 new tests in
TodayScreenModelTestand 3 inTasksScreenModelTestcovering: (1)completeTasksetsundoTaskId; (2)undoCompleteTaskcallsreopenTaskand clears state; (3)clearUndoTaskclears state without API call. All existing anonymousTodayRepositorytest objects updated to implementreopenTask.
Build status: compile-unverified — KMP build environment blocked (Google Maven / AGP unreachable in this agent host). Code reviewed against existing screen-model and test patterns for structural correctness. Requires :shared:jvmTest on a build-capable host before merge.
Files changed:
android/shared/src/commonMain/.../presentation/today/TodayRepository.ktandroid/shared/src/commonMain/.../presentation/today/TodayScreenModel.ktandroid/shared/src/commonMain/.../ui/today/TodayScreen.ktandroid/shared/src/commonMain/.../presentation/tasks/TasksScreenModel.ktandroid/shared/src/commonMain/.../ui/tasks/TasksScreen.ktandroid/shared/src/commonMain/composeResources/values/strings.xmlandroid/shared/src/commonMain/composeResources/values-vi/strings.xmlandroid/shared/src/commonTest/.../presentation/today/TodayScreenModelTest.ktandroid/shared/src/commonTest/.../presentation/tasks/TasksScreenModelTest.kt
2026-07-02 — KMP AcceptInvite screen migration (iOS + Android, F78 shared-code coverage)
Type: Priority-1 code-sharing migration + Rule 3 fix (3 hardcoded EN strings removed)
- NEW
commonMain/.../presentation/auth/AcceptInviteRepository.kt— interface +DefaultAcceptInviteRepository(acceptInvitation(token=)+myOrgs()). - NEW
commonMain/.../presentation/auth/AcceptInviteScreenModel.kt—AcceptInviteUiState,load(token, onAccepted)backed bysafeRunCatching/friendlyError. - NEW
commonMain/.../ui/auth/AcceptInviteScreen.kt— Compose composable; loading / error / success states;LumiMobilePrimitivestokens;Res.string.*keys. - NEW
commonMain/composeResources/values/strings.xml+values-vi/strings.xml—accept_invite_verifying,accept_invite_success,accept_invite_back(EN + VI). - NEW
commonTest/.../presentation/auth/AcceptInviteScreenModelTest.kt— 5 unit tests. - NEW
iosMain/MainViewController.kt#makeAcceptInviteViewController— iOS factory withonAccepted(String?)+onDismisscallbacks. - iOS:
SharedScreenBridge.swift—.acceptInvite(token:)case added;AppEnvironment.swiftreplacesAcceptInviteView(.inject(diContainer))withKMPComposeView(screen: .acceptInvite(token:)). - Android:
MainActivity.kt— replaces privateAcceptInviteScreencomposable with sharedAcceptInviteScreen; dead imports removed. - Dead code removed: iOS
AuthScreenstruct,LoginView,RegisterView(pre-KMP stubs); iOSAcceptInviteView; Android privateAcceptInviteScreencomposable. - Bug fix (silent): iOS was calling
acceptInvitation(code: token)(shortCode param — for typed codes) instead oftokenparam. KMP migration corrects this consistently with Android.
2026-06-28 — iOS: delete dead native detail screens — CompanyGroupDetailScreens.swift (F78 shared-code coverage)
Type: Dead-code removal (F78 migration, shared-code coverage improvement)
Summary:
- Deleted
ios/Lumi/UI/Workspace/CompanyGroupDetailScreens.swift(234 lines, 5 dead Swift components:AnnouncementDetailScreen,MessageDetailScreen,CompanyDetailScreen,TeamDetailScreen,DesktopHandoffCard). All four detail surfaces are served by KMP screens mounted viaSharedScreenBridge/KMPComposeView;MainTabView.destinationView(for:)routes every detail case toKMPComposeView— none of the deleted Swift structs were reachable. - Removed dead
APIClientmethods (teamMembers(teamId:),ackAnnouncement(id:)) whose only callers were the deleted screens; KMP repositories (TeamDetailRepository,AnnouncementDetailRepository) own these calls fromcommonMain. - Removed 7 dead type aliases from
KMPDtoBridge.swift(Teams, Hub/Announcements, CRM, Messaging sections) that had zero references outside the deleted file. - Full audit doc:
docs/improvements/2026-06/week-26/ios-dead-company-group-detail-screens-removal.md.
Impact:
- Zero native Swift implementations remain for these four detail surfaces (all
commonMain). - References to
CompanyGroupDetailScreens.swiftin §13 entries below (2026-06-16 CG-detail, SU-iOS grep-audit, PTR sweep) describe the file as it existed at that point in time — those entries are historical and correct for their date; the file was deleted 2026-06-28.
2026-06-29 — F30 IAP contract updated for ASC lumi_ai_pro_* products + F72 v3 soft paywall
Type: Spec Update (no behavior change yet — implementation is r-billing-iap)
Summary:
- §12 (Open Questions / Decisions) now locks the soft paywall (Gmail approach) doctrine from F72 v3 and the 8 real App Store Connect product IDs in the
lumi_ai_pro_{5|10|20|50}_{monthly|annual}shape. The legacy 6com.runlumi.{starter|team|business}_{monthly|annual}IDs are retired; any iOS code still loading them MUST be updated in r-billing-iap. - §5 (API Contract)
Store billingrow updated: thePOST /api/billing/store/verify/body now expects one of the 8 new product IDs; the backend resolver inapps/billing/services/resolve_store_productparses(plan_code, seats, period)and storesseatson the resultingSubscription. The webPOST /api/billing/pay/body gains aseatsfield (P29/ADR-024 §3). - This spec change is the contract; r-billing-iap agents A/B/C/D are the implementation, all disjoint directory subtrees per cmd-agents-team.
Reason:
- The 2026-06-16 StoreKit 2 implementation shipped with the wrong product IDs because App Store Connect at the time had a flat-starter/team/business shape; on 2026-06-29 the ASC configuration was switched to a per-AI-seat shape (
lumi_ai_pro_5/10/20/50_monthly/annual) and the iOS code, backend resolver, and shared KMP plan-code map all need to follow.
Impact:
- F30 §12 + F30 §5 only in this spec change; behavior change lives in the r-billing-iap agents’ PRs.
Tests:
- None. Spec change only; the r-billing-iap agents write the failing tests first per TDD.
2026-06-30 — Android paywall/catalog wiring corrected to match the current F30/F72 store contract
Type: Fix (Android commercial/runtime closeout, partial)
Summary:
- Android no longer ships the stale “one static product card and hope Billing is already connected” paywall shape.
BillingManagernow owns the canonical 8-productlumi_ai_pro_{5|10|20|50}_{monthly|annual}catalog, exposes it as typed paywall state, and resolves a purchase against the specific selected product id instead of a hidden singleton SKU. PaywallViewModelnow starts the Play Billing connection on init, collects the live billing catalog, and loads/api/plans/as an honest fallback source so the paywall can still show plan truth when Play product discovery is temporarily unavailable.PaywallScreennow renders a monthly/annual toggle plus selectable plan cards from either the live Play catalog or the fallback plan API. This replaces the stale Android-only static feature-list view and brings the shell materially closer to the same commercial contract iOS already follows.- Targeted proof is now in
android/app/src/test/kotlin/com/locuno/lumi/android/PaywallViewModelTest.kt; compile coverage for Android instrumented fakes was also repaired so the billing/paywall test harness still builds after the API surface widened.
Reason:
- The old Android paywall had become a product and technical debt trap: it did not call
startConnection(), could silently miss Play product discovery, only supported one purchase target, and was drifting away from the 2026-06-29 F30 contract that locked the 8-seat-basedlumi_ai_pro_*product grammar.
Verification:
cd android && ./gradlew :app:compileDevDebugAndroidTestKotlincd android && ./gradlew :app:testProdDebugUnitTest --tests com.locuno.lumi.android.PaywallViewModelTest
Impact:
- Android billing/paywall is no longer the biggest “obviously stale relative to current F30” surface.
- This is still not full closeout: native-store sandbox/runtime purchase proof, verify-failure recovery UX, and broader end-to-end actor-flow coverage remain open.
2026-07-01 — Android verify-failure recovery state added for charged-but-not-yet-activated purchases
Type: Fix (Android commercial/runtime closeout, partial)
Summary:
- Android paywall verification no longer collapses every backend failure into a dead generic error.
PaywallViewModelnow preserves the paid purchase payload (productId,purchaseToken,transactionId) whenPOST /api/billing/store/verify/fails after a completed Play purchase or restore. PaywallScreennow swaps the primary CTA into an explicit activation-retry path (Đã nhận thanh toán — kích hoạt ngay) whenever a paid purchase is still pending server activation, instead of inviting the user to try buying again.- After 3 failed activation retries, Android now escalates to a concrete support-contact state with
support@runlumi.apprather than silently leaving the user in an endless generic-error loop. - Focused unit proof lives in
android/app/src/test/kotlin/com/locuno/lumi/android/PaywallViewModelTest.ktfor: preserving pending paid state after verify failure, successful retry clearing that state, and the 3-failure support escalation.
Reason:
- After the catalog/bootstrap fix, the next sharpest product-risk was stranding a user who had already paid through Google Play but hit a backend verification failure. That violates F30 §9 and creates exactly the kind of trust debt we cannot afford in the mobile wedge.
Verification:
cd android && ./gradlew :app:testProdDebugUnitTest --tests com.locuno.lumi.android.PaywallViewModelTest
Impact:
- Android is now materially closer to the F30 “never strand a charged user silently” contract.
- Cross-platform closeout is still incomplete: iOS does not yet have the same explicit failure-count/support proof, and no real Play/App Store sandbox purchase run has been executed here.
2026-06-22 — Pull-to-refresh on all shared KMP data screens (closes §6.3 / mobile-ux-patterns.md §2 gap)
Type: Fix (UX consistency — merge-blocking per mobile-ux-patterns.md §2 + F30 §6.3)
Summary:
- All 26 scrollable data-backed screens in
android/shared/src/commonMain/were missing pull-to-refresh, violating the “pull-to-refresh MUST be present on every scrollable data-backed screen” rule inmobile-ux-patterns.md §2(classified merge-blocking). - Added
LumiRefreshabletoLumiMobilePrimitives.kt: a thinPullToRefreshBoxwrapper in the shared Lumi design token system, so all screens use a consistent indicator. - Added
isRefreshing: Boolean = falseto theUiStateof every affected ScreenModel and arefresh()function: guards against double-load (no-op ifisLoadingorisRefreshingalready), setsisRefreshing = truewhile keeping existing data visible (correct four-state behavior — does not replace content with a skeleton on pull), clears on completion. - Wrapped every affected
LazyColumnwithLumiRefreshablein the shared UI screens. - Added
refresh()unit tests to every affectedScreenModelTest.
Affected screens (all in commonMain, landing on both Android and iOS simultaneously):
Approvals · Brief · Chat (thread list + thread) · Commitments · Contacts · CRM Companies ·
CRM Deals · Decisions · Follow-ups · Forecast · Horizon · Hub · Hub Docs · Business Lane ·
Company Hub Lane · Pulse Lane · Work Graph Lane · Message Thread · Messages · Projects ·
Recurring · Risks · Search · Tasks · Teams · Today
Intentionally excluded (justified):
PulseScreen— the daily pulse compose form (explicitly excluded in mobile-ux-patterns.md §2)SettingsScreen— no scrollable server-backed listWorkspaceLaneScreen— static navigation structure, no server data
Verification:
./gradlew :shared:compileKotlinJvm— zero errors (JVM compilation of commonMain)./gradlew :shared:jvmTest— all ScreenModel unit tests pass including newrefresh()tests./gradlew :app:connectedDevDebugAndroidTest— requires Android emulator (not available in this cloud host; stated honestly per AGENTS-mobile.md §3)xcodebuild test -scheme Lumi— requires macOS + Xcode (not available in this cloud host)
Impact:
android/shared/src/commonMain/kotlin/com/locuno/lumi/shared/ui/LumiMobilePrimitives.kt(newLumiRefreshable)- All 26 UI screens in
android/shared/src/commonMain/kotlin/com/locuno/lumi/shared/ui/ - All 24 ScreenModels in
android/shared/src/commonMain/kotlin/com/locuno/lumi/shared/presentation/ - All 24 test files in
android/shared/src/commonTest/ docs/improvements/2026-06/week-04/mobile-pull-to-refresh-kmp.md(improvement record)
2026-06-16 — Unit coverage: Today inline-action VM logic + Pulse step navigation
Type: Test
Summary — added unit tests for two recently-added behaviors, plus one tiny behavior-preserving refactor to give Pulse a testable seam.
- Today inline actions (
ios/UnitTests/Actions/TodayInlineActionTests.swift): coverTodayViewModel.approveApproval/resolveRisk/completeTask/resolveCommitment,pendingActions, andactionError. Asserts: on success the acted item drops out of its section after the post-action refetch andpendingActionsdoes not retain the id; on failureactionErroris non-nil and the section stays.loadedwith its existing items (no optimistic loss) and pending is cleared; the prior error banner clears on the next action; and the in-flight invariant — using a gated mock, the id is inpendingActionswhile the action is suspended, a re-entrant call for the same id is a guarded no-op, and pending clears once the action completes. Reuses theMockTodayDataSource/CountingDataSourcestyle and model constructors fromWedge/LoadStateTests.swift+Wedge/PullToRefreshTests.swift. - Pulse step navigation (
ios/UnitTests/Wedge/PulseFormStateTests.swift): the index/answers logic was trapped insidePulseScreen’s@State. Performed the smallest seam extraction — a purePulseFormStatestruct (currentIndex,answers,advance()/back()clamping,progressPosition) that the View now renders from unchanged. Tests assert advance/back clamp within0..<6, the 1-based progress count, and that typed answers survive Back/Next and a failed submit.
Build: xcodebuild build-for-testing (UnitTests target) compiles clean — 0 error: lines. No app-code visibility tweaks needed beyond the Pulse seam extraction (behavior-preserving).
2026-06-18 — Mobile shell IA moved to F79
Type: Documentation Change
Summary:
- Added a supersession note at the top of F30 pointing shell and tab architecture work to F79.
- F30 continues to own platform breadth, gating, paywall/invite/org lifecycle glue, and verification rails; F79 now owns the five-tab operator shell doctrine.
Reason:
- The old F30 tab contract (
Today/Brief/Pulse/Ask Lumi/Workspace) slices the product by internal feature names instead of operator lanes. The new IA needed a dedicated spec rather than more patchwork inside the platform spec.
Impact:
- Future mobile implementation work must read F30 and F79 together. Any remaining old-tab language in F30 is historical drift to be resolved in implementation, not the target state.
Tests:
- None. Spec-only change.
Notes:
- This is a documentation truthfulness fix, not a status change. F30 remains unshipped.
2026-06-16 — Today inline per-row primary action (§6.4 Today contract, closes deferred clause)
Type: Fix (§6.4 Today “one primary action button” per-row clause + §6.3 senior-usability)
Summary — the §6.4 Today contract requires “Every row: object title, one-line context, one primary action button.” Today’s queue rows were whole-row navigation to a detail screen with no inline action; a prior pass (entry below) deferred this as “a redesign.” It is a binding clause — now implemented. Each actionable row gains one compact inline primary button that reuses the exact endpoint its detail screen already posts to (no new endpoints):
- Approval → “Phê duyệt” →
POST /api/approvals/{id}/approve/(same asApprovalDetailScreen). - Risk → “Đã xử lý” →
POST /api/risks/{id}/resolve/(same asRiskDetailScreen). - Task → “Hoàn thành” →
POST /api/tasks/{id}/complete/(same asTaskDetailScreen/WorkspaceTaskListScreen). - Commitment → “Đã xong” →
POST /api/commitments/{id}/resolve/(same asCommitmentDetailScreen). - Decision → no inline action — navigates to
DecisionDetailScreen.POST /api/decisions/{id}/decide/requires anoptionchoice from the decision’s option list, so a one-tap inline decide would be unsafe (it cannot pick the right option). The detail screen is the correct place to choose-then-decide.
Isolation of tap targets: each row is a LumiCard containing an HStack of a NavigationLink (wrapping only the content, .contentShape(Rectangle()), .buttonStyle(.plain)) and the action button as a sibling (never a descendant). A SwiftUI Button that is a NavigationLink sibling consumes its own tap, so tapping the button runs the action and tapping the rest of the card still navigates — no gesture ambiguity.
Post-action refresh: TodayViewModel gains four action methods that call the endpoint then refetch only the affected section (fetchApprovals/fetchRisks/fetchTasks/fetchCommitments), so the acted item drops out of its open-only queue. A pendingActions: Set<String> disables the row’s button and shows its spinner while in flight. On failure the section state is untouched (no optimistic loss) and a non-destructive warning banner (actionError) is shown at the top of Today.
Senior-usability (§6.3): visible Vietnamese-first text label (not icon-only), ≥44pt target (minHeight: 44), 17pt semibold text, LumiPalette tokens only, one primary action per row. The button matches the existing row visual language (primary fill, 12pt radius) — an addition, not a layout redesign.
Tests: MockTodayDataSource (LoadStateTests) and CountingDataSource (PullToRefreshTests) gained the four new TodayDataSource action methods so existing ViewModel tests still compile against the extended protocol.
Impact: ios/Lumi/UI/Today/TodayScreen.swift (protocol + APIClient endpoints + ViewModel actions + TodayRowActionButton + actionableRow helper + error banner), ios/UnitTests/Wedge/LoadStateTests.swift, ios/UnitTests/Wedge/PullToRefreshTests.swift.
Verification: xcodebuild build-for-testing (generic iOS Simulator, CODE_SIGNING_ALLOWED=NO) — ** TEST BUILD SUCCEEDED **, zero error: lines.
2026-06-16 — Core-tab §6.4 screen-contract compliance (Today/Brief/Pulse/Ask Lumi)
Type: Fix (§6.4 screen contract + §6.3 senior-usability enforcement)
Summary — audited the four core tabs against their §6.4 contracts and fixed concrete violations:
- Brief (
BriefScreen.swift): (1) item rank now shown as a#Nbadge (§6.4 “each item shows its rank”) — previouslyrankwas used only for sort order and never displayed. (2) Evidence link given a visible text label (“Evidence” + link icon) replacing the icon-onlylink.circle.fill(§6.3.3 — icon-only banned outside composer). (3) The two item buttons relabelled to the contract’s Vietnamese-first copy: “Giao việc” / “Bỏ qua” (was English “Assign”/“Dismiss”). The conditional “Approve” button on approval-type items is retained as a legitimate primary action. - Pulse (
PulseScreen.swift): now shows one question at a time with Back/Next navigation and a Submit on the final question (§6.4 “six questions, one visible at a time on small screens”) — previously all sixTextEditors rendered stacked at once. Progress label is the visible-question position “3/6”; typed input is preserved across navigation and across submit failure (answers held in state, never cleared on error). - Ask Lumi (
ChatScreen.swift): composer now has a mic button (system dictation) paired with send (§6.4 “composer with mic (system dictation) + send”). The mic focuses the composer field to surface the OS keyboard’s built-in dictation mic — no custom Speech entitlement/Info.plist usage-description needed. Both composer icon buttons now carry accessibility labels and ≥44pt targets (§6.3.2/§6.3.8); icon-only is permitted for the composer mic/send pair per §6.3.3. - Today (
TodayScreen.swift): compliant on the ordering contract (action queue: open approvals + open decisions → risks → tasks → commitments), one-pinned-banner-max, object title + one-line context per row, and positive empty states for quiet sections. The per-row “one primary action button” clause was deferred here as “a redesign” — that deferral is now closed by the 2026-06-16 Today inline-action entry below.
Verification: xcodebuild build-for-testing (generic iOS Simulator) — ** TEST BUILD SUCCEEDED **, zero error: lines.
2026-06-16 — Pull-to-refresh consistency sweep on recently-added iOS detail screens (PTR follow-up)
Type: Fix (PTR contract enforcement, no behavior change otherwise)
Summary:
- Audited every recently-added scrollable screen against the 2026-06-15 PTR contract (“pull-to-refresh on every scrollable data-backed iOS screen”).
- One data-backed screen was missing
.refreshable:SignalInboxDetailScreen(IntelligenceInboxScreens.swift) — it GETs/api/signal-inbox/<id>/on.taskbut had no pull-to-refresh. Added.refreshable { await load() }matching theWorkspaceScreens.swiftpattern (re-fetches the single object). - Intentionally skipped (correctly already without PTR): pure passed-in/static detail screens with no own fetch on appear —
AnnouncementDetailScreen,MessageDetailScreen,CompanyDetailScreen(CompanyGroupDetailScreens.swift);ApprovalDetailScreen,TaskDetailScreen,CommitmentDetailScreen,RiskDetailScreen,DecisionDetailScreen,RecurringTaskDetailScreen,HorizonItemDetailScreen(DetailScreens.swift);FollowUpDetailScreen(reply form on passed-in object). PTR on a screen that fetches nothing on appear has nothing to refresh; PTR on a form is wrong. - Verified already-present:
TeamDetailScreen,FollowUpListScreen,SignalInboxListScreen,ProjectDetailScreen,WorkspaceTaskListScreen,SubscriptionScreen.
Impact:
ios/Lumi/UI/Workspace/IntelligenceInboxScreens.swift(SignalInboxDetailScreen gains.refreshable). Build-for-testing green.
2026-06-16 — iOS Rule-of-Two manifest test + senior-usability a11y audit (R2-iOS / SU-iOS, closes §11 rows 6 & 7)
Type: Test + refactor (no UI change) + audit
Rule of Two (§7 test 12, §11 row 7 → ✅ match on iOS).
- Refactored
ios/Lumi/UI/MainTabView.swift: the four Workspace groups + their rows are now a static, value-typeWorkspaceManifest(Section→[Row], each row with a stableid,LocalizedStringResourcelabel, SF Symbol, optional count key, and aDestination—.view { AnyView(...) }or.settingsSheet).WorkspaceTabView.bodyrenders fromWorkspaceManifest.sectionsviamakeRow(_:). Rendered UI is byte-for-byte equivalent — same rows, same §6.2 order, same labels/icons/badges/destinations, sameWorkspaceSection/WorkspaceRowviews. - New
ios/UnitTests/Workspace/RuleOfTwoTests.swift:test_manifest_sectionsInFixedSixTwoOrder— sections are exactlywork, intelligence, company, account.test_manifest_everySixTwoDestinationPresentInOrder— each group’s rows equal the fixed §6.2 set, in order.test_manifest_everyRowReachableWithLabelAndDestination— every row has a non-empty visible label, an icon, and a buildable destination (no dead rows); every section header too.test_manifest_totalDestinationCount(20) +test_manifest_rowIDsUnique— drift guards.
- Because
MainTabViewrenders the manifest, these assert the structural truth that every §6.2 / §3-matrix Workspace-tier feature is reachable in ≤2 taps: Workspace tab (tap 1) → row (tap 2).
Senior-usability (§6.3, §7 test 13, §11 row 6 → ⚠️ partial; iOS automatable parts proven).
- Automated:
RuleOfTwoTests.test_metrics_touchTargetsMeet44ptMinimumassertsLumiMetrics.minimumTapTarget(48) ≥44pt,directoryRowHeight(56) ≥44 and ≥56,buttonHeight/fieldHeight(56) ≥44pt. The manifest label test enforces §6.3-3 (visible text label on every Workspace row/section). - Grep-audit of the recently-shipped screens —
AccountScreens.swift,CompanyGroupDetailScreens.swift,IntelligenceInboxScreens.swift,Billing/PaywallView.swift,Billing/SubscriptionScreen.swift, and the Work additions inDetailScreens.swift— for icon-only interactive controls lacking a label. Result: none found. Every Button/LumiButtoncarries aText/Labelwith a localized title or an explicit.accessibilityLabel(e.g. the org-switch row’s.accessibilityLabel(org.name), the paywall plan card’s.accessibilityLabel(Text(...))). The remainingImage(systemName:)hits are decorative (empty-state icons over aText, leading row icons next to a title, thecheckmark/pin.filladornments) inside already-labeled containers. No a11y label fixes were required.
Checklist — device-only senior-usability verification (pending, not hand-waved). These genuinely need a booted device/VoiceOver and CANNOT be unit-proven here:
| # | Invariant (§6.3) | Per-screen check | Status |
|---|---|---|---|
| C1 | 200% Dynamic Type, no truncated primary action (§6.3-1) | Set iOS text size to max (AX5). Open each Workspace group + each new screen (Account org list, CompanyGroup detail, Intelligence inbox detail/reply, Paywall, Subscription). Confirm primary LumiButton text wraps, never clips; rows grow vertically. | ⏳ pending device |
| C2 | VoiceOver rotor labels on every interactive element (§6.3-8) | VoiceOver on; rotor → “Controls”. Sweep each new screen; every actionable element announces a meaningful label (not “button” alone). | ⏳ pending device |
| C3 | Touch-target hit area in-app (§6.3-2) | Accessibility Inspector → audit each new screen; confirm no <44pt actionable frame. (Static token side proven by test_metrics_touchTargetsMeet44ptMinimum.) | ⏳ pending device |
| C4 | Contrast ≥4.5:1 text / ≥3:1 UI (§6.3-8) | Accessibility Inspector contrast audit, light + dark, on each new screen. | ⏳ pending device |
Sim-execution of UITests is unavailable in this environment (hangs), so no XCUITest was added; the above are the honest manual gate before flipping row 6 to ✅.
Verification: xcodebuild build-for-testing -scheme Lumi -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO → TEST BUILD SUCCEEDED, zero error: lines. UITests intentionally not run (sim hangs here).
Why this was a gap: §11 rows 6 & 7 were slogans (“tested invariant”, “merge-blocking”) with no executable proof. The Workspace was structurally complete but nothing pinned its shape or the Rule of Two; a future row deletion/reorder would have silently broken the contract. The manifest + tests make both regressions fail the build.
2026-06-16 — iOS unit tests: Workspace + billing response-model decode coverage
Type: Test
Summary:
- Added two UnitTests-target files (no network, deterministic, decode helper mirrors
APIClient’s.convertFromSnakeCasedecoder; encode helper mirrors its no-key-strategy encoder):ios/UnitTests/Workspace/SubscriptionDecodeTests.swift—SubscriptionResponse(full + minimal payloads,can_manage_billing/billing_period/expires_at/grace_until, nestedEntitlements.hasFeature+.freefallback,isEntitledForBootstrapacross all statuses),SubscriptionPlan,PublicPlanResponse(price_monthly_vnd/price_annual_vnd, paginated),StoreVerifyRequestencoding to snake_case (product_id/receipt_data/purchase_token/transaction_id, iOS + Android shapes, no camelCase leak), andStoreKitManager.period(of:)product-id → monthly/annual resolution.ios/UnitTests/Workspace/WorkspaceContractDecodeTests.swift— widenedRecurringTaskResponse(full + minimal title fallback),TeamMemberRowResponse(flatuser_name/user_email,displayNamefallback, paginated),AnnouncementResponse.acked+body_md,FollowUpResponse(F31) +FollowUpReplyRequestsnake_case encode,SignalInboxItemResponse/SignalProposalResponse(F32) incl.displayTitlefallback + 60-char truncation,ProposalRejectRequest,HorizonItemResponse.days_until.
- Closes the F30 §13 decode-drift coverage gap for the new Workspace/billing/Intelligence models. UITests intentionally not added (sim execution unavailable here).
Reason:
- The new screens + response models shipped 2026-06-16 with no test coverage; decode drift between iOS models and backend serializers is the F30 §13 failure class.
Verification:
xcodebuild build-for-testing -scheme Lumi -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO→ TEST BUILD SUCCEEDED, zero errors. Tests not executed (sim run hangs in this environment).
Impact:
ios/UnitTests/Workspace/SubscriptionDecodeTests.swift(new),ios/UnitTests/Workspace/WorkspaceContractDecodeTests.swift(new).
2026-06-16 — iOS Workspace › Intelligence: Follow-ups (F31) + Signal Inbox (F32)
Type: Feature
Summary:
- Added the two missing §6.2 Intelligence destinations to
ios/Lumi/UI/MainTabView.swift(after Risks): Follow-ups and Signal Inbox, each with a visible text label + SF Symbol. - New
ios/Lumi/UI/Workspace/IntelligenceInboxScreens.swift:- Follow-ups (F31):
FollowUpListScreen(GET /api/follow-ups/, paginated) →FollowUpDetailScreenwith a bottom-anchored Send reply primary action wired toPOST /api/follow-ups/<id>/reply/ {answer_text}(assignee-bindable write; reply box shown only whilestatus == "sent"). Latest reply rendered when present. - Signal Inbox (F32):
SignalInboxListScreen(GET /api/signal-inbox/) →SignalInboxDetailScreen(GET /api/signal-inbox/<id>/) showing raw text + nested proposals. Per-proposal Create/Dismiss map toPOST /api/signal-proposals/<id>/accept/and/reject/; one bottom-anchored Ignore this signal action (destructive-confirm dialog) maps toPOST /api/signal-inbox/<id>/ignore/.
- Follow-ups (F31):
- Response models (
FollowUpResponse,SignalInboxItemResponse,SignalProposalResponse) + request bodies added in the same file; decode via the client’sconvertFromSnakeCase, write bodies use explicit snake_caseCodingKeys.
Reason:
- F30 §6.2 listed Approvals · Decisions · Commitments · Risks but omitted F31/F32, which are shipped backends with no iOS surface — a breadth gap against §11 row 8 and the Rule-of-Two.
Impact:
- §11 audit row 8 advanced (Intelligence group now complete); both backends were fully shipped so both destinations are real read+write, no handoff stub needed.
- Build:
** TEST BUILD SUCCEEDED **(build-for-testing, generic iOS Simulator, zeroerror:lines).
Intentional gaps / notes:
- F31 create/send/cancel flows (manager-only, source-item entry points) are not on mobile — the CEO’s daily mobile job is replying to chase; creation lives where the source object is edited (desktop). F32 proposal accept sends no edited payload (uses the proposal’s own payload); editing title/description before accept stays a desktop affordance. Neither is a dead-end: every shipped row performs a real action.
- New en/vi locale strings are emitted via
String(localized:); iOS string catalog extraction is handled by the existing build pipeline like every other screen.
2026-06-16 — iOS Workspace › Account group completed (§6.2)
Type: Enhancement (feature completion)
The Workspace › Account group rows for Subscription, Switch organization, and Join with code
were Text(...) placeholder destinations in ios/Lumi/UI/MainTabView.swift. They now navigate
to real working screens, and the group now offers the full §6.2 fixed set
(Settings · Subscription · Switch organization · Join with code · Create organization · Manage organization).
New file ios/Lumi/UI/Workspace/AccountScreens.swift:
SubscriptionScreen— read-only plan/status fromGET /api/billing/subscription/(newAPIClient.fetchSubscription();SubscriptionResponseextended withplan,billing_period,expires_at,grace_until). Shows a labeled “Quản lý trên máy tính” desktop-handoff hint per §6.5; no in-app web checkout per §12. Members who can’t manage billing see the contact-owner line.SwitchOrganizationScreen/JoinOrganizationScreen/CreateOrganizationScreen/OrgDangerZoneScreen— reuse the existing, already-shippedAPIClientorg methods (switchOrganization,acceptInvitation,createOrganization,leaveCurrentOrganization,deleteCurrentOrganization) that previously lived only inSettingsScreen. Leave/Delete now useconfirmationDialogconfirms (§6.3-6 destructive-confirm), bottom-anchored single primaryLumiButtonper form screen (§6.3-4), ≥17pt body / ≥44pt targets, plain Vietnamese-first copy, visible text labels on every row.
SettingsScreen is unchanged (still has its own org controls); the new screens are the
Workspace-group entry points. Verified: xcodebuild build-for-testing (scheme Lumi,
generic iOS Simulator) exit 0, zero error: lines. New .swift file auto-compiles via the
Xcode 16 file-system-synchronized Lumi root group (no project.pbxproj edit needed).
2026-06-16 — iOS Company group: list-only → detail screens + supported actions (CG-detail)
Type: Enhancement (closes the §6.2 Company group list-only gap on iOS).
Problem: the Workspace › Company group (Hub · Messages · Teams · Customers) was list-only on iOS — each tab showed a list with no detail and no actions, a dead-end against the §6 navigation contract and the §6.5 “never a blank wall” rule.
Changes (ios/, all in ios/Lumi/):
- Hub — was wired to a non-existent
/api/hub/articles/route viaHubArticleResponse. Rewired to the real paginated/api/announcements/(AnnouncementResponse); rows now pushAnnouncementDetailScreenshowing the fullbody_mdplus a real acknowledge action (POST /api/announcements/<id>/ack/). TheackedSerializerMethodField is surfaced as a confirmed state in both list and detail. Full action. - Messages —
/api/messages/exposes channel/conversation routers + per-message mutation endpoints, with no flat thread the mobile shell can compose against. Rows now pushMessageDetailScreen(read of the message) with a labeled desktop-handoff for reply/compose (§6.5). Read-only + handoff — no live flat reply endpoint to bind. - Teams — rows now push
TeamDetailScreenshowing the team’s member list from the live/api/teams/<id>/members/endpoint (TeamMemberRowResponse), plus description/manager. Member admin remains a labeled desktop-handoff. Real member list. - Customers/CRM —
CompanySerializer(“all”) has nostage/deal_value(stage lives on Deals, not Company), so there is no company stage-move action. Rows now pushCompanyDetailScreenwith company info (industry, city, website) and a labeled desktop-handoff for editing / stage moves (§6.5). Read-only + handoff — backend has no company stage field.
API model additions (ios/Lumi/Networking/APIClient.swift): AnnouncementResponse.acked: Bool?; new TeamMemberRowResponse (flat user_name/user_email/role from TeamMemberSerializer). New file ios/Lumi/UI/Workspace/CompanyGroupDetailScreens.swift holds the four detail screens + a reusable DesktopHandoffCard.
Rails: all copy localized via String(localized:) (Vietnamese-first per §6.3); primary action bottom-anchored, ≥48pt targets via LumiMetrics.minimumTapTarget, LumiKit components + tokens only. Build: xcodebuild build-for-testing (scheme Lumi, generic iOS Simulator) succeeds with zero error: lines.
2026-06-16 — iOS Workspace “Work” group completed (WRK-iOS)
The iOS Workspace › Work group (§6.2: Tasks · Projects · Recurring · Horizon) was partly list-only. Closed the gaps so every Work row reaches a real screen:
- Tasks — new
WorkspaceTaskListScreen(inios/Lumi/UI/Features/DetailScreens.swift), distinct from the Today tab. Lists all tasks newest-first via/api/tasks/?ordering=-created_at, has a bottom-reachable Create task toolbar action (reusesTaskEditSheet), and an inline ≥44pt circle toggle per row that completes/reopens viaPOST /api/tasks/{id}/complete|reopen/. Each row also pushes the existingTaskDetailScreen.MainTabViewWork “Tasks” row now opens this screen instead of redirecting to Today. - Recurring — new
RecurringTaskDetailScreen: read-only schedule (frequency, next/last run, start/end, owner, project, active/paused) plus a Run now action (POST /api/recurring-tasks/{id}/run-now/, the only mobile-bindable write the backend exposes) and a “Edit the schedule on desktop” handoff hint (§6.5). List rows inWorkspaceScreens.swiftnow navigate to it.RecurringTaskResponsewidened with description/dates/owner/project/team fields (all optional, snake_case-decoded). - Horizon — new
HorizonItemDetailScreen: read-only deadline detail (days-until with warning tint, kind, due date) + desktop-edit handoff. Horizon is a derived aggregate with no mobile-bindable write endpoint, so detail is read-only by design (§6.5). List rows now navigate to it.
All copy via String(localized:); LumiKit components + tokens only; ≥17pt body, ≥44pt targets, labeled icons, one bottom-anchored primary action per screen. Verified: xcodebuild build-for-testing → TEST BUILD SUCCEEDED, zero error: lines.
Intentional gaps: Recurring pause/resume not exposed (no backend toggle action — only run-now + standard PATCH, which is a desktop edit); Horizon write is desktop-only.
2026-06-16 — iOS StoreKit 2 in-app purchase flow (paywall completion)
Type: Feature
Summary:
- Replaced the non-functional iOS paywall (hardcoded wrong VND prices, dead “Pay with App Store” button) with a real StoreKit 2 purchase flow aligned to the canonical Lumi plans.
- New
ios/Lumi/Billing/StoreKitManager.swift(@MainActor @Observable): loads the sixcom.runlumi.{plan}_{period}products viaProduct.products(for:);purchase()handles.success(.verified), sends the signed transaction’sjwsRepresentationasreceipt_data+ product id +transaction.idtoPOST /api/billing/store/verify/, thentransaction.finish()and applies server-truth entitlement; restore viaAppStore.sync()+Transaction.currentEntitlements; user-cancel / pending / verify-failure outcomes per §9 (cancel returns to paywall intact; verify-failure keeps the transaction unfinished and offers a “payment received, activating…” retry). - Rewired
ios/Lumi/Billing/PaywallView.swift: plan cards driven byproduct.displayName+product.displayPrice(StoreKit localized price — never hardcoded), monthly/annual segmented toggle that hides when only one period loads (§12 open item), one bottom-anchored purchase button, working restore link, and the “Tôi có mã mời” invitation-code door (viaAPIClient.acceptInvitation). When StoreKit products fail to load it falls back to canonical plans fromGET /api/plans/with a clear “không thể kết nối App Store” message — never a blank wall. - New
ios/Lumi/Billing/SubscriptionScreen.swift: read-only plan/status with an in-app upgrade door presenting the paywall; non-billing members see who to contact. Wired intoMainTabViewAccount → Subscription row (was aTextstub). APIClient: addedfetchSubscription,fetchPlans(PublicPlanResponse),verifyStorePurchase,refreshEntitlement,applyEntitlement; added snake_caseCodingKeystoStoreVerifyRequest(shared encoder uses no key strategy).
Reason:
- The paywall was the app’s commercial door but its prices were wrong and the purchase button did nothing; F72 repurposes it as the in-app upgrade surface that must actually transact.
Impact:
- ios/Lumi/Billing/{StoreKitManager,PaywallView,SubscriptionScreen}.swift, ios/Lumi/Networking/APIClient.swift, ios/Lumi/UI/MainTabView.swift, ios/UnitTests/Gate/GateTests.swift (PaywallView init signature).
xcodebuild build-for-testing -scheme Lumisucceeds with zero errors. Full purchase exercise requires App Store Connect product/sandbox config (absent in CI); the/api/plans/fallback keeps the surface usable without it.
2026-06-16 — Android E2E instrumented test suite (ANE-e2e)
Type: Enhancement (testing infrastructure)
Added a full Android instrumented (androidTest) E2E suite covering the entire first-run journey and core action flows, plus a gated live smoke against the real dev backend.
New files (all under app/src/androidTest/kotlin/com/locuno/lumi/android/):
e2e/FakeLumiBackend.kt— configurable fake API backend (state holder with constructor params)e2e/LumiE2EHarness.kt— shared harness base with factory helpers for@BindValueoverridese2e/LiveBackend.kt— annotation for gating live-backend testse2e/SignUpCreateOrgFlowTest.kt— fresh user → register → org setup → create/join → Todaye2e/EntitlementPaywallFlowTest.kt— 3 classes proving active, lapsed-owner, and lapsed-member bootstrap states all enter the soft-gated Lumi shell after one server entitlement reade2e/OnboardingFlowTest.kt— fresh install (onboardingSeen=false) → onboarding renders → skip → Authe2e/WorkspaceBreadthActionsFlowTest.kt— active four-tab V2 shell + Work, Business, and Hub lane reachability + approve/reject action flowe2e/smoke/LiveLoginSmokeTest.kt— gated (@LiveBackend) API-layer smoke againstapi-dev.runlumi.app
Existing WorkspaceScreenTest migrated to LumiE2EHarness (no regression). @LiveBackend tests excluded from default connectedDevDebugAndroidTest via testInstrumentationRunnerArguments.notAnnotation in app/build.gradle.kts.
F30 §7 test plan updated with rows 20–21 matching the Android suite.
2026-06-15 — Pull-to-refresh on every scrollable data-backed iOS screen (PTR)
Type: Enhancement (UX consistency)
Per mobile-ux-patterns.md §2, .refreshable { await load() } (same loader as .task) was added to every scrollable, data-backed screen that lacked it; Today/Brief/Pulse already had it and remain the reference pattern.
Screens that gained pull-to-refresh:
Chat/ChatScreen.swift:ChatThreadListScreen(thread list →loadThreads()) andChatScreen(message threadScrollView→loadMessages()).MainTabView.swift:WorkspaceTabViewdirectory root (ScrollView→loadCounts()).Workspace/WorkspaceScreens.swift: all 11*ListScreens — Projects, Recurring, Horizon, Companies/Customers, Hub, Messages, Approvals, Decisions, Commitments, Risks, Teams (each →load()).Features/DetailScreens.swift:ProjectDetailScreen(fetches child tasks →loadTasks()).
Deliberately excluded (mobile-ux-patterns.md §2, inline comments added): Approval/Task/Commitment/Risk/Decision detail screens — the record is passed in fully and they fetch nothing on appear (no load() to re-run). Settings is a static form; its org list is read from already-loaded app state, not a per-screen server loader. Auth/Onboarding/Gate are transient surfaces.
Tests: UnitTests/Wedge/PullToRefreshTests.swift asserts TodayViewModel.loadAll() (the protocol-injected loader behind .refreshable) reaches loaded and is idempotent across a refresh (re-runs the loader, no no-op, stays loaded). View-only .refreshable wiring on the plain-View Workspace screens has no unit seam and is verified by compile + static review.
2026-06-15 — iOS Decodable model drift broke every data tab after login (IO fix)
Type: Bug Fix (user-facing)
Symptom: After a successful login every data tab showed a generic load error (“Unable to load”, “Could not load today’s brief/pulse”). Backend was healthy (all endpoints HTTP 200); the iOS Decodable response models had drifted from the real DRF JSON, so decoder.decode(...) threw and surfaced as a generic error.
Root causes (verified against serializers + live api-dev.runlumi.app bodies):
BriefResponse: required non-optionalid/items, used keysdate/summary; real placeholder is{status:"not_generated", brief_date}(no id/items), full body usesbrief_date/summary_text. →id/items/titleoptional;briefDate/summaryTextkeys.BriefItemResponse: useddetail/confidence/related_task; backend usesdescription/confidence_score/related_task_detail. → renamed; addedRelatedTaskSummary.PulseEntryResponse: requiredid, usedanswers; placeholder is{questions, date, status:"not_submitted"}(no id), full body usesanswers_json. →idoptional,answersJson, addedquestions; screen treatsnot_submitted/nil-id as compose state.PulseSubmitRequest.answers: was a dict; backend expects a positional[String]list. →[String].DecisionResponse.options: decodedoptions_jsonas objects; backend serializes a plain[String](threw when options present). →[String]/optionsJson;decided_by→decisionBy(UUID).RiskResponse.linkedTask: nested task keyedlinked_task; backend sendsrelated_taskas a UUID string. →relatedTask: String?.register(): decodedLoginResponse(non-optionalorganizations) but/api/auth/register/returns only{access,refresh,user}. → dedicatedRegisterResponse; register establishes a token-only session (empty orgs) → routes toOrganizationSetupView.fetchToday(): one atomictry awaitover 7 endpoints blanked the whole tab on any single failure. → per-section independent fetches; eachLoadablesection fails on its own.
Why detection missed it: unit decode tests asserted the (wrong) iOS shapes instead of real backend bodies; the live E2E assumed register returned an org and never fetched the brief.
Tests added/updated: decode tests now feed REAL placeholder + full bodies for brief/pulse and the register body; added a Today per-section resilience test; LiveBackendE2ETests rewritten for register→org-setup→brief(not_generated)→task→pulse, still gated behind LUMI_E2E. Verified via xcodebuild build-for-testing (iPhone 17 Pro Max) — TEST BUILD SUCCEEDED.
2026-06-15 — iOS decode whack-a-mole ended: paginated lists + FK-string contracts + diagnosable decoder (IO r07)
Type: Bug Fix (user-facing)
Symptom: On TestFlight build 101 the Workspace tabs (Projects, Recurring, Companies, Teams, Approvals, Decisions, Commitments, Risks), the Today task/announcement rows, and Chat still failed with the generic “data couldn’t be read because it isn’t in the correct format” decode error even after the r06 fix to Brief/Pulse/Decision/Risk. APIClient.request did a bare decoder.decode(T.self,…) with no context, so failures were undiagnosable.
Root causes (verified against openapi/schema.yaml + DRF serializers):
- Paginated lists decoded as bare arrays. Every list endpoint is a
ModelViewSetunder the defaultPageNumberPagination(PAGE_SIZE 50) → returns{count,next,previous,results}, but the Workspace screens +DetailScreenstask-by-project + Chat messages decoded[X]. The whole Workspace surface threw. → call sites now decodePaginatedResponse<X>and read.results. TaskResponse.project/assignedTowere nested objects.TaskListSerializer/TaskSerializersendproject(andowner) as UUID strings + flatproject_name/owner_name; there is noassigned_to. Decoding a string asProjectResponse/MemberResponsethrew for any populated task. →project/ownerareString?, addedprojectName/ownerName; UI reads the names.MemberResponse.userwas a nested object.OrganizationMemberSerializersendsuseras a UUID string + flatuser_email/user_name. →user: String?+userEmail/userName; Brief picker uses the name.TeamResponse.managernested +memberCount: Int.TeamSerializersendsmanageras a UUID string +manager_name, andmember_countas a string SerializerMethodField;/api/teams/is paginated. →manager/managerName: String?,memberCount: String?; the “Teams” screen now lists teams (was wrongly typed toTeamMemberResponse).RecurringTaskResponseusedtitle/status/next_due(none exist). Backend hastemplate_title,next_run_at,is_active(no status). The non-optionaltitle/statuswere absent → guaranteed throw. → real keys + computedtitle/nextDueshims.CompanyResponse.statusnon-optional butCompanySerializer(“all”) has nostatus/stage/deal_value→ threw. → optional; realindustry/cityadded.CommitmentResponse.amountCentsdoesn’t exist — backend sendsmoney_amount(string Decimal) +currency. →moneyAmount/currency.AnnouncementResponse.body— backend field isbody_md. →bodyMd+bodyshim.- Chat send + messages.
send_messagereturns{user_message, assistant_message}(decoded as one message → threw);messages/is paginated (decoded as array → threw). →ChatSendResponseenvelope; messages viaPaginatedResponse.ChatThreadcarrieslast_message {content,role}+updated_at, notlast_message_at. - Noted dead surfaces:
/api/horizon/(no route → 404) and/api/messages/(maps to channels/conversations router, no root list) — these surface ashttpError, not decode errors; models kept tolerant.
Decode-error surface: APIClient.request now wraps the final decode in do/catch; on DecodingError it throws APIError.decodeError(path:detail:) whose detail is the failing key/type + coding path (e.g. keyNotFound "status" at results[0]), captured to Sentry with the path tag. User copy stays friendly + localized with the diagnostic appended.
Why detection missed it: r06 decode tests + live E2E only covered Brief/Pulse/register and used synthetic shapes; no test fed a populated list body or asserted pagination, so the paginated-vs-array and FK-string mismatches shipped.
Tests added/updated: new UnitTests/Wedge/ContractDecodeTests.swift feeds REAL populated bodies (task w/ project, commitment w/ money_amount, risk w/ related_task, decision w/ options_json, announcement, approval, project, team, member, company, recurring, chat thread/messages/send) + asserts paginated decoding + APIClient.describe diagnostics; existing Task/Commitment decode tests corrected to the real contract; LiveBackendE2ETests extended to fan out Today + Workspace lists (projects/companies/teams/members) and assert no decode error. Verified via xcodebuild build-for-testing (iPhone 17 Pro Max, /tmp/lumi-decode-dd) — TEST BUILD SUCCEEDED. Sim execution not run (host sim hangs).
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 “Keep”+“Build” sections) → §11 (8-row table); §13 Open Questions → §12; §14 Bug/Change Log → §13.
- §11 audit: 8 rows, 3 ✅, 2 partial (gate, iOS nav), 3 gaps (senior invariants, Rule of Two, full breadth).
2026-06-11 — Contract upgrade: full coverage, five-screen onboarding, hard paywall with invite-code door
Type: Documentation Change (scope reversal)
Summary:
- Reversed the same-day “no onboarding/paywall/invite-code” decision: mobile is now Lumi’s commercial front door with a five-screen onboarding story and a hard paywall (native store billing) with invitation-code bypass.
- Reversed the “control loop only” narrowing: every web feature now has a mobile home via the five-tier support matrix (Core tab / Workspace / Detail / Context-only / Desktop-handoff).
- Added the “Calm Core, Full Reach” navigation contract (five fixed tabs + Workspace directory + Rule of Two) and merge-blocking senior-usability invariants (§6.3).
- Declared two backend deltas honestly: F02 short invitation codes, F24 store receipt verification. Entitlement is server truth; local entitlement flags remain forbidden.
- Rewrote test plan (16 cases) including store-sandbox purchase, code redemption, Rule-of-Two audit, and senior-invariant audit.
Reason:
- Product direction: mobile must carry the full product for a phone-primary market and monetize at the door, while staying usable by a fifty-year-old non-technical CEO. The earlier narrow contract optimized for build speed, not for the actual user and business model.
Impact:
- P13 execution plan re-sequenced; mobile-onboarding.md rewritten as the onboarding/paywall contract; platform inventories updated; F02/F24 gain mobile-driven deltas.
Tests:
- No code changed in this documentation pass. New platform/backend tests specified in §7.
2026-06-11 — Reset mobile contract around support tiers and wedge parity (superseded same day by the entry above)
Type: Documentation Change
Summary:
- Rewrote F30 from a broad “desktop parity on phones” draft into a support-tier contract tied to the executive control loop.
- Removed paywall/subscription/invitation-code product language from the supported scope.
- Added required parity for approvals, brief actions, and Hub reading.
- Replaced the false “not built” statement with an honest current-behavior audit.
Reason:
- The previous spec mixed product contract, migration notes, and legacy WTM assumptions, which made implementation and audit ambiguous.
Impact:
- Established the support-tier framing and honest audit that the upgraded contract still builds on. Its paywall/onboarding exclusions are superseded; its session/tenancy/wedge-parity requirements are carried forward unchanged.
2026-06-11 — Legacy gating cleanup: state models, dead code, spec update
Type: Behavior Change + Spec Update
Summary:
- Stripped all dead legacy gating from Android state models, bootstrap, and invite-code infrastructure.
- Deleted uncompilable iOS test files (PaywallModelTests, SubscriptionStoreTests, OnboardingViewTests) that referenced deleted types.
- Deleted Android old-shell files (WtmApp, WtmAppState, old TopLevelNavItem) — replaced by LumiApp/LumiAppState/LumiTopLevelNavItem.
- Removed dead Android invite-code API surface (AcceptInviteNavKey, acceptInvite endpoint, InviteRequest DTO, pendingInvitationCode/invitationCodeUsed prefs).
- Removed dead UserData fields (shouldHideOnboarding, hasActiveSubscription, invitationCodeUsed) from the full Android cascade: model → repository interface → OfflineFirstUserDataRepository → LumiPreferencesDataSource → UserPreferences proto → TestUserDataRepository.
- Removed dead paywall/onboarding string resources (14 keys × 11 locales) from Android strings.xml.
- Fixed iOS AcceptInviteView to use dedicated acceptInvitation() method instead of generic post.
- Updated the routing contract to: loading → auth → main (no intermediate gates).
- Onboarding and paywall remain planned features — their source files stay as placeholders.
Reason:
- Old onboarding/paywall/invite-code implementations used a different UX, API, and state model than what the spec requires. The planned re-implementations will use their own state; these corpses only caused compile failures and confusion.
Impact:
- Clean build on Android with no dead-field references; iOS tests compile; routing contract honest. This cleanup is the foundation for onboarding/paywall v2 — the new gate is built on entitlement server truth, not on resurrecting the deleted local flags.
Notes:
AcceptInviteViewon iOS remains active — it’s the org-join deep-link flow (now joined by the typed-code flow in this contract).DocumentType.SUBSCRIPTION_OR_RENEWALis untouched (AI document triage, not billing).
2026-06-12 — Android gate moved onto server truth
Type: Feature Hardening
Summary:
- Android
MainActivitynow branches through onboarding, auth, organization setup, paywall, contact-owner, and main shell using server entitlement state instead of dropping authenticated users straight into the app. - Added Android-side subscription response models, entitlement gate state, and no-org/onboarding persistence needed for the route contract.
- Removed the Android paywall’s local access bypass: purchase launch no longer calls the success path directly, and invitation-code join now refreshes session/org state before re-checking entitlement.
Reason:
- The 2026-06-11 audit’s remaining real finding was that mobile still behaved like a shell in places where the F30 contract demanded a real gate.
Impact:
- Android now matches the F30 gate contract materially better, though Workspace breadth and full paywall completion are still pending.
Tests:
cd android && GRADLE_USER_HOME=$PWD/.gradle-local ./gradlew :app:testDebugUnitTest --tests 'com.locuno.lumi.android.MainActivityEntryDestinationTest' --tests 'com.locuno.lumi.android.GateEntitlementTests' --tests 'com.locuno.lumi.android.WedgeTests'
Notes:
- This does not flip F30 to shipped. It closes a specific gate-state finding while keeping the registry unshipped.
2026-06-12 — Test catalog PR-0a corrected: all 19 invitation short_code tests verified ✅
Type: Documentation Change
Summary:
- The
docs/test-plan/f30-mobile.mdcatalog listed 9 missing + 2 partial invitation short_code tests (T002/T003/T004/T007/T008/T009/T010/T013/T014/T016/T017/T019). - All 19 tests already exist in code and pass (
test_multitenancy.py::TestInvitationLinks+test_billing.py::TestInvitationShortCodeHelpers). The catalog was stale from a prior fleet round. - Updated every T001–T019 row to ✅ with correct file locations, set PR-0a summary to “0 missing, 0 partial”, updated master summary (26 ✅, 4 ⚠️, 183 ❌), and marked PR-0a done in execution order.
Reason:
- Catalog drift — tests were written in a prior round but the index was never updated. A stale catalog is a bug per docs/ENGINEERING.md.
Impact:
- test-plan/f30-mobile.md, implementation-audit/2026-06-12-full-repo-audit.md F30 section added.
2026-06-30 — Audit language corrected: mobile shell/F90 breadth are further along than the old F30 summary said
Type: Documentation Change
Summary:
- Updated the F30 implementation audit to reflect the current repo truth after the late-June F79/F90 mobile work.
- Marked F79 lane ownership as matched now that both shells ship the five-lane order plus the top rail.
- Upgraded the Rule-of-Two audit from “iOS only” to “iOS + Android unit-proven” using the current
RuleOfTwoTests,WorkspaceDirectoryTests,WorkBreadthTests, andFieldExecutionRoutingTests. - Rewrote the full-breadth summary so it no longer implies Android is missing the core Workspace/F90 product shape.
Reason:
- The older F30 audit was directionally useful when Android breadth and F90 routing were still moving, but it became stale once the repo gained stronger unit proof. Leaving that wording in place would push product decisions toward the false problem of “missing screens” instead of the real remaining gaps: billing/runtime closeout and broader actor-flow verification.
Impact:
- F30 now describes the current mobile state more honestly without overclaiming completion.
2026-06-30 — Org lifecycle callback fixed; docs move from “logout bug” to “runtime-proof gap”
Type: Documentation Change
Summary:
- The shared org-management callback now carries the preferred org id back into the Android/iOS shells so create/join/leave/delete refresh membership in-app instead of logging the user out.
- Tightened the linked mobile inventories/test catalog so they now point at the real remaining gap: live runtime/bootstrap proof, not missing shell transition behavior.
Reason:
- The repo already had real shared org-management UI and screen-model proof, but the old logout/reset callback made the product behavior worse than the docs implied. After fixing the callback contract, the docs needed to stop pointing at the old bug and instead describe the remaining verification gap honestly.
Impact:
- F30 and its linked mobile docs now describe account/org lifecycle more truthfully: shell behavior is fixed, but broader live-flow verification still remains before we can call the mobile lifecycle fully closed.
2026-06-30 — Workspace badge counts hardened to open/pending semantics
Type: Bug Fix
Summary:
- Tightened the shared mobile Workspace badge repository so it no longer counts raw table size for Tasks / Approvals / Decisions / Commitments / Risks.
- Workspace badges now summarize user-relevant open work: approvals use
queue=me, decisions / commitments / risks usestatus=open, and the task badge sumstodo + in_progress + waitinginstead of including completed/cancelled work. - Added shared unit proof for the exact badge-query contract and its graceful per-endpoint failure behavior.
Reason:
- The F30 Workspace directory promises live counts that help a mobile user decide where to tap next. Counting closed work or already-resolved approvals makes the shell look live while still telling the wrong story.
Impact:
- Shared iOS/Android Workspace badges are now materially closer to the intended operating signal, while broader rendered/runtime proof still remains tracked in the F30 test catalog.
2026-07-01 — iOS paywall verify-failure escalation now has a real support path
Type: Bug Fix
Summary:
- Tightened the iOS StoreKit recovery path so repeated post-charge server-verify failures are counted, not endlessly flattened into the same generic retry error.
StoreKitManager.swiftnow preserves the pending paid payload, increments a consecutive verify-failure counter, and flips a support-escalation state after the third failed activation retry.PaywallView.swiftnow surfaces that state with clearer copy plus a concretemailto:support@runlumi.appaffordance while keeping the existing retry CTA, restore, invitation-code door, and legal links.ios/UnitTests/Gate/GateTests.swiftnow directly proves the threshold behavior and guards the support link against drifting into forbidden web-checkout providers.
Reason:
- Android already had explicit “charged but not yet activated” retry/support proof, but iOS could still leave a paid customer repeating the same retry loop with no escalation path. That is product debt on one of the highest-trust flows in the app.
Impact:
- F30’s “never strand a charged user silently” contract is now materially truer on iOS without inventing any local entitlement shortcut or web checkout path. Remaining work is still live store-sandbox/runtime verification, not missing recovery behavior.
2026-07-10 — SettingsScreen language picker: ExposedDropdownMenu → LumiDropdownField + CI guard
Type: Bug fix + Guard Pattern (CMP crash prevention)
Summary:
SettingsScreen.kt(commonMain) was usingExposedDropdownMenuBox+OutlinedTextFieldExposedDropdownMenufor the language picker.ExposedDropdownMenuuses the same Skiko popup code path that crashes on Android CMP withNoClassDefFoundError: SkikoMenu_skikoKt— the exact class of crash thatLumiDropdownFieldwas built to replace. The file comment saying “ExposedDropdownMenu is the safe KMP path” was incorrect.
LumiDropdownFieldwas already imported on line 62 (the conversion was started but not completed). Replaced the 35-lineExposedDropdownMenuBoxblock with a 6-lineLumiDropdownField(label, selectedDisplay, options, onSelect, modifier)call.- Removed 4 banned raw Material3 imports and the
showLanguageMenustate variable. - Added a new guard test to
MobileUxInvariantsTest.kt(jvmTest):Screen files must not use ExposedDropdownMenu or raw OutlinedTextField — use LumiDropdownField. Guard fires on import lines (not content) so comments explaining the rationale don’t produce false positives.
Impact:
- Both Android and iOS shells receive the fix (SettingsScreen is commonMain).
MobileUxInvariantsTest.ktnow has 8 CI-enforced guards covering the mobile UX contract.
2026-07-01 — Negative-audit contract corrected: forbid local unlock debt, not server-truth freemium states
Type: Documentation Change
Summary:
- Corrected the stale PR-6 negative-audit language so it no longer claims “no trial/freemium branch” after F72 made freemium + server-truth
trialinga real part of the product contract. - Tightened the hardening rows around the real risk instead: no local entitlement unlock path, no native web-checkout escape hatch, and no sixth permanent tab.
- Added direct native proof on both platforms for those three invariants, instead of leaving placeholder “rg audit” tests in the iOS hardening suite.
Reason:
- The old wording would have pushed future work toward deleting legitimate server statuses (
trialing, free-tier entry) instead of protecting the real invariant: mobile must never secretly unlock itself locally or drift into a web checkout maze.
Impact:
- F30’s hardening catalog is now aligned with the actual post-F72 product contract and the current native codebase, reducing the chance that future mobile billing work reintroduces the wrong debt.
2026-07-02 — Spec drift fix: 12 confirmed-shipped ACs checked in §2
Type: Documentation
Summary:
- 12 of 19
[ ]ACs in §2 flipped to[x]— confirmed shipped by code inspection (AppState.swift, AppEnvironment.swift, GateScreens.swift, PaywallView.swift, BillingAlertBanner.kt, PulseLaneScreen.kt, WorkspaceTests.kt, RuleOfTwoTests.swift). - 7 ACs remain
[ ]— genuine pending gaps or pending runtime verification. - No code changes.
2026-07-01 — KMP onboarding migration: 5-screen carousel moved to commonMain; iOS Skip gap closed (F30 §2)
Type: Feature / Shared-code coverage improvement
Summary:
- Deleted duplicate native onboarding implementations (
android/feature/scan/impl/.../OnboardingScreen.kt176 lines,ios/Lumi/UI/Onboarding/OnboardingView.swift148 lines) — 324 lines of copy-pasted content replaced by a singlecommonMainsource. - Added
android/shared/src/commonMain/kotlin/.../ui/onboarding/OnboardingScreen.kt: 5-pageHorizontalPagercarousel withLumiPrimaryButtonNext/Get-started and an unconditionalTextButtonSkip (F30 §2 “Next and Skip both visible” contract). - Added
android/shared/src/commonTest/kotlin/.../ui/onboarding/OnboardingPageDataTest.kt: 4 tests — page count = 5, unique symbols, non-empty symbols, valid index range. - Added 14 EN + 14 VI string keys to the shared
composeResourcesstring files (onboarding_next,onboarding_skip,onboarding_get_started,onboarding_icon_desc,onboarding_screen{1–5}_{title,body}). - Added
makeOnboardingViewController(onComplete:)factory toMainViewController.kt(no API client — pre-auth static content). - iOS shell: added
.onboardingcase toSharedScreenenum inSharedScreenBridge.swift; routed intomakeOnboardingViewController; replacedOnboardingView(...)inAppEnvironment.swiftwithKMPComposeView(screen: .onboarding, onCreated: { completeOnboarding() }). - Android shell: updated
MainActivity.ktimport to point at the shared package; call site unchanged. - §2 acceptance criteria checked: both previously-open onboarding checkboxes are now
[x].
Reason:
- F30 §2 required “Next and Skip both visible” on every onboarding page. iOS was missing the Skip button. Android had it. KMP migration closed the gap on both platforms simultaneously while eliminating 324 lines of duplicated content. The iOS-native
LanguageManagermenu is intentionally dropped — language selection belongs in Settings.
Impact:
- Shared-code coverage: +1 surface into
commonMain(onboarding now built once, mounted twice). - F30 §2 acceptance criterion:
[ ] → [x]on both checkboxes. - Dead native code: 324 lines removed across two shells.
2026-07-13 — #1891: native store recovery path and sandbox evidence
Type: Billing recovery guard + manual store evidence checklist
Summary:
- Android now acknowledges a Google Play purchase only after
POST /api/billing/store/verify/succeeds and server entitlements have been applied. A verification failure leaves the purchase token retryable and does not acknowledge a local callback as access. - Added focused Android regression assertions for purchase, restore, verification failure, and retry acknowledgement; the existing iOS StoreKit product/recovery guards remain the smallest automatable floor.
- Added
docs/mobile/store-sandbox-validation.mdwith the exact iOS Sandbox / Android test-track sequence and a redacted evidence template for product IDs, server state, client state, timestamps, and build SHA.
Honest gate:
- No App Store Connect Sandbox tester or Google Play test-track device is available
in this workspace, so the real purchase, restore, and forced post-payment recovery
rows remain ⚠️ until named-device evidence is attached. Unit tests and the
/api/plans/fallback are not treated as a substitute.
2026-07-13 — #1892: cross-platform CEO actor-flow gate
Type: Live runtime test + evidence workflow
Summary:
- Added the same live actor flow to both shells: authenticate, select an organization, submit Pulse, create/complete/reopen/read a task, read Brief state, switch organization, and assert business-object isolation.
- Added an explicit expired-token assertion so the auth failure remains a recoverable unauthorized state instead of silently entering the shell.
- Added a small Maestro shell smoke for both platforms that verifies the real api-dev app reaches Pulse, organization switching, and Work, then keeps the auth form after a bad credential.
- Added workflow_dispatch-only
.github/workflows/mobile-actor-flow.yml. It runs the Android instrumentation test and iOS XCTest againsthttps://api-dev.runlumi.appwithout changing deterministic CI defaults, and uploads compact evidence containing platform, device, build SHA, backend target, and result.
Honest gate:
- The workflow is intentionally opt-in and has not been run on this host because no Android device/AVD is available; a successful dispatch is the required named-device evidence before marking the broader actor-flow row complete.
2026-07-15 — SYS-05: make emulator accessibility evidence non-scoreable by construction
Type: Test infrastructure and evidence-boundary correction
Summary:
- The workflow artifact now records
Evidence class: emulator (UI-only)andScoreable on named device: falseinstead of presenting an emulator profile as a device result. - The accessibility checklist and T192 test-plan entry state that CI output cannot replace the PM’s same-day Android floor-device and iPhone 12 VoiceOver/TalkBack walkthrough.
- Added
scripts/tests/test-mobile-accessibility-evidence-boundary.shas a regression guard for the generated artifact wording.
Honest gate:
- This makes the evidence classification truthful; it does not create named-device evidence or change the PM-owned SYS-05 score.
2026-07-15 — SYS-04: add bounded resource-use capture protocol
Type: Test infrastructure and evidence protocol
Summary:
- Added
scripts/run-sys04-resource-proof.shto capture before/after battery, network, and power snapshots around one bounded Android observation window. - Added a fake-ADB self-test and a test plan covering target validation, emulator/non-scoreable classification, reset denial, and retained raw files.
- The manifest always reports
measurement_verdict: manual-review-required; it cannot turn simulator or raw dumpsys output into a PM score.
Honest gate:
- Named Android floor execution, iPhone 12 evidence, constrained-network behavior, app-size review, and human wake-lock/polling review remain open.
2026-07-16 — SYS-04: bind resource evidence to the connected target
Type: Test infrastructure and evidence-boundary correction
The resource-proof runner now reads Android’s ro.kernel.qemu marker before
installation and rejects a device-class claim that contradicts the connected
target. It records the marker and derived emulator status in the manifest, so an
emulator cannot be mislabeled as scoreable floor-device evidence. PM-owned
resource scoring and all named-device gates remain unchanged.
2026-07-16 — SYS-01: bind cold-start evidence to the connected target
Type: Test infrastructure and evidence-boundary correction
The cold-start runner now reads Android’s ro.kernel.qemu marker before
installation and rejects a device-class claim that contradicts the connected
target. It records the marker and derived emulator status in the manifest, so
an emulator cannot be mislabeled as scoreable floor-device timing. PM-owned
cold-start scoring and all named-device gates remain unchanged.
2026-07-13 — #1893: device accessibility validation path
Type: Test infrastructure + evidence artifact
Summary:
- Added a dedicated
accessibilityroute set for the CEO operating loop: Pulse/Today, Work → task action, Brief, Settings/organization, and Risks. - The Maestro workflow now applies Android
font_scale=2.0and restores the setting with an exit trap; the existing iOSAccessibilityExtraExtraExtraLargesetting remains scoped to the suite. - Each accessibility run writes and uploads a Markdown checklist containing the build SHA, named device, OS/runtime, text-scale setting, JUnit results, screenshots, and the manual VoiceOver/TalkBack sign-off fields.
- Android’s Workspace instrumentation now fails when a clickable merged semantics node exposes neither text nor a content description.
Honest gate:
- The repository now has a repeatable device-validation path and narrow automated guards, but a human VoiceOver/TalkBack sweep must still be signed in the uploaded checklist. The F30 senior-usability row stays partial until that named-device evidence exists; no unit test is treated as a substitute.
2026-07-15 — ACCT-05: Android Audit Log restored to the five-tab Settings path
Type: Bug fix (Android Audit Log route became unreachable after More left the primary shell)
Summary:
- The shared Settings screen already owned the role-gated Audit Log row, and the Android
destination remained registered, but
LumiAppAccountEntries.ktdid not pass the navigation callback. After More became a legacy-only surface, owner/admin users had no reachable path. - Android now wires
SettingsScreen.onOpenAuditLogtoAuditLogNavKey. The sharedisOwnerOrAdmingate remains the single visibility rule, so members do not receive the row. - A focused Android source invariant and Contract Graph policy pin the callback and role gate. The existing ACCT-05 Maestro flow remains the runtime owner/member proof.
- Local emulator proof does not move the PM-owned audit score. Named Samsung A1x coverage, Vietnamese locale, and empty/error/refresh states remain explicit gaps.
2026-07-12 — ACCT-05: iOS Audit Log reachable via Settings (was disconnected route)
Type: Bug fix (iOS Audit Log route disconnected; no entry point on iOS)
Summary:
- The shared
AuditLogScreenhad no reachable entry on iOS. Android exposed it via More → Audit Log at that time; iOS had.auditLoginSharedScreenandWorkspaceViewControllers.makeAuditLogViewControllerbut no caller wired the route. - Fixed by adding
onOpenAuditLogcallback +orgRoleparameter toSettingsScreen.kt(shared KMP), gating the Audit Log row visibility to owner/admin only. - Wired through
WorkspaceViewControllers.kt→SharedScreenBridge.swift→MainTabView.swiftso tapping the row in iOS Settings pushes.auditLogand back navigation returns to Settings. - Stale comments claiming “owner/admin/manager” access corrected to “owner/admin” in all touched files.
- Maestro
52_audit_log_screen.yamlcorrected: owner path is now Workspace → Settings → Audit Log; member path enters Settings and asserts no Audit Log row. - F30-mobile.md: F16 row moved from Desktop-handoff to Workspace; §6.5 stale “genuine remaining gaps” updated.
Tests:
- Settings screen reuses the existing canonical
isOwnerOrAdminhelper fromFieldExecutionSupport(no new helper extracted; the canonical already tests its own behavior). .maestro/critical/52_audit_log_screen.yamlupdated.
2026-06-10 — Initial spec
Type: Feature
Summary:
- Created the first draft of F30 for a Lumi mobile companion.
Reason:
- Transform existing WTM codebases into Lumi mobile clients.
Impact:
- Established the initial mobile lane, later narrowed on 2026-06-11, then upgraded to the full-coverage commercial contract the same day.
2026-07-18 — Android shared text-input IME contract
- Android login fields expose
NextthenDone;Donesubmits only when the credentials are valid enough to enable the primary action. - Contact and custom-mission editors advance focus in visual order instead of trapping Return on the current field. The Settings viewport must keep the newly focused custom-mission Prompt visible above the keyboard. Final fields dismiss the keyboard or invoke their existing save action only when that action is enabled.
- The assistant composer expands to multiple lines and uses the IME send action; blank drafts remain non-sendable.
- Critical Maestro flow
77_mobile_input_contract.yamlguards the cross-surface behaviors; focused flow90_android_mission_prompt_ime_visibility.yamlguards Prompt visibility after Mission Name → Next.
2026-08-05 — Android auth entry stays calm; Assistant remains text-first
- The current F01 auth contract owns the native doorway: Login and registration stay unfocused with the keyboard closed until the user deliberately chooses a field. This prevents credential AutoFill and the IME from racing the form into a half-height viewport.
- A newly created Assistant thread still carries an explicit one-shot composer-focus intent. Opening an existing thread remains read-first and does not summon the keyboard over conversation history.
- F01 flow
120_android_auth_calm_entry.yamlowns the complete cold auth doorway and deliberate-tap proof. Critical flow105_android_text_first_autofocus.yamlstarts with the intentional Login field handoff, then separately types into the newly created Assistant composer without tapping it. Emulator proof does not replace named-device evidence.
Decision record:
- Collision: this historical F30 entry said Cold Login should request focus, while the authoritative F01 auth spec and current shared source require native auth to wait for deliberate input.
- Authority: F01 owns authentication behavior; the explicit current Snow Leopard request also prioritizes a calm, credential-manager-friendly doorway.
- Evidence:
AuthScreenSourceTest.authDoesNotOpenTheKeyboardOrStealFocusOnEntryand F01 flow120_android_auth_calm_entry.yamlprove the auth contract; the old flow’sinputTextstep did not prove a served input connection. - Decision: remove Login autofocus from F30’s promise, keep complete auth proof in F01, and retain initial focus only for newly created Assistant threads.
- Rejected: restoring Login autofocus would reintroduce the keyboard race that the auth fix was designed to remove.
- Verification:
python3 -m unittest scripts.tests.test_android_mobile_input_policyplus the registered F01 auth policy and runtime lifecycle gates. - Follow-up: named Android execution remains open under
F30.GAP009and F01 auth gaps.
2026-07-19 — CRM Company forms follow visual IME order
- Create and Edit Company expose
Nextfor Name, Industry, Website, Tax code, and Address, thenDonefor City. EachNextmoves to the next visible field;Doneclears focus without saving. - Stable field tags cover all six inputs on both forms. Critical flow
96_crm_company_ime_next.yamlenters distinct values through the keyboard on Create Company and stops before Save, so regression proof cannot mutate CRM data. - Pixel 7 API 35 exact-APK proof passed with matching built/installed SHA-256
bd77f03f77fa41412cc308792577c2d4d8b75bcceef78509bc20eb630cc23310. Named-device and runtime Edit Company proof remain open.
2026-07-19 — Edit Contact matches the Create keyboard contract
- Populated Edit Contact text fields follow the Name → Title → Phone → Email
→ Zalo order. The first four fields use
Next; Zalo usesDoneto clear focus without saving. - Title, Phone, Email, and Zalo expose stable edit-form tags. Phone and Email retain their platform keyboard types.
- Critical flow
97_edit_contact_ime_next.yamlinserts a unique prefix into every prefilled field after pressing Return and stops before Save. The disposable-tenant runner therefore proves focus without mutating CRM data. - Pixel 7 API 35 exact-APK proof passed with matching built/installed SHA-256
6e0529f6c2a444459be8ffffe707547ab33d87761e3f2288d23f51ee02b78416. - The isolated iOS runner passed the same populated visual-order traversal on
one iOS 26.4 simulator using installed build 151 SHA-256
073f0e03face451487f97b70e3fb76bf5ddae79cc2a0049f34dd7d32b71022b0. Stable Contact detail action tags prevent contact data from stealing Edit or Delete automation taps. Named-device proof remains open.
2026-07-18 — Android routed screens own the IME inset once
- The authenticated shell applies only horizontal and top safe-drawing insets. Bottom navigation and every routed form/composer retain their existing local bottom/IME ownership.
- This prevents the assistant composer and Settings editors from being pushed hundreds of pixels above the keyboard while keeping status-bar safety.
- Critical Maestro flow
78_android_chat_ime_docking.yamlretains the runtime keyboard-open frame;ShellImeInsetOwnershipSourceTestenforces the root seam.
2026-07-18 — Android keyboard adjacency correction
MainActivitymust declare a non-panning soft-input policy. Compose remains the sole keyboard-inset owner; Android must not additionally pan the activity toward a focused composer.- With the IME visible, the Assistant and Messages input fields remain adjacent to the keyboard with only their intentional composer-row spacing. A duplicate keyboard-height gap is a release-blocking regression.
- Runtime proof must compare the input field’s bottom bound with the visible IME top bound. Visibility alone is insufficient because an input can remain visible while sitting hundreds of pixels above the keyboard.
2026-07-18 — Android Assistant primary action has a spoken purpose
- The Assistant new-conversation FAB exposes “Start a new conversation” on its clickable semantics root in English and Vietnamese. Assistive technology no longer has to infer the action from a decorative plus glyph.
LumiFABaccepts an optional action-specific description while retaining the generic localized create description for existing callers.- Critical Maestro flow
80_android_assistant_fab_accessibility.yamlresolves and activates the spoken action. Human TalkBack proof on the named Android floor device remains an explicit Contract Graph gap.
2026-07-18 — iOS keyboard viewport has one platform owner
- SwiftUI preserves automatic keyboard avoidance for every hosted KMP controller, including the shared wrapper and the bespoke Task Detail and Pulse hosts.
- Full-bleed iOS shell modifiers ignore
.containeronly. They must never suppress.keyboard, because Compose root modifiers did not replace the missing host viewport on the audited iOS 26.4 runtime. - Shared Compose screens retain
imePadding()and explicit safe-area handling for Android’s edge-to-edge activity. The Assistant lane-bottom helper excludes IME from additional padding so it cannot create a second keyboard-height band. - With the iPhone keyboard visible, the full text composer and multiline draft remain reviewable directly above the keyboard. A keyboard-height blank or transparent band, or a composer that is accessibility-visible but outside the rendered viewport, is a release-blocking regression.
- Signed simulator proof is UI evidence only. Named iPhone runtime proof remains required before this criterion can change a device-audit score.
2026-07-20 — iOS shared screens must not consume the host-owned IME twice
Type: Bug Fix (user-facing regression)
Summary:
- TestFlight build 150 showed a keyboard-height blank band on Create Task, and the same shared root pattern exists across keyboard-capable KMP screens.
- SwiftUI remains the single iOS keyboard viewport owner. Shared screens must use
lumiImePadding(): Android consumesWindowInsets.ime, while iOS adds no second inset after SwiftUI has already reduced the hosted controller.
Reason:
- Retaining direct common
imePadding()after restoring SwiftUI keyboard avoidance made the two correct platform mechanisms cumulative on the current iOS runtime.
Impact:
- Android keeps its required edge-to-edge IME handling. iOS forms and composers no longer reserve an extra transparent keyboard-height region.
Tests:
SharedKeyboardInsetPolicyTestsrejects direct sharedimePadding()consumers and every direct commonMain IME API, pins the Android/iOS actual implementations, and requires the registeredCommonImeOwnershipcustom lint rule.- Main-branch CI runs that lint rule and the cross-platform policy suite. The iOS release script repeats the source and geometry contracts before build-number mutation.
verify_mobile_keyboard_clearance.pyfails when the settled action-to-IME boundary gap exceeds 24 points;run-ios-keyboard-clearance-proof.shbinds it to flow 104.- Critical flow
104_ios_create_task_ime_clearance.yamlmust retain a settled frame containing the edited task title, Create action, and keyboard with no duplicate band.
Notes:
- Named-device execution remains separate proof; simulator semantics alone cannot close the visual regression.
2026-07-18 — iOS pinned forms inherit the keyboard-reduced host viewport
- A form-specific
Surfaceexperiment was disproved by exact signed screenshots: Create and Edit Company actions remained behind the keyboard while SwiftUI avoidance was suppressed. The systemic boundary is the iOS KMP host, not a particular Compose container. - Runtime proof must show the edited field value, wait for keyboard settlement, and retain a screenshot containing both the keyboard and pinned action. Accessibility visibility without rendered geometry is not acceptance proof.
- The signed simulator matrix covers the shared wrapper with Assistant, Messages, Create Company, and Edit Company, plus the bespoke Task Detail and Pulse hosts. This is cross-host UI evidence, not named-device or every-feature runtime proof.
2026-07-18 — Mobile Assistant uses editable on-device dictation
- The Assistant composer is one compact row: editable multiline input, microphone, then Send. The previous full-width hold-to-record strip is not the mobile entry path.
- Tapping the microphone starts the phone’s on-device speech recognizer. Partial and final text replace only the active dictation segment; existing draft text is preserved.
- Recognition never sends. The member can edit the transcript and must tap Send or use IME Send before the ordinary text message API receives it. Raw microphone audio is not uploaded to Lumi through this composer.
- Android requires API 31+
createOnDeviceSpeechRecognizerand fails closed if the device has no installed on-device recognizer. iOS requiressupportsOnDeviceRecognitionand setsrequiresOnDeviceRecognition = true. - Both shells derive recognition from Lumi’s active app-language tag. Android does not fall back to the process/device locale while an account language is selected.
- Starting a new session invalidates delayed callbacks from the previous session. Leaving the iOS chat host cancels recognition and releases its audio session.
- Permission denial, missing language models, no-speech, and recognition failure keep the draft and show an inline recoverable state. There is no silent server-ASR fallback.
- Browser voice notes and explicit audio uploads remain governed by F87/F91 P0d; this mobile dictation path is deliberately distinct.
- JVM/source/compile proof does not establish live recognition. English and Vietnamese
named-device transcription, permissions, low-cost Android availability, and geometry
remain the blocking
F30.GAP014runtime matrix.
2026-08-03 — Android dictation reports missing language models as unavailable
Type: Bug Fix (Android / Assistant / Dictation / Recovery UX)
- The Android availability probe is service-wide and does not guarantee that the selected
account language has an installed on-device model. Android can report that distinction as
ERROR_LANGUAGE_NOT_SUPPORTED,ERROR_LANGUAGE_UNAVAILABLE,ERROR_CANNOT_CHECK_SUPPORT, orERROR_SERVER_DISCONNECTED. - Those support failures now map to Lumi’s explicit unavailable state, which preserves the draft and renders the localized recoverable status copy. They no longer fall through to the generic unexpected-error message.
- The Android recognizer boundary also converts synchronous startup failures into the same
recoverable callback contract:
SecurityExceptionremains permission-denied, whileIllegalStateExceptionandUnsupportedOperationExceptionremain explicit unavailable states. The adapter destroys the failed recognizer and never leaks the exception into the mobile surface; unknown startup failures remain diagnostic unexpected errors. AndroidOnDeviceSpeechRecognizerTestpins the support-error mapping and keeps permission, startup, no-speech, audio, and unknown-error categories distinct. This is source/JVM proof only;F30.GAP014still requires live English/Vietnamese model and microphone evidence on named devices.
2026-07-18 — Assistant thread Back affordance preserves title space
- The shared Assistant thread header uses the conventional auto-mirrored Back icon instead of rendering the localized Back label beside the thread title.
- The localized
Back/Quay lạistring remains the control’s accessible name for VoiceOver and TalkBack. The icon lives in a 48dp target and keeps the existingonBackcallback on both shells. - Long thread titles receive the reclaimed header width and continue to truncate to one line.
- Source and automation proof do not replace a named-device VoiceOver/TalkBack announcement check.
2026-07-18 — Assistant home prioritizes conversation and Daily Pulse
- Tapping the Assistant home FAB opens one compact menu with exactly two actions:
Ask LumiandSubmit Daily Pulse. Ask Lumipreserves the F91 nameless-thread contract and opens the editable composer.Submit Daily Pulseroutes to the existing F07/F83 composer in the owning native navigation stack; it does not create a second Pulse form.- The shared KMP menu uses localized English/Vietnamese copy, stable semantic selectors, 48dp rows, outside-tap dismissal, and a close-state FAB label.
- Android routes through
DailyPulseNavKey; iOS appends.dailyPulseto the Assistant navigation stack. Source and Maestro proof do not replace named-device TalkBack/VoiceOver, large-text, and route execution evidence.
2026-07-18 — Android shared dialogs own the first system Back press
LumiDialogis an in-window Compose overlay, not a platformDialog; while visible on Android, it registers a platform Back handler that invokes the same dismiss callback as its scrim and explicit close action.- One Back press dismisses the active dialog and preserves the routed screen and scroll context. Only a later Back press may pop the Navigation3 route.
- Critical flow
92_android_constitution_dialog_back_stack.yamlguards Constitution → Settings → Workspace ordering. Emulator evidence remains UI-only;F30.GAP017retains named-device proof.
2026-07-20 — iOS Assistant proof follows the native title owner
- The iOS Assistant root is intentionally titled by SwiftUI’s native navigation bar. Its shared
LumiPageTitleand subtitle are suppressed throughLocalNativeNavTitleHost, while Android continues to render the sharedAsk Lumititle and thread-list subtitle. - Critical flow 72 now proves Android copy only on Android and anchors iOS to the shell-owned
assistant_home_hostplus the primary FAB. It no longer waits for shared copy that the iOS product deliberately does not render, or couples navigation readiness to optional Team Pulse content. - Critical flow 84 now uses only the canonical
top_level_tab_assistantidentifier; the retiredassistant_tabcompatibility selector cannot mask tab-contract drift. - When the keyboard is open, UIKit can retain the visually covered tab bar as an accessibility node beneath the keyboard. Flow 84 therefore proves the editable input, microphone, and Send controls in the keyboard-reduced viewport and retains the settled frame; it does not mistake accessibility-tree presence for rendered visibility.
- This is current-simulator test-contract evidence, not named-device accessibility proof.
2026-07-24 — Member removal confirmation stays with the selected row
- Owner/admin removal now expands an inline confirmation beneath the selected organization member. The confirmation is no longer appended as a full-size in-layout dialog after the scroll body, where the reporting iPhone accepted the Delete press but displayed no reachable confirmation.
- Cancel and destructive confirmation remain explicit, both actions disable during the request, successful removal reloads the member list, and server errors stay visible beside the selected member.
OrgManagementMemberRemovalPlacementSourceTestprotects placement, happy-path wiring, and error custody. Existing screen-model and backend tests retain mutation and tenant-isolation proof. Named-device execution remains open inF30.GAP025.
2026-08-03 — Organization owners cannot be removed from mobile
- Shared organization management now hides the destructive member-removal action when the target
member has the
ownerrole. The backend authorization guard remains authoritative, while the client prevents a known-invalid destructive request from being presented to an owner/admin. OrgManagementMembersTestcovers the owner deny path and the authorized non-owner path; the existing placement source proof remains in force. Named-device execution remains open inF30.GAP025.
2026-07-24 — iOS short scroll gestures retain their intended direction
- The shared iOS framework resolves Compose Multiplatform 1.11.0 or newer. That release includes JetBrains’ upstream correction for reversed or unexpected fling velocity after a short gesture.
- The JetBrains Compose Gradle plugin has one version owner:
composeMultiplatformin the version catalog. A hardcoded module-level plugin version must not override the catalog. - Static policy proof and the resolved simulator dependency graph establish version custody.
Reporter and named-iPhone execution remain open in
F30.GAP026.
2026-07-23 — iOS speech recognition format guard (LUMI-IOS-S crash prevention)
Type: Production crash fix (iOS / KMP / Assistant / Dictation)
Summary:
- TestFlight build 152 crashed with AVFAudio fatal assertion (LUMI-IOS-S, 2 events, 1 user) when
installTapOnBusreceived an invalid bus format —sampleRate <= 0.0orchannelCount == 0— on a device whose audio route was not yet fully active. IosOnDeviceSpeechRecognizer.beginRecognitionnow activatesAVAudioSession(setCategory + setActive) before querying the bus format, checks Boolean returns from both session calls, and routes tap installation throughinstallSpeechTapIfFormatValid.- New
SpeechTapInstall.kt(commonMain):installSpeechTapIfFormatValid(sampleRate, channelCount, install)guards against zero/negative sample rate and zero channel count beforeinstallTapOnBusis invoked. Tested via 4-assertionSpeechTapInstallTest(commonTest). - Contract evidence:
F30.AC017.FORMAT_GUARD,F30.AC017.FORMAT_VALID, andF30.AC017.IOS_SESSION_BEFORE_TAPproof IDs added.F30.GAP014preserved as blocking named-iPhone live dictation proof. - Postmortem:
docs/postmortems/2026-07-23-ios-speech-invalid-audio-format.md. - Durable rule:
AGENTS.mdrecords the audio-session activation ordering invariant.
2026-08-03 — Android Assistant home converges on editable dictation
- The Android V2 Lumi Home now mounts the shared
ChatComposerRow: editable draft, on-device dictation, and explicit Send in one path. - The home no longer starts the retired audio-record/upload flow or renders its voice-preview overlay. That legacy capability remains available only from its explicitly owned chat surface.
- The source policy gate
F30.AC017.HOME_COMPOSERprotects the home boundary. This closes a source-level path mismatch; named-device speech, permission, teardown, keyboard geometry, and accessibility evidence remain underF30.GAP014.
2026-08-10 — Mobile build identity is visible and copyable
- Android and iOS now supply their packaged version, build number, and UTC build timestamp to one shared KMP build-information component.
- Login shows the identity as a quiet footer; Settings shows it in a labeled information section. Tapping either copies the complete identity and exposes localized success feedback with accessible button semantics.
MobileBuildInfoTestpins the display format andMobileBuildInfoSourceTestpins both shared placements plus Android/iOS artifact metadata ownership. Runtime appearance remains distinct from this source and compilation proof.