shipped in source; contract status partial pending runtime evidence and the deferred See-all affordance

Spec: F80 — Global Cross-Object Search

Current V2 mobile shell: Lumi · Work · Business · Hub.

Status: shipped in source; contract status partial pending runtime evidence and the deferred See-all affordance

Navigation reconciliation (2026-08-05): F80’s mobile search trigger applies to the canonical four-tab shell Lumi · Work · Business · Hub. Pulse and Company Hub remain searchable nested destinations, not permanent tabs.

1. Problem & CEO Job

Find anything, decide fast, never lose your lane. F79 §2.1 promises persistent global search across tasks, projects, deals, companies, docs, messages, people, and settings from each mobile tab; §2.7/§6.1a make it a fixed cross-workspace control. Today only /api/hub-docs/search/ and /api/messages/search/ exist, forcing a CEO who remembers “that Acme deposit thing” to guess the owning module. That is module-hunting, not finding.

F80 defines one fan-out read endpoint over every object the user can see, grouped by type with a stable deep-link descriptor to the correct detail screen and lane. It closes F79’s search-trigger and deep-link gaps without adding a search engine.

2. User Stories & Acceptance Criteria

  • As a CEO or operator, I want one search box that reaches every kind of object, so that I find things by what I remember, not by which module owns them.

    • Given the authenticated shell, when I type a query of at least the minimum length (2 characters), then GET /api/search/?q=<term> returns matches across tasks, projects, deals, companies, contacts (people), docs, messages, and the settings catalog in one response.
    • Given a query shorter than the minimum length (0–1 chars), when the client would search, then no request is made and the surface shows a “keep typing” / idle hint rather than calling the API.
    • Given results exist, when the response returns, then results are grouped by object type, each group capped at a small per-type limit (default 5), with a stable group order.
    • Given a Vietnamese query typed without diacritics (e.g. khach hang), when it is searched, then objects whose text carries the diacritics (e.g. khách hàng) match — search is accent-insensitive (per ADR-018 unaccent + lumi_vi_unaccent).
  • As a mobile user, I want search reachable from every lane, so that I can jump anywhere without leaving my current tab context.

    • Given any of the four primary tabs (Lumi, Work, Business, Hub) is open, when the top rail renders, then a global search bar/trigger is present and opening it does not switch my bottom tab (F79 §2.1, §6.1a). Company Hub remains reachable as a Hub destination.
    • Given the Hub tab, when it opens, then global search is one of its directory entry points (F79 §2.6).
  • As a user, I want tapping a result to take me to the exact thing, so that search is a doorway, not a dead list.

    • Given a result row, when the response includes it, then it carries { type, id, title, subtitle, deep_link } where deep_link is a stable client route descriptor { lane, screen, id }.
    • Given I tap a deal result, when the client maps its deep_link, then I land on the Business lane deal detail (F79 §2.4); tapping a task lands on the Work Graph detail (F79 §2.3); a doc lands in Company Hub; a message lands on its chat surface; a person lands on the contact/people detail; a settings entry lands on that settings screen.
    • Given a result whose deep_link cannot be mapped by the current shell, when the client renders it, then the row degrades to a non-navigating display rather than crashing or routing to a wrong screen.
  • As a member with limited access, I want search to respect what I’m allowed to see, so that search never leaks objects I couldn’t open directly.

    • Given an object the requesting member cannot access in its own module surface (org scope or per-object permission), when I search a term that matches it, then it does not appear in my results.
    • Given a query matching another organization’s data, when I search, then zero cross-tenant rows are returned (every per-type query scoped to request.organization_id).
  • As a user with many matches, I want a way to see more without a wall of results, so that the surface stays scannable.

    • Given a type has more matches than its per-type cap, when the group renders, then it shows a “See all in <lane>” affordance that routes to that lane’s own list/search, rather than expanding inline unbounded.
    • Given a types filter (e.g. ?types=deal,company), when supplied, then only those object types are queried and returned.

