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-018unaccent+lumi_vi_unaccent).
- Given the authenticated shell, when I type a query of at least the minimum length (2 characters), then
-
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
Hubtab, when it opens, then global search is one of its directory entry points (F79 §2.6).
- Given any of the four primary tabs (
-
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 }wheredeep_linkis 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_linkcannot 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.
- Given a result row, when the response includes it, then it carries
-
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
typesfilter (e.g.?types=deal,company), when supplied, then only those object types are queried and returned.
- Given a type has more matches than its per-type cap, when the group renders, then it shows a “See all in
3. Scope Boundaries
- In: one fan-out read search endpoint
GET /api/search/; thesearch_documentstenant-safe PostgreSQL FTS projection per ADR-018 (denormalized projection,PARTITION BY HASH(tenant_id),unaccent+lumi_vi_unaccentaccent-insensitive Vietnamese matching, trigger-maintained weightedtsvector, 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:
| Type | Model · app | Match fields | Title | Subtitle |
|---|---|---|---|---|
task | work.Task | title, description | task title | project name / status |
project | work.Project | name, description | project name | status |
deal | crm.Deal | title | deal title | company name · amount |
company | crm.Company | name, tax_code, website | company name | industry |
person | crm.Contact + organizations.OrganizationMember | contact name/email/phone; member user.name/user.email | name | email / company / role |
doc | hub.Doc | title, body_md (reuse /hub-docs/search/ query path) | doc title | folder / updated |
message | messaging.Message | message body (reuse search_messages) | snippet | channel · author |
settings | static catalog (no DB) | label/keywords of known settings routes | settings label | section |
A thin optional SavedSearch model is a possible later addition, not v1 — no v1 ORM business model.
5. API Contract
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| 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
qis trimmed; length< 2→400 {error: "query_too_short"}(the client should never send it; the server enforces it as a boundary check).typesis an allowlist; unknown type tokens are ignored. Empty/absent → all supported types.limitclamped 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
laneis a route namespace, not a bottom-tab count. The only permanent shell tabs areLumi·Work·Business·Hub;assistant,pulse, andcompanyare nested destinations, whileworkspaceis 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, ornullfor 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 passesdeep_linkthrough 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) orSheeton mobile,Input, group headers,Skeleton,EmptyState,Badgefor 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-018unaccentconfig — not a client concern. - i18n namespace:
search(placeholder, group labels, “see all”, empty/idle/error copy) added to bothen/andvi/. - Native contract: the deep-link descriptor is platform-neutral; each shell maps
{lane,screen,id}through its allowlisted navigation resolver. Compatibility mappers may usescreenas the routing key while retaininglaneas 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:
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Each type returns matches for a matching term (task, project, deal, company, person, doc, message, settings) | backend | backend/tests/test_search.py |
| 1a | Accent-insensitive match: khach hang matches khách hàng (Postgres-gated, unaccent) | backend | same |
| 2 | All per-type queries scoped to request.organization_id; cross-tenant term returns zero rows | backend | same |
| 3 | A result the member cannot access in its own module does not appear in search | backend | same |
| 4 | q absent or <2 chars → 400 query_too_short; no query executed | backend | same |
| 5 | ?types=deal,company returns only those types; unknown tokens ignored | backend | same |
| 6 | ?limit clamps to [1,10]; default 5; each group capped | backend | same |
| 7 | Every result row has {type,id,title,subtitle,deep_link} and deep_link matches the {lane,screen,id} schema per type | backend | same |
| 8 | Search surface renders groups by type, shows organization-scoped recents + quick jumps at idle/min-length, loading, empty, and error states | frontend/unit | frontend/src/test/intel/GlobalSearchDialog.test.tsx |
| 9 | Tapping a result routes via deep_link to the right lane/screen; unmappable deep_link degrades gracefully | frontend/unit + E2E | same; frontend/tests/e2e/lumi.spec.ts |
| 10 | Client debounces and does not call /api/search/ below min length | frontend/unit | same |
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_shortif 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_documentstenant-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.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | GET /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 |
| 2 | Response 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 |
| 3 | Per-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 |
| 4 | q 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) |
| 5 | Cross-tenant isolation test passes for every searched type | ✅ built (2026-06-19) | tests/test_search.py::test_b9_b10_cross_tenant_returns_zero |
| 6 | Global 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 |
| 7 | Tapping 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 |
| 8 | Client 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 |
| 9 | E2E 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
settingsshould 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.tsxcovers all four record detail mappings.frontend/src/test/crm/CompaniesPage.test.tsxcovers 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 isbackend/tests/test_search.py(verified byls backend/tests/). - §7 row 8 referenced
frontend/src/test/GlobalSearch.test.tsx— the actual file isfrontend/src/test/GlobalSearchDialog.test.tsx(verified byfind 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 passedfrom the 2026-06-19 run;backend/tests/test_search.pynow 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:
- Defined in §7 and docs/test-plan/F80-global-search.md; implementation begins tests-first.
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(); indexingallowed_user_idslacked::uuid[]cast; indexing did not setapp.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.searchsheet + result→SharedLaneRoute).:shared:jvmTest191 green (+7 model tests). - Web
GlobalSearchDialog(⌘K from the shell header),useGlobalSearch,lib/searchDeepLinkrouter;searchi18n 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:
SearchUiStaterewritten fromisLoading: Boolean + error: String? + results: List<SearchResultItem>(pre-contract boolean-soup) toresults: LoadState<SearchResponse>— the last KMP presentation model to reach LoadStatecompliance. runSearch()now usesLoadState.Loading(last = prevData)for stale-while-revalidate: pull-to-refresh no longer flashes a skeleton over existing results.groupedmap removed from state; computed inline inSearchScreen.ktfromvisible.groupBy { it.type }.- 8
SearchScreenModelTesttests rewritten to assert onLoadState<T>; 1 new test for stale-while-revalidate. - 4 dead/broken files deleted:
ChannelsViewModel.kt(orphaned — replaced by shared KMPMessagesScreenModel),ChannelsViewModelTest.kt(two copies),HubViewModelTest.kt(broken import —HubViewModeldeleted when Hub migrated to shared KMP).
Impact:
SearchScreenModelis now the last KMP presentation model at LoadStatecompliance — 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 ✅.