agreed — contract upgraded 2026-06-11 (full-coverage + onboarding/paywall reversal; supersedes the narrow "control loop only" contract from earlier the same day)

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; Lumi is assistant-first, Work is 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 works tour. 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/none org 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-After proof today; iOS now has deterministic transport proof for the offline failure seam, while native 429 and stale-409 proof 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 is make 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:

  1. Full reach. Every feature the web product ships is reachable on the phone. No “open your laptop for that.”
  2. 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_seen does 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_seen flag 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.
  • 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:true without 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 Subscription row 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.app serves 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.
  • 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 BuildConfig during Gradle configuration, while iOS stamps the built app’s Info.plist before signing; neither surface calls a network service for identity data.

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 NavigationEventDispatcherOwner and 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 Hub is 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.

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” in android/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 carry X-Organization-ID when 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, and JWTTokenProvider bridge refresh behavior. On this host we verified them up to xcodebuild build-for-testing; a full xcodebuild test run 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.

FeatureMobile surfaceTierNotes
F01 AuthAuth shell + no-org setup gateCore infralogin/register/restore/invite accept (link and code), then create org or join with code when memberships are empty
F02 Orgs & rolesSettings + Hub › CompanyHuborg 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 TeamsHub › Company › TeamsHubview + member list; team create/add-remove/delete shipped
F04 ProjectsWork › ProjectsWorklist/detail/create/edit
F05 TasksWork › Tasks (+ Pulse/Brief)Workfull lifecycle, phone-shaped
F06 RecurringWork › RecurringWorklist/create/pause/run-now
F07 Daily PulsePulse surfaceContext-onlyfull surface
F08 AI extraction—Backend-onlyconsumed via F07/F12/F14/F25
F09 CommitmentsWork / Pulse contextContext-onlylist/detail/resolve
F10 RisksWork / Pulse contextContext-onlylist/detail/resolve/dismiss
F11 DecisionsWork / Pulse contextContext-onlylist/detail/decide/request
F12 CEO BriefLumi / Pulse contextContext-onlyfull reading + action surface
F13 TodayPulse / Work Graph contextContext-onlybanner + queue + sections
F14 Ask LumiLumiCore tabF14 only
F15 Feedbackinline on AI surfacesContext-onlysame affordances as desktop
F16 Audit logiOS + Android: Hub › Settings › Audit LogHubowner/admin read-only list in mobile; admin HQ cross-org view stays desktop
F18 i18nall surfacesCore infra8 locales (vi first); 13 after F69 ships
F19 Design systemall surfacesCore infranative expression of the same product language
F20 Rate limiting—Backend-onlyclient respects Retry-After
F21 Telegram—Out-of-app gluecomplements mobile
F22 Landing—Excludedonboarding carousel is mobile’s landing
F23 HorizonWork › HorizonWork14-day list
F24 BillingPaywall + Hub › SubscriptionCore infrastore billing + invite-code door; gateway checkout (Sepay/Stripe) stays web
F25 Brief actionsPulse contextContext-onlyassign + dismiss
F26 ApprovalsWork / Pulse contextContext-onlyqueue/act/create; policy builder mobile backlog (not yet built)
F27 CRMBusiness › CustomersBusinesscompanies/deals/contacts full CRUD + stage move shipped; pipeline analytics mobile backlog (not yet built)
F28 HubHub › Company Hub (+ Pulse banner)Hubread/search/ack; announcement/doc create/edit/delete shipped
F29 MessagesHub › Company Hub › MessagesHubsend/reply/DM/own-message edit/delete and manager-gated channel edit/delete/archive shipped; archived channels are hidden from the active list
F31 Follow-upsPulse/Work/detail + Hub › IntelligenceContext-onlywhen backend present
F32 Signal InboxHub › Intelligence + quick-captureHubwhen backend present
F33 SourcesHub › AccountMobile gap (not yet built)source-connection administration requires a native mobile surface
F90 Field execution ledgerWork › Tasks / Projects detail (+ Pulse/Brief deep links)Context-onlyfield-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):

  1. F02 — short invitation code + org lifecycle endpoints. Invitation gains a unique, human-typeable short_code (format LUMI-XXXX, 4 chars from an unambiguous alphabet — no 0/O/1/I), generated alongside the existing UUID token. F02 also owns no-org setup support (POST /api/organizations/ with slug auto-generation, POST /api/members/leave/, POST /api/organizations/current/delete/).
  2. F24 — store receipt verification. New models or fields as F24 decides (e.g. StoreReceipt keyed to PaymentIntent/Subscription) so that an Apple/Google purchase produces the same Subscription truth 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.