3. Scope Boundaries

  • In: one fan-out read search endpoint GET /api/search/; the search_documents tenant-safe PostgreSQL FTS projection per ADR-018 (denormalized projection, PARTITION BY HASH(tenant_id), unaccent + lumi_vi_unaccent accent-insensitive Vietnamese matching, trigger-maintained weighted tsvector, GIN + trigram indexes, RLS, permission-in-SQL, source-table hydration on read); results grouped by type; the { type, id, title, subtitle, deep_link } row contract; the { lane, screen, id } deep-link descriptor (the F79 §2.7 contract); type filter; per-type result cap; permission-respecting results; the search surface reachable from every mobile tab top rail + Hub directory, and from web.
  • Out (the deferred items per ADR-018 are the explicit out-of-v1 list):
    • External search-engine infrastructure — Elasticsearch / OpenSearch / Meilisearch / Typesense. Over-engineering for current SME scale (ENGINEERING.md → Scale-Ready Design); PostgreSQL FTS is the v1 engine.
    • Cross-tenant global search — F80 is cross-object, single-tenant only; RLS makes other tenants invisible by construction.
    • Semantic / vector / embedding search — keyword + FTS only; semantic ranking is a later, separately-scoped layer.
    • ADR-018 Phase 2 / hardening (addable later without a schema migration): the 3-role DB split, async indexing tiers (v1 indexes synchronously via transaction.on_commit), and the trigram fuzzy fallback query (the trigram index ships in v1).
    • Write actions from search (rename, assign, create) — search is read-only navigation.
    • AI / semantic / embedding search as v1 — keyword/substring only; semantic ranking is a later, separately-scoped layer.
    • Saved searches / search history / typeahead suggestions — not v1.
    • Re-implementing per-domain deep search; F80 fans out to lightweight matches and defers depth to each lane’s own list/search via “See all”.

4. Data Model

No new ORM business model. F80 introduces no UUIDModel and no EvidenceSource obligation (it surfaces existing records; it does not generate AI output).

It does introduce one infrastructure projection table, search_documents, defined by raw-SQL migration per ADR-018 — not a business model. Source tables remain authoritative; search_documents is a denormalized candidate index that each searchable entity writes into. The full schema (composite PK (tenant_id, id), PARTITION BY HASH(tenant_id) × 32, trigger-maintained weighted tsvector, title_norm, GIN + trigram indexes, RLS policy, gen_random_uuid() id — documented UUID7 exception) lives in ADR-018 §Decision; this spec does not restate it. The migration is Postgres-guarded (connection.vendor == 'postgresql'), so on the SQLite test harness the table does not exist and the suite is unaffected.

Searched entity types and their human-facing match fields (each source model already organization-scoped, all UUIDModel); these are the rows projected into search_documents:

TypeModel · appMatch fieldsTitleSubtitle
taskwork.Tasktitle, descriptiontask titleproject name / status
projectwork.Projectname, descriptionproject namestatus
dealcrm.Dealtitledeal titlecompany name · amount
companycrm.Companyname, tax_code, websitecompany nameindustry
personcrm.Contact + organizations.OrganizationMembercontact name/email/phone; member user.name/user.emailnameemail / company / role
dochub.Doctitle, body_md (reuse /hub-docs/search/ query path)doc titlefolder / updated
messagemessaging.Messagemessage body (reuse search_messages)snippetchannel · author
settingsstatic catalog (no DB)label/keywords of known settings routessettings labelsection

A thin optional SavedSearch model is a possible later addition, not v1 — no v1 ORM business model.

5. API Contract

MethodPathAuth/RoleRequestResponseErrors
GET/api/search/member (IsOrgMember)?q=<term> (required, min 2 chars) · ?types=task,project,deal,company,person,doc,message,settings (optional CSV; default all) · ?limit=<n> (optional per-type cap, default 5, max 10)200 {results: [...], total}400 (q missing / q shorter than min length) · 401 · 403

Request rules

  • q is trimmed; length < 2 → 400 {error: "query_too_short"} (the client should never send it; the server enforces it as a boundary check).
  • types is an allowlist; unknown type tokens are ignored. Empty/absent → all supported types.
  • limit clamped to [1, 10]; default 5.

