# Search Content Onboarding — Registry & Prefetch Trigger Reference

> **Feature:** AB#2561 — Local Search Content Registry
> **Source of truth:** `ALPAMobile.Infrastructure/Persistence/Search/SearchContentRegistry.cs` —
> this doc mirrors it. If they disagree, the code wins and this doc is stale; update both
> together.
> **Last verified against the code:** 2026-08-26 (security-trim clearing + benchmarking pass)

---

## TLDR

Two independent questions, easy to conflate, tracked separately below:

1. **Onboarded** — is a cached API response indexed for local search at all
   (`SearchContentRegistry`'s `Onboarded` flag)?
2. **Trigger** — WHEN does that content actually get fetched in the first place, so there's
   something to index? A type can be `Onboarded=true` and still have empty search results if
   nothing ever prefetches it — the member has to organically visit that screen first.

## Current trigger model (as implemented today)

`ALPAMobile/Services/CacheContentService.cs` is the app's existing cold-start prefetch — not a
new mechanism this feature invents. Two events drive it: `IAuthentication.IsAuthenticatedChanged`
→ `true`, and `Connectivity.ConnectivityChanged` → `NetworkAccess.Internet`. Both call
`FetchContentAuthorized` then `CacheContentAsync`, each guarded by an idempotency flag
(`prefetchedAuth`/`cached`) so they run once per session, not on every event.

- **`FetchContentAuthorized`** — needs only network (no WiFi gate). Login-critical content:
  page banners, documents, member profile, flight-search airports, international directory, MEC
  hotels, resource links.
- **`CacheContentAsync`** — `GetAppConfigSettingsAsync` needs only internet; everything else is
  gated behind `Connectivity.ConnectionProfiles.Contains(ConnectionProfile.WiFi)` specifically —
  deliberately throttled so a cellular/constrained connection never gets bulk-fetched against.
- Both walks are **sequential** (`await` chains, not parallel) with a cancellation check between
  every call, and abort cleanly if connectivity drops or the member signs out mid-walk. This is
  already the "slow, deliberate, connectivity-aware" shape a search-content warm-up needs — see
  *Refactor* below.
- Runs **once per app session today**, not on every cold boot. See *Future Direction*.

## Onboarding + trigger matrix

| Cache key | Area | Friendly type | Search-onboarded | Prefetch trigger |
|---|---|---|---|---|
| `GetDocumentListAsync` | Documents | Document | **Yes** (first wave) | `FetchContentAuthorized` (login) |
| `GetNotificationListAsync` / `GetFlightNotificationListAsync` | Notifications | Notification | **Yes** (first wave) | **None — organic only** ⚠️ |
| `GetKCMAirportItemsAsync` | KCM | KCM Airport | **Yes** (first wave) | `CacheContentAsync` (WiFi) |
| `GetKCMAirlineItemsAsync` | KCM | KCM Airline | **Yes** (first wave) | `CacheContentAsync` (WiFi) |
| `GetAirlineJumpseatPoliciesAsync` | Jumpseat | Jumpseat Policy | **Yes** (second wave) | `CacheContentAsync` (WiFi) |
| `GetJumpseatAirlinesAsync` | Jumpseat | Airline | **Yes** (third wave) — deliberately duplicates Jumpseat Policy's rows/destination, onboarded by direct request | `CacheContentAsync` (WiFi) |
| `GetMECEventsAsync` / `GetLECEventsAsync` | MEC/LEC | Event | **Yes** (second wave) † | `CacheContentAsync` (WiFi, if `member.mec`/`lec`) |
| `GetMECRepsAsync` / `GetLECRepsAsync` | MEC/LEC | Representative | **Yes** (second wave) † | `CacheContentAsync` (WiFi, if `member.mec`/`lec`) |
| `GetCompanyInfoAsync` | MEC/LEC | Company Contact | **Yes** (second wave) † | `CacheContentAsync` (WiFi, if `Is_MEC_Contact_List_User()`) |
| `GetMECHotelAirportsAsync` | MEC/LEC | Hotel | **Yes** (second wave) † | `FetchContentAuthorized` (login, unconditional) **and** `CacheContentAsync` (WiFi, conditional) — double-fetched, see *Gaps* |
| `GetMECCommitteesAsync` | MEC/LEC | Committee | **Yes** (second wave) † | `CacheContentAsync` (WiFi, if `member.mec`) |
| `GetInternationalDirectoryAsync` | International Directory | Country | **Yes** (second wave) | `FetchContentAuthorized` (login) |
| `GetResourceLinksAsync` | Member Resources | Resource Link | **Yes** (second wave) | `FetchContentAuthorized` (login) |
| `GetCallToActionLinksAsync` | Member Resources | Call to Action | **Yes** (second wave) | **None — organic only** ⚠️ |
| `GetInCaseOfAccidentListAsync` | In Case of Accident | Accident Topic | **Yes** (second wave) | `CacheContentAsync` (WiFi, if logged in) |
| `GetMenuItems` | Navigation | Menu Item | **Yes** (third wave) ‡ | Own mechanism — `MenuService` reads/writes its own cache proactively, outside `CacheContentService` |
| `GetFlightSearchAirportsAsync` | Flight Search | Airport | **Yes** (third wave) † | `FetchContentAuthorized` (login) |
| `GetAllSubscriptionsForUserAsync` | Subscriptions | — | Excluded | not fetched by `CacheContentService` |
| `GetPageBannersAsync` | Page Chrome | — | Excluded | `FetchContentAuthorized` (login) + `FetchContentUnAuthorized` (signed-out) |
| `GetAppConfigSettingsAsync` | App Config | — | Excluded | `WarmAppConfigSettings` (`App.CreateWindow`) + `CacheContentAsync` (internet, not WiFi-gated) |
| `GetMemberAsync` | Member Profile | — | Excluded (privacy) | `FetchContentAuthorized` (login) + `CacheContentAsync` (redundant second call) |
| `"ALPALogCache"` | Diagnostics | — | Excluded (not an API response) | n/a — log queue, never prefetched |

† These six (Event, Representative, Company Contact, Hotel, Committee, and Flight
Search Airport) are indexed and searchable but have **no real screen to open yet**. For the first
five, `MECPage`/`PilotGroup*` were retired under AB#2500 and `DeepLinkResolver` has no mapping for
them; onboarded ahead of the real screens on the call that the MEC surface is coming back as
dynamic-feed-hydrated RCL components. For Flight Search Airport, no Blazor flight-search entry
point exists yet to deep-link an origin/destination pick into. Their `SearchIndexEntry.Route` is
empty and `ViewNotImplemented` is `true`; the search page shows a "not implemented yet" notice on
tap instead of navigating. Wire up each one's real `Route` in its extractor
(`ALPAMobile.Infrastructure/Persistence/Search/Extractors/`) once that screen exists.

‡ Menu Item's `Route` is the raw, unresolved server deep-link convention (a Shell page class name
plus optional query — e.g. `"AdvocacyPage"`, `"DocumentsPage?Scope=KCM&Category=KCM"` — see
`DeepLinkResolver`'s remarks), not a Blazor route. `ALPAMobile.Infrastructure` cannot reference
`DeepLinkResolver` (it lives in `ALPAMobile.Presentation`), so `MenuItemSearchExtractor` carries
the raw string through unresolved; `SearchPage.OpenResultAsync` resolves it via `DeepLinkResolver`
at tap time — the same Presentation-layer dispatch point that already owns every other
Route-decision for a search result, and the same convention `MenuItemCardFactory` and
`NotificationContentRouter` already resolve elsewhere. Menu Item is `SecurityTrimmed` — its own
menu structure varies by the member's entitlements/MEC (observed live: the bottom tab bar's 5th
slot and other rows differ by which MEC the member belongs to).