SurfaceEndpointsNotes
Auth/sessionPOST /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 lifecyclePOST /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
InvitationPOST /api/organizations/accept-invitation/accepts {token} or {short_code} after the F02 delta ships
EntitlementGET /api/billing/subscription/the single source of billing/upgrade truth, evaluated per current org on every bootstrap and org switch
Store billingPOST /api/billing/store/verify/ (F24 delta, refreshed r-billing-iap)body: platform, product id (one of the 8 `lumi_ai_pro_{5
TodayGET /api/approvals/?queue=me, GET /api/decisions/?status=open, GET /api/risks/?status=open, `GET /api/tasks/?status=todoin_progress, GET /api/commitments/?status=open, GET /api/announcements/?pinned=true&unread=me`
BriefGET /api/briefs/today/, POST /api/briefs/generate/, POST /api/briefs/items/<id>/assign/, POST /api/briefs/items/<id>/dismiss/F12/F25 rules
PulseGET /api/pulses/today/, POST /api/pulses/submit/F07 contract
Ask LumiGET /api/chat/threads/, POST /api/chat/threads/create/, GET /api/chat/threads/<id>/messages/, POST /api/chat/threads/<id>/messages/send/F14 only
Approvals/DecisionsGET/POST /api/approvals/, POST /api/approvals/<id>/{approve,reject,cancel}/, GET /api/decisions/, POST /api/decisions/<id>/decide/policy CRUD excluded
WorkGET/POST/PATCH /api/projects/, /api/tasks/ (+ complete/reopen), /api/recurring-tasks/ (+ run-now), Horizon via due_after/due_before params, F90 task/project execution endpointsF04/F05/F06/F23/F90
IntelligenceGET /api/commitments/ + resolve, GET /api/risks/ + resolve/dismissF09/F10
CRMGET/POST/PATCH /api/companies/, /api/deals/ (+ stage transition)F27 read/write subset
HubGET /api/announcements/, POST /api/announcements/<id>/ack/, GET /api/hub-docs/, GET /api/hub-docs/search/?q=read/ack
MessagesF29 channel/topic/message endpoints, read + post + manager channel archived-list/archive/unarchive subsetSSE with polling fallback, as web
FeedbackPOST /api/feedback/same shape as desktop

Transport invariants

  • Authorization: Bearer <access_token> on every protected request; X-Organization-ID on every tenant-owned request
  • Silent refresh once on 401; clear session on refresh failure
  • Respect Retry-After on 429; 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. Lumi is the assistant-first home, Work is 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)

  1. Công việc / Work: Tasks · Projects · Recurring · Horizon
  2. Điều hành / Intelligence: Approvals · Decisions · Commitments · Risks (· Follow-ups · Signal Inbox when present)
  3. Công ty / Company: Hub · Messages · Teams · Customers (CRM)
  4. 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:

  1. Body text ≥17pt/sp; supports OS font scaling to 200% with no truncated primary actions.
  2. Touch targets ≥48×48dp / 44×44pt, ≥8dp spacing between adjacent targets.
  3. 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.
  4. One primary action per screen, bottom-anchored, full-width, high-contrast.
  5. Every swipe/long-press capability has a visible button equivalent.
  6. Reversible actions get undo (snackbar, ≥5s); only destructive actions get a confirm; never both.
  7. 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.
  8. 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).
  9. Forms: one question per screen region, large inputs, inline plain-language validation; never lose typed input on failure.
  10. 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 onRowSelected no-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

#CaseTypeWhere
1Fresh install plays exactly 5 onboarding screens, Skip works, never replays after completionplatform UIboth platforms
2Authenticated + entitled cold start lands on Today; onboarding/paywall not shownplatform UIboth platforms
3Authenticated + 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 shellplatform integration (store sandbox)both platforms
4Invitation code: valid code joins org and proceeds; invalid/expired shows plain error, preserves inputplatform UI/integrationboth platforms
5Member of lapsed org still enters the workspace; premium surfaces show owner-contact upgrade messaging instead of an entry wallplatform UIboth platforms
6Token refresh-once-and-retry; refresh failure clears session → authplatform unit/integrationboth platforms
7Org switch changes headers, data, and re-evaluates entitlementplatform integrationboth platforms
8Today: banner + queue + stable empty states match desktop rulesplatform UIboth platforms
9Brief assign/dismiss; Pulse submit/failure-preserves-input; Ask Lumi send/empty-answerplatform UI/integrationboth platforms
10Approvals approve/reject + decision decide enforce role gating; 409 refreshes to latest truthplatform integrationboth platforms
11Hub directory: every §3 matrix row present, grouped per §6.2, counts liveplatform UIboth platforms
12Rule of Two: scripted nav audit proves ≤2 taps to every matrix featureplatform UI auditboth platforms
13Senior invariants: 200% font scale, 48dp targets, label-on-every-icon, visible-button-per-swipe — automated where possible, checklist otherwiseaccessibility auditboth platforms
14Mobile gap screens show read data, no dead ends; condition logged as temporary build item requiring native surface (never a permanent handoff)platform UIboth platforms
15Backend deltas: short-code redemption maps to same Invitation; store verify activates same Subscription truth as gateway paymentbackend testsbackend/tests/ (F02/F24 suites)
16Negative audit: no local entitlement flag gates access; no web-gateway checkout reachable in-apprepo auditrg over both platforms in the implementation PR
17Authenticated user with zero orgs lands in org setup; create/join works on both platformsplatform UI/integrationiOS + Android gate tests
18Settings / Hub let user create org, switch org, leave org, and delete org with confirmationplatform UI/integrationiOS + Android Hub/account tests
19iOS 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-devplatform E2E (live backend)iOS UnitTests/E2E/LiveBackendE2ETests, UITests/*
20Android 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 FakeLumiBackendplatform UIAndroid e2e/ test classes (deterministic, no backend)
21Android live smoke (gated @LiveBackend): API-layer login→me→myOrgs→billingSubscription against api-dev.runlumi.appplatform E2E (live backend)Android e2e/smoke/LiveLoginSmokeTest.kt
22F90 mobile parity: field task detail supports milestone capture, worklog capture, timeline, and task day digest for member+ roles without leaving the mobile shellshared + platform UIKMP common tests + iOS/Android detail-flow tests
23F90 mobile manager parity: project/task day digest and latest execution badges are visible in Work Graph task/project flows with manager_only visibility enforcedshared + platform UIKMP common tests + iOS/Android role-gated tests
24F90 deep-link parity: Pulse, Brief, and notifications all open the same mobile field task/project detail surface instead of a web fallback or duplicate routeplatform navigationiOS + Android navigation tests
25Selecting an organization member opens one bounded actions dialog that owns role selection and eligible removal instead of appending controls below the management scroll bodyshared source/JVM + platform UIOrgManagementRolePickerPlacementSourceTest + OrgManagementMemberRemovalPlacementSourceTest + named Android/iOS verification
26Project detail loads owner/members/channels independently and add/remove/transfer never render optimistic stateshared JVMP60 project screen-model tests
27Team detail lists/creates linked channels and recovers one failed section without blanking the detailshared JVMP60 team screen-model tests
28Team/project channel tap resolves the canonical Messages route on Android and iOSshared + shell policyP60 navigation tests
29Container actions preserve 200% text, Vietnamese/English parity, labeled semantics, and target sizessource/unit + runtimeP60 accessibility suite
30Complete project and team lifecycle runs on one approved mobile runtime at a timeMaestroP60 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: RootContainer routes through onboarding, auth, org setup, and MainTabView, 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 in MainActivity, 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.app contact 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 opens MemberActionsDialog, 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) and POST /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 MainActivityViewModel proof for preferred-org reload plus no-org fallback safety, and iOS now has direct APIClient.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 failed 400 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 under android/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_job mode, 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

  • 401 expired 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/404 tenant mismatch: “item not available”; stale action buttons removed on reload.
  • 409 conflicts: refresh object, show latest truth.
  • Network failure: retry affordance; preserve typed input (pulse, comments, composer, invite code).
  • 429: respect Retry-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)

#ClaimStatusEvidence
1Mobile 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
2Invitation short codes (LUMI-XXXX) backend delta✅ match§13: 19 short_code tests verified ✅ (test_multitenancy.py + test_billing.py)
3POST /api/billing/store/verify/ backend delta✅ match§8: “backend deltas are shipped”
3aiOS 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
4Android no longer fail-opens via local paywall bypass✅ match§13 2026-06-12 entry: invitation-code join refreshes session + re-checks entitlement
5Four-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
6Senior-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
7Rule 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
8Full feature breadth (all §3 matrix features accessible)⚠️ partialiOS 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
8aInvitation UUID deep links still enter invite acceptance from either auth or signed-in shell⚠️ partialiOS 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
8bPassword-reset email links route to the shared native confirmation screen with strict reset/invitation separation⚠️ partialAndroid 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.
12iOS 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
9iOS 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
10iOS 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
11iOS 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):

  1. 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 F24 read_only 403 is softened to advisory only — it informs the banner level but does not block writes.
  2. 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 iOS StoreKitManager.productIDs MUST match this set; the legacy com.runlumi.{starter|team|business}_{monthly|annual} IDs are retired.
  3. 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 a seats field that is the source of truth for per-seat × seats pricing on the per-AI-user model.
  4. One mode. Senior-usability invariants are the default and only design; no accessibility fork.
  5. Four tabs forever (this contract): breadth grows inside Hub, never as a fifth permanent destination.
  6. 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 changePlan in 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 GET requests, covering the read-only startup and lane calls reported by Sentry (tasks, projects, follow-ups, pulse rollup, decisions, risks, and auth/me).
  • Persistent gateway failures still return the final ApiException and 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 LumiPrefs creation after an encrypted-preference or Android Keystore integrity failure by deleting only lumi_secure_prefs and 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 versionCode from 29 to 30 because Play’s live maximum is 29. JVM regression coverage exercises direct AEADBadTagException, a wrapped Keystore integrity failure, and the non-cryptographic failure path. Release-bound named-device non-recurrence remains an explicit proof gap, and the reported com.locuno.lumi.android@1.0.0+19 listing cannot be upgraded in place by the replacement com.lumi.ai Play 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.md cases 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 LumiDialog overlay now consumes the platform-aware lumiImePadding() before centering its card, so the final overlay is measured in the keyboard-reduced viewport. The SwitchOrgDialog keeps 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 EditOrgDialog and opens one MemberActionsDialog when 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 no org_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 app com.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 isolated com.locuno.lumi.dev install identity and Firebase project lumi-dev-5c0a0 so 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 to com.lumi.ai and api.runlumi.app.
  • AndroidApplicationIdSourceTest fails 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 = 0 were 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-5c0a0 and Play package com.locuno.lumi.dev as the dev flavor’s independent distribution identity.
  • Bound devRelease to api-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.ai as 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.ai listing 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.
  • versionCode advances 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.so copies 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 KB PT_LOAD alignment.
  • 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 versionCode is 29. Its signed production AAB passed the manifest and 16 KB ELF gates, and Play production readback confirmed status completed; 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 versionCode 20. 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 final Get started; Android’s authenticated tour uses Next, Close, and final Got 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 onDeleted callback. The callback now crosses SwiftUI → Kotlin host → shared TaskDetailScreen, 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 in Scaffold layout 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 LumiPageTitle had 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 SharedLaneRoute destinations 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.systemUltraThinMaterial appearance and preserves SwiftUI keyboard ownership because the modifier ignores only the .container bottom edge.
  • Added F30.AC021 source 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 UnauthorizedException as an IOException, allowing OkHttp to report the failure to the Retrofit/coroutine caller instead of terminating RealCall.AsyncCall.
  • Preserve MainActivityViewModel.restoreSession as 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 implements NavigationEventDispatcherOwner, 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.

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 real https://app.runlumi.app/auth/accept-invitation/<token> Android VIEW intent, and then runs one Maestro flow. The runner must bind both ADB and Maestro to the same required ANDROID_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 AcceptInviteScreenModel for 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 598245fdd216cfbcca9caabf7cbca1a754b9323e passed the guarded Lumi_Pixel7_API35 journey 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ại recovery 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 URLProtocol failure response against the real authenticated APIClient request seam.
  • The test asserts APIError.networkError and 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 Content and 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:

  • KMPComposeView now observes LanguageManager.shared and 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_url through the self-scoped PATCH /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 CrmScreenModel keyed 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-ID provider 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 optional attachment_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 UploadSelection values; 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.manage capability, 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.manage capability, 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-tasks
  • bash 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 LumiPhoneScaffold without shell-level scroll tracking, translation, or fading and omits the bar only while the IME is visible. iOS uses PersistentTabBarAppearanceModifier without 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 via UITabBarAppearance. Reduced-motion: UIView.animate skipped when UIAccessibility.isReduceMotionEnabled.
  • Android: ScrollVisibilityState (state machine), LumiAutoHidingPhoneScaffold (NestedScrollConnection + animateFloatAsState). Translucent LumiNavigationBar. 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, …) in App.swift); the hosted KMP Compose screens kept resolving stringResource(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 all internal (verified against the v1.10.3 sources). Both CMP resolution paths (composable stringResource via Locale.current’s darwin delegate, and suspend getString via getSystemEnvironment()) read NSLocale.preferredLanguages, which is driven by the AppleLanguages user default — iOS’s own per-app-language mechanism. lumiComposeViewController(…, locale:) now normalizes the tag and puts it first in AppleLanguages (no-op when the device already resolves to that language), so every stringResource in every hosted screen resolves against the app language; null keeps system behavior.
  • New shared normalizeAppLanguageTag() (commonMain i18n/AppLanguageTag.kt): canonicalizes shell language values to BCP-47 (“pt_br” → “pt-BR”, “zh-hans” → “zh-Hans”), null for garbage — covered by AppLanguageTagTest (jvmTest).
  • Every iOS make*ViewController factory (Work/Business/Workspace/MainViewController.kt) gained locale: String? = null, forwarded to the composition root; KMPComposeView.locale now defaults to LanguageManager.shared.language and passes it to every factory, so all hosted screens get the app language at creation.
  • Language-switch freshness: all KMP .id(…) identities in MainTabView.swift (assistant chat, lane roots, tasks/projects, lane detail routes, and now Settings itself) are keyed by LanguageManager.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 + SharedKmpViewModel call AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(lang)), which updates the configuration Compose resources read and recreates the activity on switch.

Tests:

  • cd android && ./gradlew :shared:jvmTest — new AppLanguageTagTest (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 Lumi against the rebuilt shared.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) or failed. The Pulse-lane check-in card only recognised submitted/failed, so a processed pulse rendered “Not submitted” + a Submit Pulse button that dead-ended on the already-submitted form. New normalizePulseStatus() in PulseHomeScreenModel collapses the lifecycle once for all consumers; the lane UI also accepts processed directly.
  • 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), normalizePulseStatus table 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_button remains 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_cta prevents the stale hideKeyboard step 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 (new LumiLightColorScheme, mirrors the Android design-system scheme) is now applied at the iOS composition root: previously no MaterialTheme wrapped the hosted screens, so every colorScheme.* 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 LumiInlineAction pill (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_count across /api/messages/channels/, via new APIClient.fetchMessageUnreadCount()).
  • LumiFilterChip / LumiInlineAction pills 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 UIViewController was opaque, painting pale squares behind the banner’s rounded corners on the paper background. lumiComposeViewController gains an opaque parameter; the banner passes false.
  • New shared formatMoneyAmount(): deal amounts render 15 000 000 VND (no-break-space grouping, zero fraction dropped) instead of the raw 15000000.00 VND serializer 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, LumiMoneyTest added.
  • Visual verification via .maestro/qc/visual_qc_tour.yaml screenshots 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, and ThreadRow clamps the title to 1 line / subtitle to 2 with ellipsis.
  • Screen titles now have a single owner on iOS. New LocalNativeNavTitleHost CompositionLocal: when the SwiftUI shell titles the nav bar for a screen, its iOS factory passes lumiComposeViewController(nativeTitleHost = true) and the shared LumiPageTitle renders 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 SharedLaneRoute now 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 via laneScrollBottomInset()), 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 — ChatUiNullSafetyTest gains 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 (ChatScreen thread-title fallback and ProposalCard owner/due chips), so partial production payloads no longer abort iOS render.
  • Removed the remaining shared UI !! usage in AcceptInviteScreen and PulseScreen while touching the same crash-safety surface.
  • Added ChatUiNullSafetyTest for blank/null thread and proposal payloads plus SharedUiBangGuardTest, which fails if !! re-enters android/shared/src/commonMain/.../ui.

Reason:

  • TestFlight build 141 crashed 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 failed banner.
  • Added shared regression coverage in ErrorExtTest and AuthScreenModelTest so 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 — added reopenTask(id: String) to the interface and DefaultTodayRepository (forwards to api.reopenTask).
  • TodayUiState — added undoTaskId: String? field (null = no pending undo; non-null = show snackbar).
  • TodayScreenModel — completeTask now sets undoTaskId = id on API success (instead of just reloading). New undoCompleteTask(id) calls reopenTask then clears undoTaskId. New clearUndoTask() clears undoTaskId without an API call (snackbar timeout path).
  • TodayScreen — added LaunchedEffect(undoTaskId) that calls SnackbarHostState.showSnackbar(duration=Long ≈10s, actionLabel="Undo"); ActionPerformed → undoCompleteTask; Dismissed → clearUndoTask. SnackbarHost added inside a wrapping Box.
  • TasksUiState — added undoTaskId: String?.
  • TasksScreenModel — completeTask sets undoTaskId = id on success. New undoCompleteTask / clearUndoTask follow the same pattern.
  • TasksScreen — same LaunchedEffect + SnackbarHost pattern; snackbar sits above the FAB (padding(bottom = 80.dp)).
  • i18n — action_undo / undo_task_completed added to both values/strings.xml (EN) and values-vi/strings.xml (VI). No hardcoded strings.
  • Tests — 3 new tests in TodayScreenModelTest and 3 in TasksScreenModelTest covering: (1) completeTask sets undoTaskId; (2) undoCompleteTask calls reopenTask and clears state; (3) clearUndoTask clears state without API call. All existing anonymous TodayRepository test objects updated to implement reopenTask.

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.kt
  • android/shared/src/commonMain/.../presentation/today/TodayScreenModel.kt
  • android/shared/src/commonMain/.../ui/today/TodayScreen.kt
  • android/shared/src/commonMain/.../presentation/tasks/TasksScreenModel.kt
  • android/shared/src/commonMain/.../ui/tasks/TasksScreen.kt
  • android/shared/src/commonMain/composeResources/values/strings.xml
  • android/shared/src/commonMain/composeResources/values-vi/strings.xml
  • android/shared/src/commonTest/.../presentation/today/TodayScreenModelTest.kt
  • android/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 by safeRunCatching/friendlyError.
  • NEW commonMain/.../ui/auth/AcceptInviteScreen.kt — Compose composable; loading / error / success states; LumiMobilePrimitives tokens; 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 with onAccepted(String?) + onDismiss callbacks.
  • iOS: SharedScreenBridge.swift — .acceptInvite(token:) case added; AppEnvironment.swift replaces AcceptInviteView(.inject(diContainer)) with KMPComposeView(screen: .acceptInvite(token:)).
  • Android: MainActivity.kt — replaces private AcceptInviteScreen composable with shared AcceptInviteScreen; dead imports removed.
  • Dead code removed: iOS AuthScreen struct, LoginView, RegisterView (pre-KMP stubs); iOS AcceptInviteView; Android private AcceptInviteScreen composable.
  • Bug fix (silent): iOS was calling acceptInvitation(code: token) (shortCode param — for typed codes) instead of token param. 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 via SharedScreenBridge / KMPComposeView; MainTabView.destinationView(for:) routes every detail case to KMPComposeView — none of the deleted Swift structs were reachable.
  • Removed dead APIClient methods (teamMembers(teamId:), ackAnnouncement(id:)) whose only callers were the deleted screens; KMP repositories (TeamDetailRepository, AnnouncementDetailRepository) own these calls from commonMain.
  • 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.swift in §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 6 com.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 billing row updated: the POST /api/billing/store/verify/ body now expects one of the 8 new product IDs; the backend resolver in apps/billing/services/resolve_store_product parses (plan_code, seats, period) and stores seats on the resulting Subscription. The web POST /api/billing/pay/ body gains a seats field (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. BillingManager now owns the canonical 8-product lumi_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.
  • PaywallViewModel now 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.
  • PaywallScreen now 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-based lumi_ai_pro_* product grammar.

Verification:

  • cd android && ./gradlew :app:compileDevDebugAndroidTestKotlin
  • cd 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. PaywallViewModel now preserves the paid purchase payload (productId, purchaseToken, transactionId) when POST /api/billing/store/verify/ fails after a completed Play purchase or restore.
  • PaywallScreen now 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.app rather 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.kt for: 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 in mobile-ux-patterns.md §2 (classified merge-blocking).
  • Added LumiRefreshable to LumiMobilePrimitives.kt: a thin PullToRefreshBox wrapper in the shared Lumi design token system, so all screens use a consistent indicator.
  • Added isRefreshing: Boolean = false to the UiState of every affected ScreenModel and a refresh() function: guards against double-load (no-op if isLoading or isRefreshing already), sets isRefreshing = true while keeping existing data visible (correct four-state behavior — does not replace content with a skeleton on pull), clears on completion.
  • Wrapped every affected LazyColumn with LumiRefreshable in the shared UI screens.
  • Added refresh() unit tests to every affected ScreenModelTest.

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 list
  • WorkspaceLaneScreen — 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 new refresh() 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 (new LumiRefreshable)
  • 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): cover TodayViewModel.approveApproval/resolveRisk/completeTask/resolveCommitment, pendingActions, and actionError. Asserts: on success the acted item drops out of its section after the post-action refetch and pendingActions does not retain the id; on failure actionError is non-nil and the section stays .loaded with 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 in pendingActions while 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 the MockTodayDataSource/CountingDataSource style and model constructors from Wedge/LoadStateTests.swift + Wedge/PullToRefreshTests.swift.
  • Pulse step navigation (ios/UnitTests/Wedge/PulseFormStateTests.swift): the index/answers logic was trapped inside PulseScreen’s @State. Performed the smallest seam extraction — a pure PulseFormState struct (currentIndex, answers, advance()/back() clamping, progressPosition) that the View now renders from unchanged. Tests assert advance/back clamp within 0..<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 as ApprovalDetailScreen).
  • Risk → “Đã xử lý” → POST /api/risks/{id}/resolve/ (same as RiskDetailScreen).
  • Task → “Hoàn thành” → POST /api/tasks/{id}/complete/ (same as TaskDetailScreen / WorkspaceTaskListScreen).
  • Commitment → “Đã xong” → POST /api/commitments/{id}/resolve/ (same as CommitmentDetailScreen).
  • Decision → no inline action — navigates to DecisionDetailScreen. POST /api/decisions/{id}/decide/ requires an option choice 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 #N badge (§6.4 “each item shows its rank”) — previously rank was used only for sort order and never displayed. (2) Evidence link given a visible text label (“Evidence” + link icon) replacing the icon-only link.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 six TextEditors 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 .task but had no pull-to-refresh. Added .refreshable { await load() } matching the WorkspaceScreens.swift pattern (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-type WorkspaceManifest (Section → [Row], each row with a stable id, LocalizedStringResource label, SF Symbol, optional count key, and a Destination — .view { AnyView(...) } or .settingsSheet). WorkspaceTabView.body renders from WorkspaceManifest.sections via makeRow(_:). Rendered UI is byte-for-byte equivalent — same rows, same §6.2 order, same labels/icons/badges/destinations, same WorkspaceSection/WorkspaceRow views.
  • New ios/UnitTests/Workspace/RuleOfTwoTests.swift:
    • test_manifest_sectionsInFixedSixTwoOrder — sections are exactly work, 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 MainTabView renders 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_touchTargetsMeet44ptMinimum asserts LumiMetrics.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 in DetailScreens.swift — for icon-only interactive controls lacking a label. Result: none found. Every Button/LumiButton carries a Text/Label with 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 remaining Image(systemName:) hits are decorative (empty-state icons over a Text, leading row icons next to a title, the checkmark/pin.fill adornments) 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 checkStatus
C1200% 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
C2VoiceOver 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
C3Touch-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
C4Contrast ≥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 .convertFromSnakeCase decoder; 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, nested Entitlements.hasFeature + .free fallback, isEntitledForBootstrap across all statuses), SubscriptionPlan, PublicPlanResponse (price_monthly_vnd/price_annual_vnd, paginated), StoreVerifyRequest encoding to snake_case (product_id/receipt_data/purchase_token/transaction_id, iOS + Android shapes, no camelCase leak), and StoreKitManager.period(of:) product-id → monthly/annual resolution.
    • ios/UnitTests/Workspace/WorkspaceContractDecodeTests.swift — widened RecurringTaskResponse (full + minimal title fallback), TeamMemberRowResponse (flat user_name/user_email, displayName fallback, paginated), AnnouncementResponse.acked + body_md, FollowUpResponse (F31) + FollowUpReplyRequest snake_case encode, SignalInboxItemResponse/SignalProposalResponse (F32) incl. displayTitle fallback + 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) → FollowUpDetailScreen with a bottom-anchored Send reply primary action wired to POST /api/follow-ups/<id>/reply/ {answer_text} (assignee-bindable write; reply box shown only while status == "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 to POST /api/signal-proposals/<id>/accept/ and /reject/; one bottom-anchored Ignore this signal action (destructive-confirm dialog) maps to POST /api/signal-inbox/<id>/ignore/.
  • Response models (FollowUpResponse, SignalInboxItemResponse, SignalProposalResponse) + request bodies added in the same file; decode via the client’s convertFromSnakeCase, write bodies use explicit snake_case CodingKeys.

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, zero error: 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 from GET /api/billing/subscription/ (new APIClient.fetchSubscription(); SubscriptionResponse extended with plan, 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-shipped APIClient org methods (switchOrganization, acceptInvitation, createOrganization, leaveCurrentOrganization, deleteCurrentOrganization) that previously lived only in SettingsScreen. Leave/Delete now use confirmationDialog confirms (§6.3-6 destructive-confirm), bottom-anchored single primary LumiButton per 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 via HubArticleResponse. Rewired to the real paginated /api/announcements/ (AnnouncementResponse); rows now push AnnouncementDetailScreen showing the full body_md plus a real acknowledge action (POST /api/announcements/<id>/ack/). The acked SerializerMethodField 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 push MessageDetailScreen (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 TeamDetailScreen showing 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 no stage/deal_value (stage lives on Deals, not Company), so there is no company stage-move action. Rows now push CompanyDetailScreen with 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 (in ios/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 (reuses TaskEditSheet), and an inline ≥44pt circle toggle per row that completes/reopens via POST /api/tasks/{id}/complete|reopen/. Each row also pushes the existing TaskDetailScreen. MainTabView Work “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 in WorkspaceScreens.swift now navigate to it. RecurringTaskResponse widened 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 six com.runlumi.{plan}_{period} products via Product.products(for:); purchase() handles .success(.verified), sends the signed transaction’s jwsRepresentation as receipt_data + product id + transaction.id to POST /api/billing/store/verify/, then transaction.finish() and applies server-truth entitlement; restore via AppStore.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 by product.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 (via APIClient.acceptInvitation). When StoreKit products fail to load it falls back to canonical plans from GET /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 into MainTabView Account → Subscription row (was a Text stub).
  • APIClient: added fetchSubscription, fetchPlans (PublicPlanResponse), verifyStorePurchase, refreshEntitlement, applyEntitlement; added snake_case CodingKeys to StoreVerifyRequest (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 Lumi succeeds 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 @BindValue overrides
  • e2e/LiveBackend.kt — annotation for gating live-backend tests
  • e2e/SignUpCreateOrgFlowTest.kt — fresh user → register → org setup → create/join → Today
  • e2e/EntitlementPaywallFlowTest.kt — 3 classes proving active, lapsed-owner, and lapsed-member bootstrap states all enter the soft-gated Lumi shell after one server entitlement read
  • e2e/OnboardingFlowTest.kt — fresh install (onboardingSeen=false) → onboarding renders → skip → Auth
  • e2e/WorkspaceBreadthActionsFlowTest.kt — active four-tab V2 shell + Work, Business, and Hub lane reachability + approve/reject action flow
  • e2e/smoke/LiveLoginSmokeTest.kt — gated (@LiveBackend) API-layer smoke against api-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()) and ChatScreen (message thread ScrollView → loadMessages()).
  • MainTabView.swift: WorkspaceTabView directory 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-optional id/items, used keys date/summary; real placeholder is {status:"not_generated", brief_date} (no id/items), full body uses brief_date/summary_text. → id/items/title optional; briefDate/summaryText keys.
  • BriefItemResponse: used detail/confidence/related_task; backend uses description/confidence_score/related_task_detail. → renamed; added RelatedTaskSummary.
  • PulseEntryResponse: required id, used answers; placeholder is {questions, date, status:"not_submitted"} (no id), full body uses answers_json. → id optional, answersJson, added questions; screen treats not_submitted/nil-id as compose state.
  • PulseSubmitRequest.answers: was a dict; backend expects a positional [String] list. → [String].
  • DecisionResponse.options: decoded options_json as objects; backend serializes a plain [String] (threw when options present). → [String]/optionsJson; decided_by→decisionBy (UUID).
  • RiskResponse.linkedTask: nested task keyed linked_task; backend sends related_task as a UUID string. → relatedTask: String?.
  • register(): decoded LoginResponse (non-optional organizations) but /api/auth/register/ returns only {access,refresh,user}. → dedicated RegisterResponse; register establishes a token-only session (empty orgs) → routes to OrganizationSetupView.
  • fetchToday(): one atomic try await over 7 endpoints blanked the whole tab on any single failure. → per-section independent fetches; each Loadable section 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 ModelViewSet under the default PageNumberPagination (PAGE_SIZE 50) → returns {count,next,previous,results}, but the Workspace screens + DetailScreens task-by-project + Chat messages decoded [X]. The whole Workspace surface threw. → call sites now decode PaginatedResponse<X> and read .results.
  • TaskResponse.project / assignedTo were nested objects. TaskListSerializer/TaskSerializer send project (and owner) as UUID strings + flat project_name/owner_name; there is no assigned_to. Decoding a string as ProjectResponse/MemberResponse threw for any populated task. → project/owner are String?, added projectName/ownerName; UI reads the names.
  • MemberResponse.user was a nested object. OrganizationMemberSerializer sends user as a UUID string + flat user_email/user_name. → user: String? + userEmail/userName; Brief picker uses the name.
  • TeamResponse.manager nested + memberCount: Int. TeamSerializer sends manager as a UUID string + manager_name, and member_count as a string SerializerMethodField; /api/teams/ is paginated. → manager/managerName: String?, memberCount: String?; the “Teams” screen now lists teams (was wrongly typed to TeamMemberResponse).
  • RecurringTaskResponse used title/status/next_due (none exist). Backend has template_title, next_run_at, is_active (no status). The non-optional title/status were absent → guaranteed throw. → real keys + computed title/nextDue shims.
  • CompanyResponse.status non-optional but CompanySerializer (“all”) has no status/stage/deal_value → threw. → optional; real industry/city added.
  • CommitmentResponse.amountCents doesn’t exist — backend sends money_amount (string Decimal) + currency. → moneyAmount/currency.
  • AnnouncementResponse.body — backend field is body_md. → bodyMd + body shim.
  • Chat send + messages. send_message returns {user_message, assistant_message} (decoded as one message → threw); messages/ is paginated (decoded as array → threw). → ChatSendResponse envelope; messages via PaginatedResponse. ChatThread carries last_message {content,role} + updated_at, not last_message_at.
  • Noted dead surfaces: /api/horizon/ (no route → 404) and /api/messages/ (maps to channels/conversations router, no root list) — these surface as httpError, 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:

  • AcceptInviteView on iOS remains active — it’s the org-join deep-link flow (now joined by the typed-code flow in this contract).
  • DocumentType.SUBSCRIPTION_OR_RENEWAL is untouched (AI document triage, not billing).

2026-06-12 — Android gate moved onto server truth

Type: Feature Hardening

Summary:

  • Android MainActivity now 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.md catalog 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, and FieldExecutionRoutingTests.
  • 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 use status=open, and the task badge sums todo + in_progress + waiting instead 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.swift now preserves the pending paid payload, increments a consecutive verify-failure counter, and flips a support-escalation state after the third failed activation retry.
  • PaywallView.swift now surfaces that state with clearer copy plus a concrete mailto:support@runlumi.app affordance while keeping the existing retry CTA, restore, invitation-code door, and legal links.
  • ios/UnitTests/Gate/GateTests.swift now 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 using ExposedDropdownMenuBox + OutlinedTextField
    • ExposedDropdownMenu for the language picker. ExposedDropdownMenu uses the same Skiko popup code path that crashes on Android CMP with NoClassDefFoundError: SkikoMenu_skikoKt — the exact class of crash that LumiDropdownField was built to replace. The file comment saying “ExposedDropdownMenu is the safe KMP path” was incorrect.
  • LumiDropdownField was already imported on line 62 (the conversion was started but not completed). Replaced the 35-line ExposedDropdownMenuBox block with a 6-line LumiDropdownField(label, selectedDisplay, options, onSelect, modifier) call.
  • Removed 4 banned raw Material3 imports and the showLanguageMenu state 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.kt now 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 trialing a 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.

Type: Feature / Shared-code coverage improvement

Summary:

  • Deleted duplicate native onboarding implementations (android/feature/scan/impl/.../OnboardingScreen.kt 176 lines, ios/Lumi/UI/Onboarding/OnboardingView.swift 148 lines) — 324 lines of copy-pasted content replaced by a single commonMain source.
  • Added android/shared/src/commonMain/kotlin/.../ui/onboarding/OnboardingScreen.kt: 5-page HorizontalPager carousel with LumiPrimaryButton Next/Get-started and an unconditional TextButton Skip (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 composeResources string files (onboarding_next, onboarding_skip, onboarding_get_started, onboarding_icon_desc, onboarding_screen{1–5}_{title,body}).
  • Added makeOnboardingViewController(onComplete:) factory to MainViewController.kt (no API client — pre-auth static content).
  • iOS shell: added .onboarding case to SharedScreen enum in SharedScreenBridge.swift; routed into makeOnboardingViewController; replaced OnboardingView(...) in AppEnvironment.swift with KMPComposeView(screen: .onboarding, onCreated: { completeOnboarding() }).
  • Android shell: updated MainActivity.kt import 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 LanguageManager menu 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.md with 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 against https://api-dev.runlumi.app without 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) and Scoreable on named device: false instead 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.sh as 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.sh to 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 accessibility route 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.0 and restores the setting with an exit trap; the existing iOS AccessibilityExtraExtraExtraLarge setting 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.kt did not pass the navigation callback. After More became a legacy-only surface, owner/admin users had no reachable path.
  • Android now wires SettingsScreen.onOpenAuditLog to AuditLogNavKey. The shared isOwnerOrAdmin gate 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 AuditLogScreen had no reachable entry on iOS. Android exposed it via More → Audit Log at that time; iOS had .auditLog in SharedScreen and WorkspaceViewControllers.makeAuditLogViewController but no caller wired the route.
  • Fixed by adding onOpenAuditLog callback + orgRole parameter to SettingsScreen.kt (shared KMP), gating the Audit Log row visibility to owner/admin only.
  • Wired through WorkspaceViewControllers.kt → SharedScreenBridge.swift → MainTabView.swift so tapping the row in iOS Settings pushes .auditLog and 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.yaml corrected: 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 isOwnerOrAdmin helper from FieldExecutionSupport (no new helper extracted; the canonical already tests its own behavior).
  • .maestro/critical/52_audit_log_screen.yaml updated.

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 Next then Done; Done submits 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.yaml guards the cross-surface behaviors; focused flow 90_android_mission_prompt_ime_visibility.yaml guards 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.yaml owns the complete cold auth doorway and deliberate-tap proof. Critical flow 105_android_text_first_autofocus.yaml starts 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.authDoesNotOpenTheKeyboardOrStealFocusOnEntry and F01 flow 120_android_auth_calm_entry.yaml prove the auth contract; the old flow’s inputText step 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_policy plus the registered F01 auth policy and runtime lifecycle gates.
  • Follow-up: named Android execution remains open under F30.GAP009 and F01 auth gaps.

2026-07-19 — CRM Company forms follow visual IME order

  • Create and Edit Company expose Next for Name, Industry, Website, Tax code, and Address, then Done for City. Each Next moves to the next visible field; Done clears focus without saving.
  • Stable field tags cover all six inputs on both forms. Critical flow 96_crm_company_ime_next.yaml enters 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 uses Done to 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.yaml inserts 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.yaml retains the runtime keyboard-open frame; ShellImeInsetOwnershipSourceTest enforces the root seam.

2026-07-18 — Android keyboard adjacency correction

  • MainActivity must 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.
  • LumiFAB accepts an optional action-specific description while retaining the generic localized create description for existing callers.
  • Critical Maestro flow 80_android_assistant_fab_accessibility.yaml resolves 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 .container only. 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 consumes WindowInsets.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:

  • SharedKeyboardInsetPolicyTests rejects direct shared imePadding() consumers and every direct commonMain IME API, pins the Android/iOS actual implementations, and requires the registered CommonImeOwnership custom 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.py fails when the settled action-to-IME boundary gap exceeds 24 points; run-ios-keyboard-clearance-proof.sh binds it to flow 104.
  • Critical flow 104_ios_create_task_ime_clearance.yaml must 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 Surface experiment 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+ createOnDeviceSpeechRecognizer and fails closed if the device has no installed on-device recognizer. iOS requires supportsOnDeviceRecognition and sets requiresOnDeviceRecognition = 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.GAP014 runtime 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, or ERROR_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: SecurityException remains permission-denied, while IllegalStateException and UnsupportedOperationException remain 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.
  • AndroidOnDeviceSpeechRecognizerTest pins the support-error mapping and keeps permission, startup, no-speech, audio, and unknown-error categories distinct. This is source/JVM proof only; F30.GAP014 still 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ại string remains the control’s accessible name for VoiceOver and TalkBack. The icon lives in a 48dp target and keeps the existing onBack callback 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 Lumi and Submit Daily Pulse.
  • Ask Lumi preserves the F91 nameless-thread contract and opens the editable composer. Submit Daily Pulse routes 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 .dailyPulse to 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

  • LumiDialog is an in-window Compose overlay, not a platform Dialog; 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.yaml guards Constitution → Settings → Workspace ordering. Emulator evidence remains UI-only; F30.GAP017 retains 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 LumiPageTitle and subtitle are suppressed through LocalNativeNavTitleHost, while Android continues to render the shared Ask Lumi title and thread-list subtitle.
  • Critical flow 72 now proves Android copy only on Android and anchors iOS to the shell-owned assistant_home_host plus 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_assistant identifier; the retired assistant_tab compatibility 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.
  • OrgManagementMemberRemovalPlacementSourceTest protects placement, happy-path wiring, and error custody. Existing screen-model and backend tests retain mutation and tenant-isolation proof. Named-device execution remains open in F30.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 owner role. The backend authorization guard remains authoritative, while the client prevents a known-invalid destructive request from being presented to an owner/admin.
  • OrgManagementMembersTest covers the owner deny path and the authorized non-owner path; the existing placement source proof remains in force. Named-device execution remains open in F30.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: composeMultiplatform in 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 installTapOnBus received an invalid bus format — sampleRate <= 0.0 or channelCount == 0 — on a device whose audio route was not yet fully active.
  • IosOnDeviceSpeechRecognizer.beginRecognition now activates AVAudioSession (setCategory + setActive) before querying the bus format, checks Boolean returns from both session calls, and routes tap installation through installSpeechTapIfFormatValid.
  • New SpeechTapInstall.kt (commonMain): installSpeechTapIfFormatValid(sampleRate, channelCount, install) guards against zero/negative sample rate and zero channel count before installTapOnBus is invoked. Tested via 4-assertion SpeechTapInstallTest (commonTest).
  • Contract evidence: F30.AC017.FORMAT_GUARD, F30.AC017.FORMAT_VALID, and F30.AC017.IOS_SESSION_BEFORE_TAP proof IDs added. F30.GAP014 preserved as blocking named-iPhone live dictation proof.
  • Postmortem: docs/postmortems/2026-07-23-ios-speech-invalid-audio-format.md.
  • Durable rule: AGENTS.md records 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_COMPOSER protects the home boundary. This closes a source-level path mismatch; named-device speech, permission, teardown, keyboard geometry, and accessibility evidence remain under F30.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.
  • MobileBuildInfoTest pins the display format and MobileBuildInfoSourceTest pins both shared placements plus Android/iOS artifact metadata ownership. Runtime appearance remains distinct from this source and compilation proof.