Response shape

{
  "results": [
    {
      "type": "deal",
      "id": "0192f3a1-....",
      "title": "Acme Q3 expansion",
      "subtitle": "Acme Corp · ₫120,000,000",
      "deep_link": { "lane": "business", "screen": "deal", "id": "0192f3a1-...." }
    },
    {
      "type": "task",
      "id": "0192f3b2-....",
      "title": "Send Acme deposit invoice",
      "subtitle": "Q3 Onboarding · in_progress",
      "deep_link": { "lane": "work", "screen": "task", "id": "0192f3b2-...." }
    }
  ],
  "total": 2
}

Clients group the flat results array by type and render groups in a fixed order (e.g. task, project, deal, company, person, doc, message, settings); the server returns the per-type cap already applied. A group at its cap implies a “See all in <lane>” affordance, using deep_link.lane as destination context through the platform’s allowlisted resolver.

Deep-link descriptor schema (the F79 §2.7 contract)

deep_link := { "lane": <Lane>, "screen": <string>, "id": <uuid|null> }
RouteNamespace := "assistant" | "pulse" | "work" | "business" | "workspace" | "company"
Lane := RouteNamespace
  • lane is a route namespace, not a bottom-tab count. The only permanent shell tabs are Lumi · Work · Business · Hub; assistant, pulse, and company are nested destinations, while workspace is the existing Hub-owned compatibility route used by settings results.
  • screen — destination screen key within that lane (task, project, deal, company, contact, doc, message, or a settings screen key).
  • id — target object id, or null for catalog/settings destinations that need no id.
  • The descriptor is lane/screen abstract, not a web URL, so the same contract serves the web shell and the Compose Multiplatform native shells (ADR-015). Each shell owns the descriptor→destination mapping; the API never embeds a platform-specific path.

Implementation (per ADR-018): the endpoint and response contract above are frozen and unchanged, but the implementation is the projection-backed FTS query over search_documents, not icontains. It uses unaccent accent-insensitive matching, websearch_to_tsquery, and applies the permission predicate in SQL before ranking, then hydrates and re-checks tenant + permission from source tables before returning. The projection’s internal row shape (entity_type, entity_pk, rank) is mapped to the F80 contract at the API boundary: entity_type → type, entity_pk → id, and deep_link built per ADR-016. Client UIs are therefore unaffected by the backend swap from the interim icontains fan-out to FTS.

Tenant isolation (Rule 1): the projection query filters tenant_id/organization_id and is backed by RLS (FORCE, transaction-local set_config('app.tenant_id', …, true) — never session-level, because Neon pools connections). Permission-respect: the visibility predicate is applied in SQL before ranking, and source hydration re-checks each module’s existing visibility before a row is returned, so search cannot widen access. A result the member could not open directly must never surface through search. Cross-tenant test is mandatory.

Scale / future path (mechanical, not built now): the do-once pieces (projection, partitioning, composite PK, unaccent config, RLS) are in v1. The deferred upgrades — 3-role DB split, async indexing tiers, trigram fuzzy fallback query — are addable later without a schema migration (ADR-018 §Deferred), behind the same endpoint contract, so the response shape and deep-link contract do not change.