## Gaps found (2026-08-25)

1. **Notifications has no prefetch trigger** despite being search-onboarded (first wave) —
   search results for the Notifications area stay empty until a member happens to open the
   Notifications tab. Closed by the registry-driven refactor below, not a hand-added call.
2. `GetCallToActionLinksAsync` also has no prefetch trigger (next-wave onboarding, lower
   priority than #1).
3. `GetMECHotelAirportsAsync` and `GetMemberAsync` are each fetched twice (once in
   `FetchContentAuthorized`, once in `CacheContentAsync`). Harmless — `CacheDatabase.SetCache`
   is an upsert — but wasteful. Worth a follow-up cleanup; not blocking.

## Refactor: registry-driven prefetch

Closing gap #1 by hand-adding a call would repeat the exact mistake that created it — two
independently-maintained lists (the search registry and `CacheContentService`'s hardcoded walk)
silently drifting apart. Instead, `SearchContentDescriptor` gains a
`Fetch: Func<CancellationToken, Task>?` delegate alongside `Extract`.
`CacheContentService.CacheContentAsync` becomes a loop over the registry's onboarded entries
calling each one's `Fetch`, rather than a hand-written sequence of `await _xQueries.YAsync()`
calls — the registry is now the single source of truth for both "what gets indexed" and "what
gets prefetched," so they structurally can't diverge again.

Existing safety properties are preserved exactly, not redesigned: sequential awaits, a
cancellation check between every call, the WiFi gate, and the same catch-and-log-never-throw
shape the class's crash history established (`AB#2420` background-ANR, `AB#2411` native SIGSEGV
from an unobserved exception, `WI-1962`, `BUG-2067`, `BUG-2092` — all referenced inline in
`CacheContentService.cs`).

## Security-trim clearing (AB#2561, 2026-08-26)

Two independent behaviors were conflated until now: "does the search index get cleared on
logout" and "does onboarding re-run on the next login." The second already worked — see
*Re-seed-on-relogin* below — the actual gap was the first: `CacheDatabase.ClearCache()` only did
`cache.DeleteAll()` on the raw API-cache collection, never touching the `SearchIndex` collection.
A member's Documents/Notifications/MEC governance search results survived logout and stayed
queryable until their own re-fetch happened to overwrite them — a real exposure on a shared
device (a different member authenticating next could search and briefly see the outgoing
member's private content).

`SearchContentDescriptor` gained a `SecurityTrimmed` flag (default `false`). `ClearCache()` now
deletes `SearchIndex` rows for `SecurityTrimmed` cache keys only, immediately, synchronously with
the rest of the logout wipe:

| SecurityTrimmed = true (cleared on logout) | SecurityTrimmed = false (left in place) |
|---|---|
| Document, Notification (Comms + Flight), Event (MEC+LEC), Representative (MEC+LEC), Company Contact, Hotel, Committee | KCM Airport, KCM Airline, Jumpseat Policy, International Directory Country, Resource Link, Call to Action, Accident Topic |

The left column is per-member-private or derived from the outgoing member's own `mec`/`lec`
fields — wrong or sensitive for whoever logs in next. The right column is global or otherwise not
identity-filtered — safe to leave visible for the few seconds until the incoming member's own
prefetch naturally overwrites it (`IndexSearchContent`'s delete-then-insert by `CacheKey` already
does this regardless of whether `ClearCache()` touched that row).

**Live-validated 2026-08-26** against real DAL → FDX → UAL member logins on the same simulator
(`maui-devflow`, `ALPAMobile-UITEST-VAULT` creds) — the split above held up, no reclassification
needed this pass:

- `SecurityTrimmed: true` rows varied per member as expected: Document counts were 808 (DAL) /
  345 (FDX) / 1191 (UAL) with different titles each time; Notification content differed (DAL
  showed flight-tracking "Flight Changed" entries FDX/UAL didn't); MEC/LEC governance counts
  differed per member's own mec/lec (e.g. `GetCompanyInfoAsync` only populated for the FDX login
  — `Is_MEC_Contact_List_User()` true for that member, false for DAL/UAL). Immediately after each
  logout→relogin, `Search` showed **no DOCUMENT group at all** and no bleed-through of the
  outgoing member's Notification/Committee/Representative content — the clear-on-logout fix
  worked as intended.
- `SecurityTrimmed: false` rows came back with **identical counts across all three MECs** — KCM
  Airport (112), KCM Airline (76), Jumpseat Policy (130) every single login — hard evidence these
  are genuinely global/shared, not identity-filtered, confirming they're safe to leave alone.

Still not a permanent guarantee — a future payload/API change could shift a row's actual
scoping. If that's ever suspected, re-verify per this section's method and post findings to
AB#2561 as a work-item comment rather than silently reclassifying here alone.

## Re-seed-on-relogin (verified, no code change)

Confirmed by reading `Authentication.cs`: `IsLoggedIn` and `IsAuthenticated` are always set
together, both on login (e.g. `SilentLoginAsync`/token-exchange success) and on logout
(`RunLogOutTaskAsync`) — the `IsLoggedIn` setter fires `LoggedInChanged` (which drives
`DataManager.ClearCacheForAuthContextChange()`) strictly before the `IsAuthenticated` setter fires
`IsAuthenticatedChanged` (which drives `CacheContentService`'s prefetch). On the `false` leg,
`CacheContentService` already resets `cached`/`caching`/`prefetchingAuth`/`prefetchedAuth` to
`false` before the next `true` leg can fire — so a full logout followed by a different member
logging in on the same device already re-runs the entire onboarding walk with no code change
needed. `SettingsPageViewModel`'s environment-switch flow additionally calls
`CacheContentService.ResetAndRefreshAsync()` explicitly as a belt-and-suspenders for that
specific flow.

## Benchmarking (AB#2561, 2026-08-26)

`CacheContentService` now logs a `Stopwatch`-timed duration for every prefetch step, not just
start/end markers:

- `FetchContentAuthorized`: each login-critical step logs `"{name} completed in {ms}ms"`, plus a
  `"login-critical wave completed in {ms}ms"` summary.
- `CacheContentAsync`: each registry-driven target logs `"{cacheKey} prefetch completed in
  {ms}ms"` (or `"... prefetch failed after {ms}ms"` on error), plus an `"onboarding walk completed
  in {ms}ms"` summary. The two early-exit guards (no network, no WiFi) also log how long it took
  to hit that guard and why the rest of the walk was skipped — useful for confirming the
  constrained-network guard is actually engaging during a live benchmark.

Pull these live via `maui-devflow MAUI logs --source native` (or `MAUI logs -f` to stream) while
driving a login through `maui-devflow` against real DAL/FDX/UAL credentials.

**Live benchmark, 2026-08-26** (dedicated iOS 26.1 simulator, DAL → FDX → UAL logins in sequence):

| Member | Login-critical wave | Bulk (WiFi) walk | Dominant cost |
|---|---|---|---|
| DAL | 26879ms | 26486ms | `GetDocumentListAsync` 21843ms; first-run KCM map cache 15041ms |
| FDX | 25615ms | 4535ms | `GetDocumentListAsync` 24171ms; KCM maps already cached (6ms) |
| UAL | 29620ms | 8335ms | `GetDocumentListAsync` 27347ms |

`GetDocumentListAsync` is the consistent bottleneck across all three (22-27s) — the single best
target if onboarding needs to get faster. KCM map caching is a one-time cost per device (11MB,
~15s), not per-login, once cached.

**2026-09-07 (AB#2682):** KCM maps now go through the shared image cache. The ATL map ships inside the
package as the bundled example seed (78,735 B) and is copied in on first launch; the first-run WiFi walk
downloads the remaining 111 (measured 11,236,344 B for the full set on a fresh iPhone 17 Pro simulator).
The walk is registry-driven (`IImagePrefetchSource`); the KCM source is the only one registered.

## Follow-up (not built this pass)

If a member navigates to `SearchPage.razor` while the onboarding walk above is still running,
there is currently no visible indication search results may still be incomplete. Proposed:
a left-to-right progress indicator line under the search field, driven by walk-in-progress state.
UI/UX design + placement is its own follow-up task, out of scope here (AB#2561 itself scopes the
search UI/page as separate from this local-index feature).

## Future Direction (pending — record new asks here, dated)

New direction about prefetch cadence/scope lands here as it arrives, before it's built — this
section is the append point, not scattered notes elsewhere.

- **2026-08-25**: today's model prefetches **once per app session** (on login, or on regaining
  connectivity if that hadn't happened yet), not on every cold boot. No decision yet on whether
  search content should re-warm on every cold boot, on a timer, or stay session-once. Revisit
  once real direction comes in — don't build a boot-time trigger speculatively.
- **2026-08-26**: clear-on-logout is now security-trim-aware (see *Security-trim clearing*
  above) rather than a full nuke; re-seed-on-relogin was verified already-correct (no trigger
  change); per-step benchmarking/telemetry was added (see *Benchmarking* above). Still no
  boot-time re-seed trigger — unchanged from the 2026-08-25 entry above.