6. UI / UX

  • Surface: a global search panel/overlay opened from the persistent top-rail search bar/pill present on every F79 tab, and listed as a directory entry in Hub. Opening search never changes the active bottom tab; it overlays the current tab.
  • States:
    • Idle / below min length: organization-scoped recent destinations (maximum five) plus quick jumps to Today, CEO Brief, Tasks, Deals, and Lumi; no request. Recent destinations must never be shared between organizations.
    • Loading: skeleton rows under group headers (debounced; no spinner flash on every keystroke).
    • Results: groups by type with a type header + icon, capped rows, and a “See all in <lane>” affordance per capped group.
    • Empty: “No matches for <q>” with the active type filters shown.
    • Error: inline “Search is unavailable” with retry; the host lane stays intact.
  • Interaction: client debounces input (~250ms) and enforces the min query length before calling /api/search/. Tapping a recognized row passes deep_link through the platform’s allowlisted resolver; web and native route mappers use the lane/screen context where supported, while unknown descriptors remain visible but non-navigating — never an arbitrary URL or wrong surface.
  • Components: Command/Dialog (command-palette pattern) or Sheet on mobile, Input, group headers, Skeleton, EmptyState, Badge for type — shadcn + Lumi tokens only (Rule 4).
  • Accent-insensitive (required): a Vietnamese query typed without diacritics must match accented content (khach hang → khách hàng). This is a required acceptance criterion (§2) and a required test (§7), delivered by the ADR-018 unaccent config — not a client concern.
  • i18n namespace: search (placeholder, group labels, “see all”, empty/idle/error copy) added to both en/ and vi/.
  • Native contract: the deep-link descriptor is platform-neutral; each shell maps {lane,screen,id} through its allowlisted navigation resolver. Compatibility mappers may use screen as the routing key while retaining lane as destination context; unknown descriptors remain non-navigating. One contract, multiple safe mappers.

7. Test Plan

See docs/test-plan/F80-global-search.md for the full TDD catalog. Summary:

#CaseTypeWhere
1Each type returns matches for a matching term (task, project, deal, company, person, doc, message, settings)backendbackend/tests/test_search.py
1aAccent-insensitive match: khach hang matches khách hàng (Postgres-gated, unaccent)backendsame
2All per-type queries scoped to request.organization_id; cross-tenant term returns zero rowsbackendsame
3A result the member cannot access in its own module does not appear in searchbackendsame
4q absent or <2 chars → 400 query_too_short; no query executedbackendsame
5?types=deal,company returns only those types; unknown tokens ignoredbackendsame
6?limit clamps to [1,10]; default 5; each group cappedbackendsame
7Every result row has {type,id,title,subtitle,deep_link} and deep_link matches the {lane,screen,id} schema per typebackendsame
8Search surface renders groups by type, shows organization-scoped recents + quick jumps at idle/min-length, loading, empty, and error statesfrontend/unitfrontend/src/test/intel/GlobalSearchDialog.test.tsx
9Tapping a result routes via deep_link to the right lane/screen; unmappable deep_link degrades gracefullyfrontend/unit + E2Esame; frontend/tests/e2e/lumi.spec.ts
10Client debounces and does not call /api/search/ below min lengthfrontend/unitsame

Backend coverage ≥97%. Frontend first slice must type-check cleanly.

8. Current Behavior

Built and shipped across the backend, web shell, and shared mobile shell:

  • backend exposes GET /api/search/ with the {type,id,title,subtitle,deep_link} contract
  • web ships the global search dialog / deep-link routing
  • shared KMP ships the mobile search screen + screen-model behavior
  • both iOS and Android shells mount the shared search surface from the F79 top rail and route result descriptors back into their native lane stacks

Remaining work is no longer “build search.” The honest remaining gaps are broader on-device verification and non-en/vi translation completion.

9. Error States

  • Endpoint fails / one type query errors: the endpoint returns the groups it could resolve and the client shows an inline “Search is unavailable” retry without tearing down the host lane; a single failing per-type subquery must not blank the whole response.
  • Query too short: client shows idle hint, never calls the API; server returns 400 query_too_short if called directly.
  • Unmappable deep_link: client renders the row as non-navigating display; a bad descriptor is an implementation bug and should be logged.

10. Diagrams & Docs Touched

  • backend/DIAGRAMS.md: add the /api/search/ fan-out request flow (one endpoint → N org-scoped per-type subqueries → grouped response). No ER change (no new model).
  • frontend/DIAGRAMS.md: add the global-search overlay and the deep_link → route mapping.
  • F79 §2.1 / §2.7: F80 is the implementation of the promised global search + deep-link contract; cross-reference both ways.
  • ADR-016: the {lane,screen,id} deep-link descriptor as a cross-cutting convention.
  • ADR-018: the search_documents tenant-safe PostgreSQL FTS projection — the search architecture behind this endpoint.

11. Implementation Audit

Backend ✅ shipped 2026-06-19. Web client ✅ shipped 2026-06-19. E2E ✅ added 2026-06-24.

#ClaimStatusEvidence
1GET /api/search/ fan-out endpoint exists, org-scoped per type✅ built (2026-06-19)apps/search/views.py GlobalSearchView; route in config/urls.py (api/search/); every _search_* filters organization_id=request.organization_id
2Response returns {type,id,title,subtitle,deep_link} rows with the {lane,screen,id} descriptor✅ built (2026-06-19)tests/test_search.py::test_b17_b18_row_and_deeplink_shape_per_type, ::test_b19_settings_deeplink_has_null_id
3Per-type queries respect existing per-object/role visibility (no leak through search)✅ built (2026-06-19)docs reuse status="published"; messages reuse search_messages channel-membership predicate; tests/test_search.py::test_b11_private_channel_message_not_leaked
4q min-length, types filter, and limit cap enforced server-side✅ built (2026-06-19)tests/test_search.py B12 (400 query_too_short), B13/B14 (types allowlist), B15/B16 (limit clamp [1,10] + per-type cap)
5Cross-tenant isolation test passes for every searched type✅ built (2026-06-19)tests/test_search.py::test_b9_b10_cross_tenant_returns_zero
6Global search surface reachable from every F79 tab top rail + Hub directory✅ built (2026-06-19)Layout.tsx header search button + ⌘K hotkey (TanStack Hotkeys); uiStore.setCommandPaletteOpen shared by any surface
7Tapping a result routes via deep_link to the correct lane/screen detail✅ built (2026-06-19)src/lib/searchDeepLink.ts resolveSearchDeepLink(); maps recognized {lane,screen,id} → web route, falls back to the closest list when needed, and leaves unknown descriptors non-navigating
8Client debounce + min-length gate before calling the API✅ built (2026-06-19)useDebouncedValue(term, 300) in GlobalSearchDialog; SEARCH_MIN_CHARS=2 gate in useGlobalSearch; enabled: enabled && isLongEnough
9E2E proves search → result → deep-link navigation✅ added (2026-06-24)frontend/tests/e2e/search.spec.ts — 4 cases: dialog opens via button + ⌘K, min-length hint, project result → board route, task result → task list

Backend test run: tests/test_search.py — 27 passed (count corrected 2026-07-02; the suite grew by one case since the 2026-06-19 run that first recorded 26); apps/search 100% coverage; full suite 98.86% total coverage (≥97% gate).

2026-06-19 — Architecture changed to ADR-018 FTS projection (owner decision); deployed to production same day. The interim icontains fan-out has been replaced by the ADR-018 FTS projection: apps/search/projections.py populates search_documents (accent-insensitive unaccent, tenant-safe partitioned by tenant_id, RLS, permission-in-SQL, source hydration); apps/search/views.py routes through apps/search/services.py which is engine-aware — Postgres runs the FTS projection; other test engines fall back to icontains. The production deployment (2026-06-19) fixed the accent-blind defect; three production bugs caught pre-release (RLS policy nullif(), allowed_user_ids::uuid[] cast, app.tenant_id session var). Accent-insensitive Vietnamese matching (§2) and the Postgres-gated test cases (§7) are satisfied.

12. Open Questions / Decisions

  • Decision: v1 is keyword/substring fan-out over existing indexed columns — no new search engine (ENGINEERING.md → Scale-Ready Design). The trigram → FTS → external-index path is mechanical and stays behind the same endpoint.
  • Decision: results are read-only navigation; no write actions from search in v1.
  • Decision: the deep-link descriptor is platform-neutral {lane,screen,id} so web and Compose Multiplatform shells share one contract (recorded in ADR-016).
  • Open question: ranking across types when many groups are at their cap — v1 ships per-type ordering + “see all”; cross-type relevance ranking (and FTS SearchRank) is the first follow-up if users report the cap hides the right answer.
  • Open question: whether settings should be searched server-side or client-side from a static catalog — v1 leans static catalog (no DB), surfaced through the same grouped response shape.

13. Bug / Change Log

2026-08-04 — Web search results open CRM and task detail panes

Type: Bug fix / Web navigation reliability

Summary:

  • The web mapper previously sent deal, company, contact, and task descriptors to list roots even when the descriptor contained an id. That broke F80’s promise that a result opens the same record-level surface as other doorway links.
  • Search descriptors now map to the existing query-driven detail panes (?deal=, ?company=, ?contact=, and ?task=). CompaniesPage also derives its selected 360 record from ?company= so refresh, shared links, and Back retain one source of truth.

Evidence:

  • frontend/src/test/intel/GlobalSearchDialog.test.tsx covers all four record detail mappings.
  • frontend/src/test/crm/CompaniesPage.test.tsx covers company query selection and Back recovery. Browser runtime and named-device parity remain separate evidence dimensions.

2026-07-14 — Scope idle recents to the active organization

Type: Security and UX bug fix (W-SHELL-02, #1667)

  • The zero-keystroke command menu keeps recent result destinations under an organization-specific local-storage key; an organization switch cannot reveal titles or subtitles visited in another tenant.
  • The legacy unscoped key is intentionally ignored instead of migrated because its tenant provenance cannot be proven.
  • Focused frontend coverage proves organization isolation, persistence, clearing, quick jumps, keyboard activation, Escape dismissal, and focus restoration to the search trigger.

2026-07-02 — Fix stale test paths: test_global_search.py → test_search.py; GlobalSearch.test.tsx → GlobalSearchDialog.test.tsx (batch 3)

Type: Documentation

Summary:

  • §7 row 1 referenced backend/tests/test_global_search.py — this file does not exist; the actual file is backend/tests/test_search.py (verified by ls backend/tests/).
  • §7 row 8 referenced frontend/src/test/GlobalSearch.test.tsx — the actual file is frontend/src/test/GlobalSearchDialog.test.tsx (verified by find frontend/src -iname '*earch*.test.tsx').
  • Rows 2–7 and 9–10 use “same” which now resolves to the corrected filenames.
  • No code changes.

2026-07-02 — Spec drift fix: correct §11 backend test count (26 → 27)

Type: Documentation (spec accuracy, docs-only)

  • §11 recorded tests/test_search.py — 26 passed from the 2026-06-19 run; backend/tests/test_search.py now contains 27 tests. Updated the count to match the file.

2026-06-19 — New spec: global cross-object search (F80)

Type: Feature

Summary:

  • Introduced F80 to satisfy F79 §2.1 (one search across tasks/projects/deals/companies/docs/messages/people/settings) and §2.7 (tap-to-deep-link), approved as a scope change by the product owner.
  • Specified the GET /api/search/ fan-out endpoint, the grouped {type,id,title,subtitle,deep_link} row contract, and the platform-neutral {lane,screen,id} deep-link descriptor.

Reason:

  • Today only per-domain search exists (/hub-docs/search/, /messages/search/); the mobile shell promises a unified search surface with deep-link navigation and had no spec.

Impact:

  • Defines one read-only cross-object search contract shared by web and the Compose Multiplatform native shells; no new model, no search-engine infra.

Tests:

Notes:

  • Elasticsearch/materialized index, write actions, and AI semantic search are explicitly out of v1.

2026-06-19 — backend deployed to production

  • /api/search/ (projection-backed FTS, ADR-018) shipped to dev then prod via the ECS Express pipeline. Neon migration applied cleanly on both. Live probe: https://api.runlumi.app/api/search/ → 401 (auth-gated), health 200.
  • Three production bugs caught by the Postgres-gated suite before release: RLS policy missing nullif(); indexing allowed_user_ids lacked ::uuid[] cast; indexing did not set app.tenant_id (FORCE-RLS WITH CHECK rejection).
  • Remaining (clients, not built): shared-KMP search results screen + the web search results surface that consume /api/search/ and route via the deep-link descriptor. Backend + contract are ready for them.

2026-06-19 — client surfaces shipped

  • Shared KMP SearchScreen (debounced, min-2-char, results grouped by type) + LumiApiClient.search(); consumed on Android (SearchNavKey, top-rail trigger, routeDeepLink) and iOS (SharedScreen.search sheet + result→SharedLaneRoute). :shared:jvmTest 191 green (+7 model tests).
  • Web GlobalSearchDialog (⌘K from the shell header), useGlobalSearch, lib/searchDeepLink router; search i18n namespace across all locales (en+vi translated). tsc 0, design-token guard pass, suite green (+8 dialog tests).
  • Deep-link routing maps the ADR-016 {lane, screen, id} to each shell’s existing destinations; where an id-detail route is absent it falls back to the closest list (parity across web/Android/iOS), while an unknown descriptor remains visible but non-navigating.
  • Remaining for F80: backend/frontend runner execution, browser execution, named Android/iOS verification, production PostgreSQL/RLS evidence, and the unimplemented “See all in <lane>” capped-group affordance. for non-en/vi locales (currently English fallback).

2026-06-24 — E2E suite added; spec audit updated

  • Added frontend/tests/e2e/search.spec.ts (4 cases: dialog open via button + ⌘K, short-query hint, project result → board route, task result → task list).
  • Updated §11 audit: items 6–9 now ✅; header note updated.

2026-07-02 — Spec drift correction: check shipped ACs against §11 audit

Type: Documentation

Summary:

  • Corrected 12 of 13 acceptance criteria from [ ] to [x] based on §11 Implementation Audit confirmation.
  • Criteria checked: (1) fan-out query across all types; (2) min-length client gate + idle hint; (3) results grouped by type with per-type cap; (4) accent-insensitive Vietnamese matching; (5) search reachable from every tab without switching tabs; (6) Hub directory entry; (7) result row carries {type,id,title,subtitle,deep_link}; (8) deep-link routing to correct lane/screen per type; (9) unmappable deep-link degrades gracefully; (10) per-object visibility respected (no leaked objects); (11) cross-tenant isolation (zero rows); (13) ?types= filter.

Left unchecked ([ ]):

  • (12) “See all in <lane>” affordance per capped group — §11 has no explicit audit row confirming this UI affordance was built; conservative.

Evidence: §11 audit rows 1–9 (all ✅ 2026-06-19/2026-06-24); 2026-06-19 architecture change note (“Accent-insensitive Vietnamese matching §2 satisfied”); 2026-06-19 client surfaces log.

2026-06-24 — KMP SearchScreenModel migrated to LoadState

Type: Contract migration (merge-blocking UX-consistency fix)

Summary:

  • SearchUiState rewritten from isLoading: Boolean + error: String? + results: List<SearchResultItem> (pre-contract boolean-soup) to results: LoadState<SearchResponse> — the last KMP presentation model to reach LoadState compliance.
  • runSearch() now uses LoadState.Loading(last = prevData) for stale-while-revalidate: pull-to-refresh no longer flashes a skeleton over existing results.
  • grouped map removed from state; computed inline in SearchScreen.kt from visible.groupBy { it.type }.
  • 8 SearchScreenModelTest tests rewritten to assert on LoadState<T>; 1 new test for stale-while-revalidate.
  • 4 dead/broken files deleted: ChannelsViewModel.kt (orphaned — replaced by shared KMP MessagesScreenModel), ChannelsViewModelTest.kt (two copies), HubViewModelTest.kt (broken import — HubViewModel deleted when Hub migrated to shared KMP).

Impact:

  • SearchScreenModel is now the last KMP presentation model at LoadState compliance — 100% across all KMP screens.
  • Refresh no longer blanks the result list on slow 3G connections (Vietnam, Indonesia, Philippines).
  • Net: 0 new screens; dead code subtracted.

Branch: mobile/kmp-search-loadstate-contract

  • Updated MVP_SCOPE.md: web frontend column flipped to ✅.