Engineering · Proposed implementation plan
Service Layer Decoupling Plan
Extract the service layer in reviewable phases, preserve current behavior explicitly, and prove each milestone before integrating the complete refactor.
imp/blazor-hybrid-service-layer-decoupling. Documentation PRs may target imp/blazor-hybrid. The final implementation merge waits for full regression and review.Scope: DataManager, RestService, Authentication, API cache and prefetch, endpoint switching, generated and direct HTTP clients, and their consumers. Implementation progress and validation limits are recorded in the phase evidence; an automated pass does not imply a live device walkthrough. The PR 2522 correction pass is recorded in the review resolution and exact-revision validation.
Full contents and technical reference
Overview
| Today | Target |
|---|---|
DataManager.cs (3266 lines) implements 16 ports; 2 of 16 extracted (MenuService, FavoritesService) |
Focused Infrastructure implementations behind the existing ports; retire the temporary facade when the verified consumer inventory is empty (coordinate with AB#2764) |
RestService.cs (2323 lines): hand-rolled HTTP + 4 NSwag clients + bearer lifecycle + retry + UI snackbars |
Per-domain I<Domain>ApiClient ports over one explicitly owned transport policy; NSwag clients wrapped behind ports; RestService deleted |
IAuthentication (24 declared members), used across the head, Presentation, and test fakes |
Four narrow ports (ISessionState, IAuthTokenProvider, ICredentialStore, IAuthSession); IAuthentication kept as [Obsolete] composite; one shared fake |
Repeated “connectivity → auth-guard → fetch → SetCache → catch → GetCacheJSON” blocks |
One CachedRemoteQuery helper with two named policies (FallbackOnTransportFailure, StaleWhileRevalidate) |
Endpoints switched by RestService.SetEndpoints() reading AppProperties.DevEnv; IRestEndpointProvider is now consumed by infrastructure and head callers |
IOptionsMonitor<ApiEndpointOptions> as the source of truth, with an options-backed IRestEndpointProvider compatibility adapter until every consumer is migrated |
Direction retained from the merged plan (initial decisions 2026-09-16; safety additions 2026-09-19):
- Extracted services land in
ALPAMobile.Infrastructureonce — no head-first double move. RestServiceis deleted at the end; hand-rolled HTTP moves toIRequestProvider, SDK contract types stay behind Infrastructure ports; transport ownership is explicit for each adapter.IAuthenticationis split into narrow ports with the[Obsolete]composite trick already used forIDataManager.- The legacy API cache keeps Newtonsoft for on-disk payload compatibility; transport stays
System.Text.Json. - Authentication has two transport paths: an authenticated request port for application calls and an explicitly raw request port for token acquisition. DI binds both to one
RequestProviderinstance; the decorator must not wrap the auth call. - A cache coordinator owns auth-context generation, invalidation, and the write barrier. A request that started under an old session cannot repopulate memory, LiteDB, or the search index after logout.
0. Fresh review and how to use this plan
This review starts at the freshly pulled b5365544, which includes the merged safety revision. The numbers below describe checked-in source, not completed migration work. Each phase includes an example, the files it changes, and evidence required to call the phase complete. Proposed snippets are design excerpts, not complete classes or code claimed to compile today. New types and methods are explicitly identified; real source references are collected in the evidence table.
| Phase | Deliverable to review | Concrete demonstration |
|---|---|---|
| 0 — Safety baseline | Consumer inventory, cache barrier, connectivity/classifier characterization | A response released after logout cannot restore the cache or Search rows |
| 1 — Transport | Endpoint options, raw/authenticated request wiring, shared cache policy | A GET recovers once from 401; a write is not replayed |
| 2 — MEC pilot | Four query methods, six cache partitions moved behind IMECQueries |
Committees load live, then offline, under the same search key |
| 3 — Other domains | One focused slice per domain; Content first | Banner notifications, subscription invalidation, and router live/mock behavior survive |
| 4 — Documents | One document port facade composing catalogue, hydration, store, cache, and navigation | Two callers join one fetch; canceling one leaves the other working |
| 5 — Authentication | Narrow auth ports and ordered logout participants | Logout revokes the old context before event handlers can start new work |
| 6 — RestService retirement | Generated-client adapters and remaining endpoint/transport migrations | Logging sends once; the queue still owns retries; old service inventory reaches zero |
| 7 — DataManager retirement | FTDT adapter, final consumer cleanup, measured deletion gate | Every global query port resolves without constructing DataManager |
When validation becomes possible
The markers below are acceptance gates; current implementation status and unexecuted checks are recorded in the phase evidence. Automated tests can begin before a phase is complete; the marker is earned only when that phase’s numbered acceptance criteria and evidence pass. Each phase PR records criterion IDs as Pass / Fail / Not run, the tested SHA, artifact links and any explicit deferral. “Not run” is not a pass. A device demonstration applies only to the platforms actually exercised.
| Capability | Earliest meaningful validation | Integrated acceptance marker | Still deferred at that point |
|---|---|---|---|
| Cache keys, codecs, logout write barrier | Phase 0 controlled tests | Phase 0 baseline | Real migrated feature data flow |
| Endpoint hot-swap, HTTP retries/401/cancellation | Phase 1 handler/DI tests | Phase 1 foundation | All domain behavior; dynamic pilot if not yet migrated |
| One full extracted domain: MEC | Phase 2 tests + live/offline demo | Phase 2 first feature | Other domains and documents |
| Other feature reads/commands and prefetch | Each domain in the Phase 3 PR | Phase 3 all slices integrated | New documents and auth lifecycle |
| Catalogue cancellation/hydration/open/eviction | Phase 4 tests + document walkthrough | Phase 4 documents | New session lifecycle and transport deletion |
| Narrow auth, account switch and ordered logout | Phase 1 token-unit tests; full lifecycle in Phase 5 | Phase 5 session | Generated clients still using compatibility bridge |
| All REST/generated-client paths | Per-client tests in Phases 3–6 | Phase 6 transport retirement | Aggregate deletion and full stabilization |
| Whole refactor and base-branch merge readiness | Phase 7 integrated candidate | Phase 7 + final review/platform regression gate | Nothing may remain deferred for final completion |
Corrections made by this fresh source pass
| Merged-plan assumption | Verified source / correction |
|---|---|
| Four MEC cache fields; four new registry constants needed | Four methods use six memo fields/keys. The four MEC/LEC event/rep constants already exist; replace string call sites, do not redeclare keys. |
Copy DataManager.IsTransientNetworkError and remove the REST classifier |
The classifiers differ and already reuse ExceptionExtensions. Preserve REST-specific disposed-content and iOS cases and DataManager’s TLS EOF case before consolidating. |
IFileSystemService can replace every file call |
It exposes only CacheDirectory and AppDataDirectory; bundled reads need a platform operation; ordinary file operations can stay in Infrastructure with those paths. |
| WebView HTTP may be JSON authentication | AutoLoginWebViewContentView downloads a PDF using cookies. It is a binary exception; SentryEventProcessor sends no HTTP. |
New IAuthApiClient replaces IAuthRestClient |
Reuse the existing JWT port IAuthRestClient; rebind its implementation. Preserve non-2xx auth bodies before moving it onto raw transport. |
| FTDT REST contract moves unchanged to Application | IFTDTRestApi exposes generated DutyPeriodExpanded; a domain-owned mapping boundary is required first. |
| Exactly 13 legacy ViewModels require the facade until AB#2764 | Text matches include comments, exception messages and static message references. Inventory actual symbols and DI resolutions; do not turn an unverified count into a deletion gate. |
| Dynamic-feed refactor plan is not committed | It exists, and DynamicContentHost has already landed. Sequence against current source, not that plan’s historical current-state section. |
The example session event is a record inheriting EventArgs |
Use a class: C# records cannot inherit a non-record class other than object. |
Branch and PR policy
Documentation can land independently. Refactor implementation stays isolated until thoroughly tested. This is the agreed integration policy for the work, not an open design question.
| Change | PR target | When it can land |
|---|---|---|
| This plan, HTML conversion, documentation corrections | imp/blazor-hybrid |
Individual documentation PRs; do not bundle unstable implementation |
| Phase 0–7 implementation, including guards, infrastructure, DI, migrations and refactor tests | imp/blazor-hybrid-service-layer-decoupling |
One separate PR per phase, recording completed checks and any outstanding device evidence |
| Complete, stabilized refactor | imp/blazor-hybrid |
One final integration PR from imp/blazor-hybrid-service-layer-decoupling after the full regression gate and review |
imp/blazor-hybrid
├── documentation PRs ─────────────────────────────→ imp/blazor-hybrid
└── imp/blazor-hybrid-service-layer-decoupling
↑ phase 0 PR ↑ phase 1 PR … ↑ phase 7 PR
└── integrated build + unit + iOS/Android regression + review
└── final integration PR ─────────────→ imp/blazor-hybrid
Create the implementation integration branch from the current base when implementation begins. Keep it current by bringing base changes into the integration branch and resolving conflicts there; repeat affected checks after synchronization. Each implementation PR names its phase, the exact integration-branch baseline, completed evidence and remaining blockers. Do not cherry-pick partial refactor code onto the base branch. Do not assume the dedicated branch has the base branch’s policies: verify branch protection before using auto-complete; if no policy gate exists, use an explicit review and completion step.
The final PR must include the tested commit SHA, completed phase checklist, no unresolved release-blocking failures, cross-domain login/logout/offline/endpoint-switch results, Search and prefetch evidence, and the required iOS/Android regression runs. The build being merged must be the tested integrated revision; a rebase or conflict resolution requires revalidation. Implementation branch creation and refactor work are separate from documentation delivery.
Required design gates
The following decisions are implementation gates, not optional cleanup:
- Endpoint compatibility.
ApiEndpointOptionsis the single configuration source. An options-backedIRestEndpointProviderremains registered until a source/IL inventory proves that no consumer reads it. The environment switch must publish an explicit change signal in both Debug and Release;AppProperties.DevEnv’s Debug setter currently only changes an in-memory field.DynamicContentApiClientkeeps its site-scoped route, customAlpaWireJsonconverters, 15-second linked deadline, anddynamiccontent.*error semantics during the pilot. - Cache safety. Cache outcomes carry value, source, and error information. Caller cancellation and context invalidation never become stale fallback. Protected reads deny auth fallback by default; the characterized legacy exceptions (banners, Jumpseat policies/airlines, subscriptions, airport reference data and flight notifications) require explicit per-domain authorization-fallback flags recorded in Phase 0. General notifications and MEC data retain their auth gate. Shared work is single-flight without
Task.Run; the caller may abandon its wait while the shared operation continues with its own lifetime token. A generation captured at fetch start is checked before every memory, disk, and search-index write, and the raw cache write plus index update is atomic with respect to logout. - Retry ownership. There is one retry authority per client. The current raw provider retries all verbs, while the old service has operation-specific retries, including read-by-POST requests; Phase 1 must define a safe-read default (GET/HEAD) and no automatic write retry without an endpoint-specific idempotency guarantee. A 401 refresh has one independent replay allowance within the same overall deadline, even when transient retries are disabled; only GET/HEAD and explicitly replay-safe requests qualify. Do not stack a new resilience handler on top of the existing provider until the old policy is removed or explicitly disabled.
Retry-After, jitter, total time budget, and caller cancellation are part of the transport contract. - Boundary mapping. NSwag or wire JSON types are raw representations. A pure
RawRepresentationFactory<T>maps and validates raw data into domain models; services and cache code do not deserialize external JSON directly into domain collections. ExistingPodcastEpisodeRawFactoryand flight-search mappers are the reference idioms. - Ratchet accuracy. The head-consumer ratchet scans constructor fields and service-locator calls (
GetService<T>/GetRequiredService<T>), using source or IL analysis. A constructor-only allowlist would miss currentSharedActionsServiceandSettingsPageViewModelaccesses. - Lifetime ownership. Singleton services are thread-safe and own one subscription each. Cache invalidation is centralized; domain services do not independently subscribe to the same session event. Cross-domain prefetch orchestration remains Application-facing and moves out of the head only after its platform dependencies are ports.
These gates follow the Microsoft guidance for MAUI dependency injection, the .NET Options pattern, HTTP resilience, the cache-aside pattern, and the anti-corruption layer pattern. The ConcurrentDictionary.GetOrAdd contract explicitly permits a value factory to run more than once, so it is not a single-flight primitive by itself.
1. Purpose
Make the data/service layer readable and maintainable by giving every concern one home with one contract:
- a port in
ALPAMobile.Application(already exists for 16 concerns); - an implementation in
ALPAMobile.Infrastructure(net10.0, MAUI-free, covered byLayerDependencyRulesTests); - a transport behind
IRequestProvider(already exists, zero production callers); - a cache policy expressed once, instead of in each domain.
Everything MAUI-bound (Shell navigation, DisplayAlert, Launcher, Connectivity) stays in the head behind the platform ports that already exist (IConnectivityService, INavigationService, IDocumentActionNavigator, IFileSystemService, ILauncherService).
2. Current state at the pulled tip
2.1 Service and transport inventory
| File | Lines | Contract | What it actually owns |
|---|---|---|---|
ALPAMobile/Services/DataManager.cs |
3266 | IDataManager = 16 ports (IDataManager.cs (see source evidence), [Obsolete]) |
Domain queries with repeated cache-fallback blocks; document catalogue SWR + LiteDB hydration + prefetch; document open/navigation via Shell.Current; app-update UI workflow with DisplayAlertAsync/Launcher/Process.Kill; auth-context cache clearing; IsTransientNetworkError static |
ALPAMobile/Services/RestService.cs |
2323 | IRestService (53 members) + IFTDTRestApi |
HttpClient from IHttpClientFactory + 4 NSwag clients (ApiClientVersion1 (FTDT), PilotCommsApiClient, JumpseatApiClient, MobileMenuApiClient); SetEndpoints() from AppProperties.DevEnv; bearer fan-out; service-level retry and 401 recovery; string transport helpers; snackbar calls |
ALPAMobile/Services/Authentication.cs |
1280 | IAuthentication (24 declared members, including LastRefreshFailure) |
Session flags + events, JWT acquire/validate/force-refresh, MSAL bridge via IAuthClient, credential storage, post-login FTDT sync; logout side effects (cookies, cache clear, theme/feed invalidation via lazy IServiceProvider), account-locked UI |
ALPAMobile.Infrastructure/Remote/RequestProvider.cs |
209 | IRequestProvider |
Result<T>-returning GET/POST/PUT/PATCH/DELETE over the Polly DefaultHttpClient, finite backoff (3 retries at 200/400/800ms), transient status codes 408/429/5xx. Registered, contract-tested (RequestProvider_ContractTests.cs), no production caller |
ALPAMobile.Application/Abstractions/Remote/IRestEndpointProvider.cs + ALPAMobile.Infrastructure/Remote/DynamicContentApiClient.cs |
introduced after the original plan | Endpoint provider + authenticated dynamic-content caller | DynamicContentApiClient reads ApiBaseUrl per request, uses /api/mobilecontent/dynamic/site/{siteKey}/page/{pageKey}, custom AlpaWireJson converters, a 15-second linked deadline, and dynamiccontent.* error codes; current endpoint consumers must be inventoried before the provider is retired |
2.2 The cache layer (three separate things)
| Component | Where | Role |
|---|---|---|
CacheDatabase |
ALPAMobile.Infrastructure/Persistence/CacheDatabase.cs |
Concrete class, no interface. LiteDB APICacheModel collection keyed by a string (today nameof(method)). SetCache(uri, payload) also re-indexes the local search index via IndexSearchContent when the key is onboarded in SearchContentRegistry. ClearCache() wipes the API cache and security-trimmed search rows on auth-context change. |
| Shape A — fallback on failure | Multiple DataManager methods, e.g. GetPageBannersAsync, GetMECCommitteesAsync |
in-memory field → HasConnectiviy guard → ValidateAuthToken → RestService call → SetCache on success → catch → GetCacheJSON → Newtonsoft deserialize → return stale. MEC auth-required exceptions rethrow; other domains differ (see the Phase 0 behavior matrix). |
| Shape B — stale-while-revalidate | GetDocumentListAsync |
cached copy returned immediately; background refresh and foreground callers share a fetch; cancellation abandons only the caller’s wait (AB#2761). Corrupt cache = cache miss, not fallback. |
CacheContentService |
ALPAMobile/Services/CacheContentService.cs (namespace ALPADocs.Helpers) |
Prefetch orchestrator, not a cache. On IsAuthenticatedChanged/Connectivity.ConnectivityChanged it walks the 8 narrow *Queries ports (FetchContentAuthorized, CacheContentAsync, FlatPrefetchTargets keyed by SearchContentRegistry.Keys) to warm CacheDatabase, tracks per-step progress for the Search “preparing” UI, and separately drives IImageCacheService prefetch. Hard dependency on the concrete DataManager class via DataManager.IsTransientNetworkError. |
Hard constraint: cache keys are also search-index keys. Every extracted method must write the same key string it writes today (SearchContentRegistry.Keys.*), or the on-disk cache and the search index silently go cold after upgrade.
There is a second hard constraint: CacheDatabase.SetCache currently releases its write lock before IndexSearchContent takes a separate lock. A fetch that completes after logout can therefore re-create security-trimmed search rows that ClearCache just removed. Phase 0 must make the raw cache write and index update one coordinator-owned operation, and every in-flight fetch must carry the auth-context generation that was current when it started. A stale generation must not write memory, disk, or the index. Proposed protected-data rule: return an explicit context-invalidated outcome instead of delivering the old member’s result into the next session; characterize and approve that behavior in Phase 0.
2.3 What is already right (reuse, do not reinvent)
| Existing seam | Location | Used by the plan for |
|---|---|---|
16 narrow ports (IMECQueries, IContentQueries, …) |
ALPAMobile.Application/Abstractions/* |
Service contracts — unchanged in this plan |
MenuService / FavoritesService extraction |
ALPAMobile/Services/ |
Precedent for the delegation step in DataManager and the DI rebind |
IRequestProvider / RequestProvider / Result<T> |
Application / Infrastructure / Domain | Transport |
IAuthClient, ITokenStore (SecureTokenStore) |
Application / Infrastructure | Auth split starting point |
IConnectivityService (HasInternetAccess, IsOnWiFi, event) |
Application, head adapter MauiConnectivityService |
Replaces Connectivity.Current in services |
IDocumentDatabase / LiteDbDocumentDatabase |
Application / Infrastructure | Document hydration target |
IDocumentActionNavigator / SharedActionsNavigator, INavigationService / ShellNavigationService |
Application / head | Document open flow leaves DataManager |
IFileSystemService, ILauncherService, IMessenger |
Application ports | Bundled-file reads, store links, change messages |
Options configurators (PrefetchTagOptionsConfigurator, FTDTFeatureFlagsOptionsConfigurator) |
head | Pattern for ApiEndpointOptions |
SearchContentRegistry.Keys |
Infrastructure | Cache-key constants |
IRestEndpointProvider |
Application / head adapter | Compatibility port for endpoint consumers; backed by IOptionsMonitor<ApiEndpointOptions> during migration |
ThemeOptionsBridge and feature-flag change sources |
head | Existing examples for an explicit Options change-token bridge |
LayerDependencyRulesTests (NetArchTest) |
UnitTest/Architecture/ |
Extended with a head ratchet in Phase 0 |
2.4 Dependents that must keep working
| Dependent | Depends on | Re-pointed in |
|---|---|---|
CacheContentService |
8 *Queries ports, IAuthentication, CacheDatabase, DataManager.IsTransientNetworkError |
Phase 0 (classifier and platform events), Phase 3 move to Application-facing orchestration, Phase 5 (ISessionState) |
Scaffold routers (ScaffoldDataSourceRouters.cs, DI at HeadServiceCollectionExtensions.cs) |
IDataManager for the live side |
Each domain phase (constructor arg → new service) |
RestServiceLogEventSink, CacheDatabaseLogEventStore |
IRestService.LogEvent(s), CacheDatabase |
Phase 6 |
Authentication |
IRestService.AuthenticateUserViaJWTAsync, SetBearerToken, CacheDatabase, IFTDTSyncService, lazy IServiceProvider |
Phase 5 |
App.xaml.cs (DataManager.StartAppUpdateCheck) |
concrete DataManager |
Phase 3 (Content). AppDelegate already uses a narrow port; the Firebase aggregate reference is commented out. |
DataManagerAirportLookup |
IFlightSearchQueries (port — already fine) |
none |
| Remaining facade users | App, composition-root aliases, router live-side resolutions, static messages/classifier users |
Re-inventory by symbol; remove each use in its owning phase; coordinate the final gate with AB#2764 |
DynamicPageOrchestrator (Presentation) |
IAuthentication |
Phase 5 (ISessionState) — see §9 |
| Settings and endpoint consumers | IRestEndpointProvider, plus SettingsPageViewModel service-locator calls |
Phase 1 compatibility adapter; remaining consumers move before Phase 6 deletion |
Ad-hoc HttpClient callers |
IHttpClientFactory directly |
§2.5 |
2.5 Ad-hoc HttpClient callers — disposition
| File | Layer | Disposition |
|---|---|---|
ALPAMobile.Infrastructure/Remote/DynamicContentApiClient.cs |
Infrastructure | JSON + bearer from ITokenStore + own 15s linked deadline, site-scoped route, custom AlpaWireJson converters, and dynamiccontent.* errors. Pilot only after the authenticated transport can preserve those semantics and the options-backed IRestEndpointProvider bridge is tested (Phase 1). |
ALPAMobile.Infrastructure/Remote/PodcastFeedClient.cs |
Infrastructure | XML stream, not JSON. Keeps IHttpClientFactory; documented exception. |
ALPAMobile.Infrastructure/Caching/ImageCacheService.cs |
Infrastructure | Binary streams. Keeps IHttpClientFactory; documented exception. |
ALPAMobile.Infrastructure/Remote/RequestProvider.cs |
Infrastructure | Is the abstraction. |
ALPAMobile/Converters/BannerImageConverter.cs |
head | HTTP inside an IValueConverter is a smell, but IImageCacheService has no stream/decode API today. Verified Android binary-image fetch/downsample. Route through IImageCacheService only after it exposes the needed stream behavior — follow-up, not on this critical path. |
ALPAMobile/Views/AutoLoginWebViewContentView.xaml.cs |
head | Verified cookie-authenticated binary PDF GET (DownloadPdfAndNavigateToViewerAsync); preserve cookies, content checks and native open behavior. Migrate endpoint lookup in Phase 6; retain the binary transport exception. |
ALPAMobile/Helpers/SentryEventProcessor.cs |
head | Verified event/exception inspection only; no HTTP caller to migrate. |
ALPAMobile/Services/RestService.cs |
head | Retired in Phase 6. |
3. Target architecture
MEC page → IMECQueries → MecService → IMecApiClient → authenticated transport
↓ ↓
CachedRemoteQuery raw DTO factory
↓
CacheContextCoordinator
generation + write barrier
↓
CacheDatabase + Search index
Authentication → existing IAuthRestClient → AuthApiClient → raw transport
Legacy facade → existing narrow ports (no new domain logic)
Scaffold page → IScaffold*Queries → router → live narrow port / existing mock
Generated clients keep their supported contracts behind adapters. The raw JWT path never resolves the authenticated decorator. Endpoint configuration is shared through Options with a temporary IRestEndpointProvider adapter. One coordinator, not one handler per service, owns cache invalidation and session generation.
3.1 Layer placement rules
| Thing | Layer | Why |
|---|---|---|
Ports, options types, Result<T> consumers |
Application | Arch tests forbid System.Net.Http, Newtonsoft, LiteDB, MAUI here |
| Domain services, API clients, cache helper, raw-representation factories, transient-error classifier, NSwag wrappers | Infrastructure | Newtonsoft/LiteDB/System.Net.Http allowed; MAUI not referenced (add Infrastructure_Should_Not_Reference_Maui test in Phase 0) |
CacheContentService after platform decoupling |
Application-facing orchestration | It coordinates several domains and progress state; Infrastructure owns the individual cache/remote implementations |
Authentication, Shell/DisplayAlert/Launcher workflows, endpoint/options change-source configurators reading AppProperties, scaffold routers, DI composition |
head | MAUI-bound or composition root |
IsTransientNetworkError |
Infrastructure (Remote/TransientNetworkError.cs) |
Inspects HttpRequestException — banned in Application |
3.2 Contracts that do not change in this plan
- Existing query/command signatures, including their current cancellation parameters.
IDocumentsQueries.GetDocumentListAsyncalready has a token; do not drop it. The one deliberate exception isIDocumentsCommands.OpenFileRequestin Phase 4, removed only after all callers use the existing navigation port. Other signature redesign is deferred (§11 Q1). - The
CacheDatabaseon-disk format and key strings. CacheContentService’s public surface (IContentPrefetchStatus,IContentPrefetchProgress).- The existing
IRequestProvidermethod signatures. The authenticated implementation is the default decorator; a narrowIRawRequestProvider(or an equivalent explicit raw registration) is required for token acquisition and must never resolve the decorated service.
4. Building blocks and proposed contracts
4.1 ICacheStore port + CacheDatabase implementing it
ALPAMobile.Application/Abstractions/Caching/ICacheStore.cs:
namespace ALPADocs.Abstractions.Caching
{
/// <summary>
/// Key → JSON payload store behind the API cache. Keys are also search-index keys
/// (SearchContentRegistry), so implementations must persist exactly the key given.
/// </summary>
public interface ICacheStore
{
string? GetJson(string key);
void SetJson(string key, string json);
void Delete(string key);
/// <summary>Auth-context change: wipe the API cache and security-trimmed index rows.</summary>
void ClearForAuthContextChange();
}
}
ICacheStore remains the persistence port, but it is not the concurrency boundary. Add a singleton ICacheContextCoordinator beside it with a monotonically increasing generation and an auth-context invalidation operation. CachedRemoteQuery captures the generation before a fetch; CacheDatabase accepts a write only while that generation is current and performs the cache-row and search-index update under the same lock/transaction. Logout increments the generation and clears the store before any new authenticated fetch is allowed. This closes the existing SetCache/IndexSearchContent versus ClearCache race without changing the on-disk format.
CacheDatabase adds : ICacheStore and maps GetJson → GetCacheJSON, SetJson → SetCache(uri, payload), Delete → DeleteCache, ClearForAuthContextChange → ClearCache. Registration:
// InfrastructureServiceCollectionExtensions.cs
services.AddSingleton<CacheDatabase>();
services.AddSingleton<ICacheStore>(sp => sp.GetRequiredService<CacheDatabase>());
Existing direct consumers remain temporarily. New services use the coordinated path; exposing SetJson does not authorize a write that bypasses the generation barrier. SiteStore and CacheDatabaseLogEventStore need explicit migration entries: the latter stores an offline log queue, not a remote-query cache.
4.2 CachedRemoteQuery — the repeated blocks, once
The helper is a policy boundary, not a T? convenience method. Its outcome must preserve the source and error so a caller can distinguish a live value, a stale value, an offline miss, auth.required, and caller cancellation:
public enum CachePolicy
{
FallbackOnTransportFailure,
StaleWhileRevalidate,
}
public enum CacheValueSource { Live, Memory, Persisted, None }
public sealed record CacheReadOutcome<T>(
T? Value,
Error? Error,
CacheValueSource Source,
bool IsStale);
The implementation in ALPAMobile.Infrastructure/Caching/CachedRemoteQuery.cs follows this algorithm:
- The caller supplies a registry key, a
CachePolicy, a raw-response codec/factory, and a fetch delegate. The key is the existingSearchContentRegistry.Keysvalue; it is never derived from a CLR type or method name. Cache persistence uses Newtonsoft for the legacy DataManager payloads (SiteStore retains its existing AlpaWireJson codec), while aRawRepresentationFactory<TDomain>maps raw wire data into domain objects. - A memo entry and in-flight operation key include their auth-context generation. A memo hit is valid only for the current generation and the policy permits it.
- For a stale-while-revalidate key, a valid persisted value is returned immediately with
Source = Persisted/IsStale = true; the refresh is scheduled through the same shared gate used by foreground requests. - Single-flight is implemented with a per-key
SemaphoreSlim, a locked shared-task table, or aLazy<Task<...>>whose removal compares the exact task instance.ConcurrentDictionary.GetOrAddalone is insufficient because its value factory may run more than once, and an unconditionalTryRemove(key)can remove a newer operation (the ABA case). Do not useTask.Runfor async I/O. - The shared fetch has an operation-lifetime token. A caller’s cancellation cancels only its
WaitAsync(ct); it does not cancel other callers’ work. Caller cancellation preserves the public method’s existing contract (document waiting throwsOperationCanceledException); the raw provider may userequest_provider.canceledinternally. It never becomes stale fallback. Shared work can still commit for an active context and another caller; canceling one waiter is not logout. - A successful fetch may commit only through
ICacheContextCoordinatorwhile its captured generation is current. The coordinator atomically writes the raw payload and search index. Logout invalidates/cancels first, then clears persistence; an old fetch can no longer restore it. - The shared helper defaults to no stale fallback for auth, validation or non-transient failures. Phase 3 preserves characterized legacy catalogue exceptions with explicit per-domain error-code allowlists: known HTTP/decoding failures may fall back while remaining non-transient and reported once. Auth fallback requires its separate explicit flag (banners, Jumpseat policies/airlines, subscriptions, airport reference data and flight notifications); cancellation and invalidated context never fall back. General notifications and protected MEC reads retain their auth gate. A gateway 403 does not acquire Jumpseat challenge retries merely because cached catalogue fallback is allowed. Corrupt persisted payloads are removed and treated as misses.
The domain service translates the typed outcome to its existing public contract. During the pilot, auth.required retains the current throw/re-authentication behavior; a later decision may let pages gate on ISessionState, but that change is not hidden inside this helper (see §11 Q2).
4.3 ApiEndpointOptions (Options pattern)
ALPAMobile.Application/Abstractions/Remote/ApiEndpointOptions.cs:
namespace ALPADocs.Abstractions.Remote
{
public sealed class ApiEndpointOptions
{
public string BaseDomain { get; set; } = "alpa.org";
public Uri BaseUrl { get; set; } = new("https://alpa.org");
public Uri BaseUrlWww { get; set; } = new("https://www.alpa.org");
public Uri ApiBaseUrl { get; set; } = new("https://gatewayapi.alpa.org");
public Uri ApiBaseUrlV2 { get; set; } = new("https://gatewayapi.alpa.org/v2");
public Uri AuthBaseUrl { get; set; } = new("https://authsvc2.alpa.org");
// Temporary AB#2717 route. Remove only when the production mapUrl response is fixed.
public Uri KcmLegacyGatewayUrl { get; set; } = new("https://gateway.alpa.org");
}
}
Use data-annotation or a custom IValidateOptions<ApiEndpointOptions> validator to require non-empty absolute HTTPS URIs (and a valid BaseDomain), then register ValidateOnStart(). The head configurator follows the existing PrefetchTagOptionsConfigurator/ThemeOptionsBridge shape:
// ALPAMobile/Helpers/ApiEndpointOptionsConfigurator.cs
public sealed class ApiEndpointOptionsConfigurator(AppProperties appProperties) : IConfigureOptions<ApiEndpointOptions>
{
public void Configure(ApiEndpointOptions o)
{
if (appProperties.DevEnv)
{
o.ApiBaseUrl = new("https://gatewayapiqa.alpa.org");
o.ApiBaseUrlV2 = new("https://gatewayapiqa.alpa.org/v2");
o.AuthBaseUrl = new("https://authapi.alpa.org");
}
}
}
// HeadServiceCollectionExtensions.cs
services.AddOptions<ApiEndpointOptions>()
.ValidateOnStart();
services.AddSingleton<IConfigureOptions<ApiEndpointOptions>, ApiEndpointOptionsConfigurator>();
services.AddSingleton<IOptionsChangeTokenSource<ApiEndpointOptions>, ApiEndpointOptionsChangeTokenSource>();
The change-token source is driven by an explicit environment/endpoint coordinator. It must be signalled by SettingsPageViewModel after changing the setting because the current Debug AppProperties.DevEnv setter does not raise PropertyChanged. Test both Debug and Release behavior. IOptionsMonitor<ApiEndpointOptions>.CurrentValue is the value read by Settings and new clients. During migration, register an IRestEndpointProvider adapter over the monitor; ApplyEndpoints() refreshes the adapter and the generated clients. Delete that adapter only after the consumer inventory is empty. GatewayApiUrl/GatewayApiV2Url are not target names: the current code uses ApiBaseUrl/ApiBaseUrlV2.
4.4 IAuthTokenProvider + AuthenticatedRequestProvider
The first slice of the auth split, needed by Phase 1 (the rest lands in Phase 5).
ALPAMobile.Application/Abstractions/Authentication/IAuthTokenProvider.cs:
namespace ALPADocs.Services
{
public interface IAuthTokenProvider
{
/// <summary>Current bearer token, refreshed if the local clock says it expired. Null when signed out.</summary>
Task<string?> GetValidTokenAsync(CancellationToken ct = default);
/// <summary>401 recovery: refresh even if the local clock disagrees. True when a new token was obtained.</summary>
Task<bool> ForceRefreshAsync(string reason, CancellationToken ct = default);
RefreshFailureKind LastRefreshFailure { get; }
}
}
Authentication implements it by forwarding to ValidateAuthToken+GetJWTAuthToken and ForceRefreshAuthTokenAsync as an initial adapter. Cancellation-aware waits, rejected-token refresh coalescing and logout exclusion require explicit new implementation; forwarding alone does not satisfy those contracts.
ALPAMobile.Infrastructure/Remote/AuthenticatedRequestProvider.cs is a decorator over the raw transport. It applies the endpoint’s required/optional bearer policy for every verb, but performs the old forced-refresh recovery by default only for GET (and HEAD if that operation is later added to the port): one refresh, one retry, and no retry on POST/PUT/PATCH/DELETE unless an endpoint explicitly declares an idempotency key/contract. Non-cancellation ForceRefreshAsync failures are caught and the original failed result is returned; caller cancellation retains cancellation semantics; the final error retains a typed request_provider.unauthorized/auth.required distinction.
Use one shared error-code definition. RequestProvider returns request_provider.unauthorized for HTTP 401 and request_provider.http_error for other status codes; the decorator checks the code, never a status embedded in a message. Caller cancellation is handled consistently by the transport and is not eligible for retry. Guest-safe endpoints (banners, app configuration, Jumpseat reads and eligible push registration) must still send without a bearer when signed out. A missing token blocks protected MEC calls, not every request.
The current port has no HEAD operation. GET/HEAD is the policy vocabulary, not a claim that HEAD already exists. Read-by-POST flight search/lookup/flight notification endpoints already have retry behavior: characterize and explicitly allowlist server-guaranteed replay-safe operations, or document and approve the changed behavior. Never infer safety from the verb or from a transient exception alone. Normalize endpoint base paths, including trailing /v2/, before URI composition; a leading-slash relative path would discard the version prefix.
The raw transport must be explicit in the contract. Either add IRawRequestProvider (implemented by RequestProvider) or register the concrete provider behind an IAuthRestClient factory; the auth client must never resolve the decorated IRequestProvider. JWT acquisition uses three bounded attempts for transport failures only. HTTP statuses return unchanged to Authentication, which owns its existing status retry loop; the provider must not add nested status retries.
Registration binds one singleton concrete instance and avoids the duplicate-registration/cycle trap:
services.AddSingleton<RequestProvider>();
services.AddSingleton<IRawRequestProvider>(sp => sp.GetRequiredService<RequestProvider>());
services.AddSingleton<IRequestProvider>(sp => new AuthenticatedRequestProvider(
sp.GetRequiredService<RequestProvider>(),
sp.GetRequiredService<IAuthTokenProvider>(),
sp.GetRequiredService<ILogger<AuthenticatedRequestProvider>>()));
Before adding Microsoft.Extensions.Http.Resilience, remove or disable the existing Polly fallback and RequestProvider retry so only one handler owns status-code retries, backoff, Retry-After, jitter, and the total attempt/time budget. The current provider retries all verbs; Phase 1 must change that behavior and extend its contract tests before any domain client uses it.
4.5 Transient-error classification
First compare DataManager.IsTransientNetworkError, the private RestService classifier, and ExceptionExtensions.IsTransientNetworkError. The REST classifier has additional disposed StringContent and iOS NSErrorException branches; DataManager has a TLS EOF IOException branch. Preserve those cases in characterization tests before consolidating. Shared .NET classification belongs in Infrastructure; the iOS-specific adapter remains in the head.
A retry decision also needs the verb/idempotency policy and remaining budget. An exception being transient does not by itself authorize replaying a POST. Re-point CacheContentService and the two ViewModel catch guards; retain an obsolete forwarder only while actual references remain.
4.6 Per-domain API client port — MEC as the template
ALPAMobile.Application/Abstractions/MEC/IMecApiClient.cs:
namespace ALPADocs.Abstractions.MEC
{
public interface IMecApiClient
{
Task<Result<ObservableCollection<Committee>>> GetCommitteesAsync(CancellationToken ct = default);
Task<Result<ObservableCollection<MECAirports>>> GetHotelAirportsAsync(CancellationToken ct = default);
Task<Result<ObservableCollection<MECLECEvent>>> GetEventsAsync(MECLECEvent.Scope scope, CancellationToken ct = default);
Task<Result<ObservableCollection<Representative>>> GetRepsAsync(Representative.Scope scope, CancellationToken ct = default);
}
}
Infrastructure implementation over IRequestProvider (routes copied from the corresponding RestService methods). The transport deserializes raw wire DTOs; pure factories validate/map them to domain types before the result crosses the API-client boundary:
// ALPAMobile.Infrastructure/Remote/Mec/MecApiClient.cs
public sealed class MecApiClient(
IRequestProvider http,
IOptionsMonitor<ApiEndpointOptions> endpoints) : IMecApiClient
{
public async Task<Result<ObservableCollection<Committee>>> GetCommitteesAsync(CancellationToken ct = default)
{
var raw = await http.GetAsync<IReadOnlyList<CommitteeRaw>>(
new Uri(endpoints.CurrentValue.ApiBaseUrl, "api/carrier/GetCommittees").AbsoluteUri,
cancellationToken: ct);
if (raw.IsFailure)
return Result<ObservableCollection<Committee>>.Failure(raw.Error);
return Result<ObservableCollection<Committee>>.Success(
new ObservableCollection<Committee>(raw.Value.Select(CommitteeRawFactory.CreateFromRaw)));
}
// /api/carrier/getmecevents, /api/carrier/getlecevents,
// /api/carrier/getmecreps, /api/carrier/getlecreps, and
// /api/carrier/getmecairports are the current RestService routes.
}
CommitteeRawFactory, MecEventRawFactory, RepresentativeRawFactory, and MecAirportRawFactory are stateless CreateFromRaw mappers with validation and no service resolution or I/O. The cache codec preserves the existing domain-shaped on-disk JSON schema and search-extractor input, reads it as a raw representation, then invokes the same factory on reads. Separate wire DTOs and persistence DTOs if their shapes differ; do not silently change the stored schema. This keeps the anti-corruption boundary explicit and prevents a service from silently becoming a JSON/domain mapper.
Interim adapter, only if the MEC change is split into two commits: a temporary RestServiceMecApiClient may call the old service, but it must retain the old DataManager auth guard before transport, preserve OperationCanceledException and the legacy auth-required exception/message, and map other failures through shared error constants. Do not catch every exception and relabel it transport.error — that loses the re-authentication contract. Remove this adapter when MecApiClient lands in the same Phase 2; it is not a Phase 6 dependency.
4.7 The domain service — MEC as the template
ALPAMobile.Infrastructure/Services/Mec/MecService.cs:
public sealed class MecService : IMECQueries
{
private readonly IMecApiClient _api;
private readonly CachedRemoteQuery _query;
public MecService(IMecApiClient api, CachedRemoteQuery query)
{
_api = api;
_query = query;
// Cache invalidation is coordinated once for all domain services. Do not subscribe here.
}
public Task<ObservableCollection<Committee>?> GetMECCommitteesAsync(bool refresh = false) =>
ReadAsync(SearchContentRegistry.Keys.GetMECCommitteesAsync, ct => _api.GetCommitteesAsync(ct), refresh);
public Task<ObservableCollection<MECAirports>?> GetMECHotelAirportsAsync(bool refresh = false) =>
ReadAsync(SearchContentRegistry.Keys.GetMECHotelAirportsAsync, ct => _api.GetHotelAirportsAsync(ct), refresh);
public Task<ObservableCollection<MECLECEvent>?> GetMECLECEventsAsync(MECLECEvent.Scope scope, bool refresh = false) =>
ReadAsync(scope == MECLECEvent.Scope.MEC
? SearchContentRegistry.Keys.GetMECEventsAsync
: SearchContentRegistry.Keys.GetLECEventsAsync,
ct => _api.GetEventsAsync(scope, ct), refresh);
public Task<ObservableCollection<Representative>?> GetMECLECRepsAsync(Representative.Scope scope, bool refresh = false) =>
ReadAsync(scope == Representative.Scope.MEC
? SearchContentRegistry.Keys.GetMECRepsAsync
: SearchContentRegistry.Keys.GetLECRepsAsync,
ct => _api.GetRepsAsync(scope, ct), refresh);
// Partial excerpt: the cache codec and legacy exception mapper are proposed dependencies.
private async Task<T?> ReadAsync<T>(string key, Func<CancellationToken, Task<Result<T>>> fetch, bool refresh)
where T : class
{
var outcome = await _query.GetAsync(key, fetch, CachePolicy.FallbackOnTransportFailure, refresh);
if (outcome.Error?.Code == RemoteErrorCodes.AuthRequired)
throw CreateLegacyAuthRequiredException(); // Proposed compatibility mapper; preserve the current message contract.
return outcome.Value;
}
}
Until Phase 5 delivers ISessionState, the centralized cache-context coordinator is wired at the composition root; this service does not subscribe to the session event. After Phase 5, Authentication raises the session transition and the coordinator performs the ordered invalidation once.
The four query methods use six memo partitions. Preserve EventsReadyMessage publication when event data becomes available; move that nested message to Application and update its subscriber. The sample above omits message dispatch and the cache codec to show delegation only; neither may be omitted from implementation. See Phase 2 for the six exact keys.
4.8 DI rebind + DataManager delegation (the MenuService recipe)
// InfrastructureServiceCollectionExtensions.cs
services.AddSingleton<ICacheContextCoordinator, CacheContextCoordinator>();
services.AddSingleton<CachedRemoteQuery>(); // one shared, thread-safe helper; registry keys are globally unique
services.AddSingleton<MecService>();
services.AddSingleton<IMECQueries>(sp => sp.GetRequiredService<MecService>());
// HeadServiceCollectionExtensions.cs — remove:
// services.AddSingleton<IMECQueries>(sp => sp.GetRequiredService<IDataManager>());
// add (interim, first Phase 2 commit only):
services.AddSingleton<IMecApiClient, RestServiceMecApiClient>();
// scaffold routers keep receiving the live side through the port:
// new NotificationsQueriesRouter(sp.GetRequiredService<INotificationsQueries>(), mock)
DataManager keeps IMECQueries on IDataManager for legacy XAML only, as pure delegation (the _menuService pattern at DataManager.cs source evidence):
public Task<ObservableCollection<Committee>?> GetMECCommitteesAsync(bool refresh = false) => _mecQueries.GetMECCommitteesAsync(refresh);
4.9 Head ratchet test (Phase 0)
UnitTest/Architecture/HeadConsumerRatchetTests.cs is a source/IL inventory, not a constructor parameter test. It reports fields, constructor parameters, method bodies, and generic calls to GetService<T>/GetRequiredService<T> for IRestService, RestService, IDataManager, DataManager, and IRestEndpointProvider. The Phase 0 report is regenerated from the implementation branch tip (this documentation review used b5365544); each later phase compares the report and may only remove entries. The report must include current service-locator consumers such as SharedActionsService and SettingsPageViewModel, which the old sample missed:
// Conceptual shape; implementation may use Roslyn or Mono.Cecil.
var consumers = ConsumerInventory.Scan(
assembly: "ALPADocs",
targetTypes: ["IRestService", "RestService", "IDataManager", "DataManager", "IRestEndpointProvider"],
includeGenericServiceLocatorCalls: true);
Assert.That(consumers, Is.EqualTo(BaselineForCurrentPhase));
CI stores the inventory as an artifact and fails when a phase adds a consumer or when a planned consumer disappears without its migration entry. A separate check scans Application and Infrastructure for Shell, Connectivity.Current, and concrete head service types.
Plus in LayerDependencyRulesTests: Infrastructure_Should_Not_Reference_Maui and Infrastructure_Should_Not_Reference_Presentation.
4.10 Auth ports (Phase 5)
public interface ISessionState
{
bool IsLoggedIn { get; }
bool IsAuthenticated { get; }
bool TokenExpired { get; }
UserInfo? CurrentUser { get; }
event EventHandler<SessionStateChangedEventArgs>? Changed;
event EventHandler? AccountLocked; // replaces ShowAccountLockedMessage(); head shows the alert
}
/// <summary>Immutable snapshot raised after a session transition.</summary>
public sealed class SessionStateChangedEventArgs(bool isLoggedIn, bool isAuthenticated) : EventArgs
{
/// <summary>Whether a member session exists.</summary>
public bool IsLoggedIn { get; } = isLoggedIn;
/// <summary>Whether that session has a valid token.</summary>
public bool IsAuthenticated { get; } = isAuthenticated;
}
public interface ICredentialStore
{
Task<string?> GetUserNameAsync();
Task<string?> GetPasswordAsync();
Task SetUserNameAsync(string userName);
Task SetPasswordAsync(string password);
Task ClearAsync(); // secure erase; callers do not manipulate the backing store directly
}
public enum SignOutReason { UserRequested, SessionExpired, AccountLocked }
public sealed record SignOutRequest(SignOutReason Reason, bool ClearStoredCredentials);
public interface IAuthSession
{
Task<bool> SilentLoginAsync(string userName = "", string password = "");
Task<bool> SilentLoginWithRetryAsync();
Task LogOutAsync(SignOutRequest request);
Task<bool> AcquireMsalInteractiveAsync(string[] scopes);
Task<bool> TryAcquireMsalSilentTokenAsync(string[] scopes);
}
[Obsolete("Composite retained as the Authentication backing type. Depend on ISessionState / IAuthTokenProvider / ICredentialStore / IAuthSession.")]
public interface IAuthentication : ISessionState, IAuthTokenProvider, ICredentialStore, IAuthSession
{
// legacy setters/props (IsLoggedIn set, TokenLifeSpan, TokenTimeStamp, UseMSAL, ProcessAuthJWTResult, GetUserInfo)
// stay here until their last consumer moves
}
The [CallerMemberName] string caller parameters are diagnostics; they stay on IAuthentication and are dropped from the narrow ports (callers that need them keep the composite until Phase 7).
Consolidate repeated fakes into shared fixtures where behavior matches; retain scenario-specific refresh/logout fault injection. Measure the fake inventory before removing local implementations.
5. Phases
Implement one reviewable slice at a time on an isolated worktree under ../ALPA Mobile.worktrees/, with a codex/ branch unless the assigned work item specifies another name. Every implementation branch starts from and opens its PR into imp/blazor-hybrid-service-layer-decoupling. The phase validation in §7 runs before each integration PR. Phase completion does not merge code into imp/blazor-hybrid. Follow the branch policy below.
Phase 0 — Guards, inventory and safety fixes
Implementation update — 2026-09-19: the safety baseline is implemented on codex/service-layer-phase0, targeting imp/blazor-hybrid-service-layer-decoupling. The Phase 0 inventory and validation record connects P0-A1–P0-A4 to the executable guards and controlled tests, records deliberate safety changes, and states the remaining runtime-validation limits. The original review examples below remain tied to their documented source baseline.
Exit: build + unit suite green; the baseline inventory is checked in; connectivity semantics, cache atomicity, endpoint consumers, and raw mapping decisions have tests. Review the logout race fix and connectivity/auth-policy corrections explicitly as safety/behavior changes, not merely file moves.
Phase 0 walkthrough — make the baseline measurable
Current source: CacheDatabase.SetCache writes the API row, releases its lock, then updates Search separately. Clearing the cache during that gap can be undone by old work. Separately, DataManager uses literal MEC keys even though the registry already defines them.
// Existing call site (DataManager.GetMECLECEventsAsync):
_cacheDatabase.SetCache("GetLECEventsAsync", json);
// Proposed constant-only change; persisted key bytes remain identical:
_cacheDatabase.SetCache(SearchContentRegistry.Keys.GetLECEventsAsync, json);
Safety example (proposed behavior, not existing API):
1. Member A fetch starts: key=GetLECEventsAsync, generation=12.
2. Logout closes new authenticated work, advances generation to 13, clears cache + index.
3. Member A response arrives: coordinator rejects generation 12 before any write/publication.
4. Member B asks for the same key: joins/starts generation 13 work, never generation 12.
5. Assert: no A payload in memo, API cache, Search, document store, or readiness messages.
Key the shared operation by (generation, cache key), or reject an old-generation join. A check before taking the persistence lock is insufficient; generation validation and the writes must share the same synchronization boundary as invalidation. Include document hydration and publication, not only API JSON rows. A caller canceling a wait is different from invalidating a session: shared work may continue for other callers in the same active context.
Characterize current auth policy before applying the proposed no-protected-fallback rule:
| Representative call | Current behavior after failed auth guard | Required phase decision/test |
|---|---|---|
GetMECCommitteesAsync |
Throws an exception containing Constants.MessageAuthenticationRequired |
Preserve the re-authentication signal; no stale protected result |
GetNotificationListAsync |
Returns null before fallback | Preserve empty/auth-gated behavior unless deliberately changed |
GetPageBannersAsync |
Can fall back to persisted banners | Classify guest-safe data and approve any fallback change explicitly |
| Jumpseat policies / flight notifications | Can enter fallback after auth failure | Decide per data partition; do not call a global rule “unchanged behavior” |
Reviewable output: consumer inventory by symbol, cache-key/codec inventory, and tests for late writes, cross-generation joins and connectivity. MauiConnectivityService currently requires Internet, while DataManager also accepts ConstrainedInternet and honors DEBUG force-offline. Port those semantics deliberately. Preserve SearchContentRegistryTests.CacheKeys_AreUnique and the security-trim tests. New concurrency tests use controllable completions, not timed sleeps.
Phase 0 acceptance and testability
Marker: Safety baseline. Status: implemented; automated validation and PR evidence are recorded in the Phase 0 validation record. Device smoke remains outstanding; later feature markers are unchanged.
What can be validated now: Automated/controlled integration tests; existing app smoke only. No extracted feature is ready for acceptance yet.
| ID | Pass condition | Validation / evidence |
|---|---|---|
| P0-A1 | Symbol/DI inventory includes constructors, fields, service-location and nested/static references; every entry has an owning phase. | Checked-in inventory + ratchet; no unclassified executable consumer. |
| P0-A2 | Release old-generation work after logout; it cannot restore memo/API/Search/document rows or publish readiness. A new generation cannot join old work. | Deterministic controlled-fetch tests, including writes concurrent with clear. |
| P0-A3 | Existing key strings and persisted/search payload fixtures remain byte/schema compatible; corrupt entries are handled explicitly. | Registry uniqueness/security-trim tests + legacy payload fixtures. |
| P0-A4 | Connectivity, classifier and per-domain auth/fallback differences are recorded and covered before extraction. | Debug/Release connectivity cases; REST/iOS/TLS cases; approved policy matrix. |
What this phase does not yet prove: Cache invalidation can be proven without real HTTP. Full new transport, feature UI and auth split cannot be accepted here.
Phase 1 — Transport foundation
Implementation update — 2026-09-20: endpoint options, raw/authenticated transport, narrow token access and shared cache policy are implemented on codex/service-layer-phase1. See the validation record. The dynamic-content transport pilot remains explicitly deferred to Phase 6.
Proposed tests: AuthenticatedRequestProvider (token attached; 401 on GET → one refresh → one retry; 401 on POST → no retry; refresh exception/failure → original failure returned; concurrent 401s coalesce); RequestProvider (GET retry budget, no automatic write retry, 401 code, caller cancellation, Retry-After); CachedRemoteQuery (typed outcome, auth/cancellation never fallback, memo hit, offline fallback, offline SWR returns a valid persisted catalogue; an uncached offline request returns null, corrupt payload purge, foreground and background single-flight, exact task removal, generation barrier); options validation and change-token tests in Debug and Release; dynamic-content route/serializer/deadline tests.
Exit: transport and options tests pass; DynamicContent serves live pages through the new transport only if all of its existing wire semantics are covered on both simulators. No retry policy is stacked accidentally.
Phase 1 walkthrough — one dynamic-page request, one retry owner
Before: DynamicContentApiClient owns its bearer read, HTTP request, linked 15-second deadline, AlpaWireJson.Options decoding, missing-PageId repair and dynamiccontent.* error mapping. RequestProvider has a different serializer, retries all verbs, and drops non-success bodies.
Target sequence:
FetchPageAsync("alpa-home")
→ read current endpoint snapshot
→ GET /api/mobilecontent/dynamic/site/alpa/page/alpa-home
→ authenticated request provider (single safe-read retry owner)
→ AlpaWireJson decoding + missing-PageId repair
→ existing dynamiccontent.* result contract
// Proposed codec bridge; excerpt, not a complete FetchPageAsync implementation.
// JsonElement uses today's IRequestProvider signature without mapping directly to PageDto.
var response = await http.GetAsync<JsonElement>(uri, cancellationToken: deadline.Token);
if (response.IsFailure)
return TranslateTransportFailure(response.Error); // New explicit error mapper.
var page = response.Value.Deserialize<PageDto>(AlpaWireJson.Options);
// Also preserve empty/invalid JSON handling, PageId repair and the existing deadline contract.
A serializer bridge alone is insufficient for JWT: preserve the status and body of rejected requests so LockedAccount and BadCredentials remain distinct. Add a raw response seam or keep dedicated auth transport inside AuthApiClient; a marker interface alone does not recover bodies already discarded by RequestProvider. Do not switch either pilot until its parity tests pass.
| Endpoint | Production | Development/QA |
|---|---|---|
| API | https://gatewayapi.alpa.org |
https://gatewayapiqa.alpa.org |
| Versioned API | API base + /v2 |
API base + /v2 |
| JWT | https://authsvc2.alpa.org |
https://authapi.alpa.org |
| Temporary KCM map host | https://gateway.alpa.org |
Same legacy exception |
Both the native SettingsPageViewModel and Blazor SettingsPage.razor.cs must publish endpoint changes. Test an existing client singleton before/after a switch in Debug and Release, including trailing slash and missing-host behavior. Merely registering IOptionsMonitor does not cause AppProperties.DevEnv to emit notifications.
Demonstration/acceptance: GET returns 401 → one coalesced refresh → one replay; POST returns 401 → no replay. Three concurrent rejected GETs share one refresh for the rejected token/context. Cancellation during send and backoff has the same outward contract. Token acquisition today has no cancellation parameter: a total deadline that includes token acquisition requires explicit shared-wait/cancellation work, not a forwarding wrapper. Preserve existing DynamicContentApiClientEndpointTests and extend RequestProvider_ContractTests.
Phase 1 acceptance and testability
Marker: Foundation test marker. Status: implementation and automated results are recorded in the phase PR. The dynamic-content pilot was completed in Phase 6; the device walkthrough remains pending.
What can be validated now: Raw/decorated transport, endpoint switching and cache policy can be tested with handlers and fakes. Dynamic pages are a conditional live pilot.
| ID | Pass condition | Validation / evidence |
|---|---|---|
| P1-A1 | GET 401 recovers at most once; concurrent rejected requests share one refresh; writes are not replayed; signed-out guest requests send without bearer and protected requests return auth-required. | Request-count tests; bounded attempt/time budget; no stacked retry owners. |
| P1-A2 | Raw JWT path never resolves the auth decorator and retains rejected-response status/body; canceled send/backoff/token wait has the agreed contract. | Container graph + handler tests for LockedAccount/BadCredentials and cancellation. |
| P1-A3 | An existing singleton follows environment changes in Debug and Release; invalid endpoints fail validation. | Options change-source tests from both Settings paths; exact next-request URI assertions. |
| P1-A4 | Shared cache distinguishes live/memory/persisted/miss/auth/cancel; preserves generation, single-flight and legacy codecs. | Unit tests for each outcome, late writes and one-waiter cancellation. |
| P1-A5 | Dynamic pilot preserves site route, AlpaWireJson, PageId repair, deadline and dynamiccontent.* errors, or is explicitly left on its current transport. | Existing endpoint tests + codec/error fixtures. Record pilot as migrated or deferred, never implicitly complete. |
What this phase does not yet prove: MEC and other domain extraction cannot be accepted yet. If dynamic transport is deferred, its live validation is deferred to its actual migration PR and must finish before Phase 6.
Phase 2 — Pilot domain: MEC
Implementation: Code and automated evidence are tracked in the implementation ledger. The P2-A4 device walkthrough remains open. Readiness now signals new live publication or initial persisted materialization once per shared fetch; a failed/null refresh that retains a prior memo does not repeat the signal.
Proposed tests: MecServiceTests with FakeMecApiClient + in-memory ICacheStore + FakeConnectivityService: exact route and raw-factory mapping; live → persisted under the registry key; failure → stale copy; auth-required/cancellation → no stale copy; offline → stale; central logout generation → memo and persisted cache cleared; corrupt cache → live fetch; logout during fetch cannot recreate search rows.
Exit: MEC committees/reps/events/hotels render identically on /mec/committees, /lec, PilotGroup* pages; Search index still lists committee/rep/event/hotel rows after a fresh login (proves the key strings survived).
Phase 2 walkthrough — LEC events from request to Search
Files changed: add the Application MEC API port and Infrastructure client/service/factories; rebind IMECQueries in DI; replace DataManager bodies with forwarding methods; move EventsReadyMessage from the head’s nested type into Application messages and update MyPilotGroupPageViewModel.
| Existing query member | Scope | Exact cache key | Current relative route |
|---|---|---|---|
GetMECLECEventsAsync |
MEC | GetMECEventsAsync |
/api/carrier/getmecevents |
GetMECLECEventsAsync |
LEC | GetLECEventsAsync |
/api/carrier/getlecevents |
GetMECLECRepsAsync |
MEC | GetMECRepsAsync |
/api/carrier/getmecreps |
GetMECLECRepsAsync |
LEC | GetLECRepsAsync |
/api/carrier/getlecreps |
GetMECHotelAirportsAsync |
— | GetMECHotelAirportsAsync |
/api/carrier/getmecairports |
GetMECCommitteesAsync |
— | GetMECCommitteesAsync |
/api/carrier/GetCommittees |
// Proposed extraction of one existing signature; full service also handles outcome mapping.
public Task<ObservableCollection<MECLECEvent>?> GetMECLECEventsAsync(
MECLECEvent.Scope scope, bool refresh = false) =>
ReadEventsAsync(
scope == MECLECEvent.Scope.MEC
? SearchContentRegistry.Keys.GetMECEventsAsync
: SearchContentRegistry.Keys.GetLECEventsAsync,
scope,
refresh);
// ReadEventsAsync is new: fetch/map, use the coordinator, preserve auth behavior,
// and publish the corresponding readiness message only for the active generation.
Example to demonstrate: load LEC events → verify the LEC route/key and readiness signal → load MEC events and verify they do not replace LEC data → force offline and read the same persisted partitions → refresh and verify the memo is bypassed. Search must still consume the legacy JSON shape; CommitteeSearchExtractor currently expects that shape. The API returns mapped domain results, so the cache needs an explicit codec for serializing the legacy cache representation; it cannot access an original raw response that the API contract does not return.
DI proof: resolving IMECQueries returns the registered MecService singleton. A retained DataManager facade delegates to that port and cannot be the port’s implementation, or DI would cycle. No query consumer changes merely to accommodate the extraction.
Phase 2 acceptance and testability
Marker: First feature test marker. Status: implemented with automated validation in the phase evidence; the MEC device walkthrough remains outstanding.
What can be validated now: MEC is the first complete extracted feature: unit/DI, live/offline UI, Search and session invalidation can now be demonstrated.
| ID | Pass condition | Validation / evidence |
|---|---|---|
| P2-A1 | All four query members route to MecService; six scope partitions use the exact existing keys/routes; DataManager only forwards. | DI identity, route/key and no-aggregate-backedge tests. |
| P2-A2 | Live→persisted, memory hit, refresh bypass, offline stale, corrupt miss and auth-required behavior match the approved MEC contract. | MecService tests with legacy codec fixtures; no protected fallback on auth-required. |
| P2-A3 | LEC/MEC results remain independent; the correct readiness message fires only in the active generation. | Message subscriber tests and logout-during-fetch test. |
| P2-A4 | Committees, reps, events and hotels render with unchanged Search entries from a live fetch and subsequent offline read. | Dedicated-device walkthrough with a test account + Search/cache evidence, at the tested SHA. |
What this phase does not yet prove: This proves the extraction recipe for MEC only. It does not accept the other domains, document lifecycle, or the new auth ports.
Phase 3 — Remaining domains (Content first)
| Domain | Port | Source members | Notes |
|---|---|---|---|
| Content | IContentQueries |
GetPageBannersAsync, settings/resources, update check |
Largest. PageBanners setter sends PageBannersHasChangedMessage → ContentService sends it via IMessenger. Hydration-language bundled reads need an added platform operation; today IFileSystemService exposes only directories. StartAppUpdateCheck is not a query → head AppUpdateCoordinator (DisplayAlert/Launcher/Process.Kill), App.xaml.cs:234 re-pointed; CheckAppUpdateStatusAsync stays on the port. |
| Member | IMemberQueries |
GetMemberAsync, email lookup, PAC info |
MemberFetchAlreadyReportedException guard becomes a Result error code. |
| Jumpseat + KCM | IJumpseatQueries |
Jumpseat policies/airlines; KCM airports/airlines | KCM stays on IJumpseatQueries (port already groups it). Jumpseat SDK wire types are mapped behind IJumpseatApiClient; the shared provider owns transport and the endpoint-scoped Cloudflare 403/429 policy. |
| Notifications | INotificationsQueries |
notification lists; push registration | RegisterPushTokenAsync snackbar (RestService.cs (see source evidence)/2101) → returns Result; caller shows the diagnostic. PilotComms SDK wire types are mapped behind the shared transport adapter. |
| Subscriptions | ISubscriptionsQueries |
subscription reads/toggle | Commands on a *Queries port — leave the name, note in §11 Q1. |
| FlightSearch | IFlightSearchQueries |
airports/search/lookup/refresh | Already mapper-based (ALPADocs.Mappers.FlightSearch) — copy that idiom. KCM leg stamping takes IJumpseatQueries. DataManagerAirportLookup unchanged. |
CacheContentService |
— | — | Swap Connectivity.ConnectivityChanged for IConnectivityService.ConnectivityChanged; add a serialized/cancellable event lifecycle; move the cross-domain orchestrator to Application-facing code, while cache persistence remains Infrastructure. |
Each domain follows the Phase 2 recipe and test matrix: re-point the scaffold router live argument, then replace the DataManager body with delegation. Per the implementation request, these domain slices ship together in one Phase 3 PR; the focused services and test fixtures provide the review boundaries.
Exit: DataManager implements only IDocumentsQueries, IDocumentsCommands, IDocumentCacheQueries, IFTDTQueries, IAuthContextCache + delegations; CacheContentService runs its full wave against the new services (expected success/skip outcomes recorded; no unexplained failures).
Phase 3 walkthroughs — do not force every domain into one cache recipe
| Slice | Before → proposed ownership | Concrete behavior and acceptance evidence |
|---|---|---|
| Content | DataManager content bodies → ContentService; update UI → head AppUpdateCoordinator |
Backend FR setting wins. If FR is absent, merge the bundled normalized MEC list. Preserve PageBannersHasChangedMessage; rehome DataManager_HydrationLanguageFallbackTests. |
| Member | GetMemberAsync, GetMemberNumberFromEmailAsync, GetPACMemberInfoAsync → MemberService |
An already-reported member.exception returns stale/null without a second report. Retain DataManager_GetMemberAsync_ReportGuardTests and auth-required coverage. |
| Jumpseat / KCM | Query bodies + SDK wire-type adapter → JumpseatService |
Preserve scoped Jumpseat 403 challenge/429 classification; do not make all gateway 403s transient. Preserve double-report protection and both KCM partitions. |
| Notifications | Two read paths and push registration → NotificationsService |
Empty flight audience list means zero HTTP calls. Normalize fs- tags, exclude the debug tag and keep flight/comms cache keys separate. Do not cache push-registration commands. |
| Subscriptions | Read mapping + toggle → SubscriptionsService |
Map Id, AudienceDisplayName, OptIn through a pure factory. Toggle is a server-side flip: one request, no generic read-cache wrapper or automatic retry. |
| Flight search | Existing request/response mappers + lookup behavior → FlightSearchService |
Keep SignedOut, Offline, NotFound, Found, Failed distinct. Preserve cancellation and KCM leg flags through IJumpseatQueries. Search/lookup/batch refresh are not all cached reads. |
| Prefetch orchestration | CacheContentService in head → Application-facing orchestration once dependencies are ports |
Preserve wave cancellation/backgrounding/resume and per-step status. Requires connectivity, readiness (HasIndexedContent), target descriptors and policy ports; moving just the event subscription is insufficient. |
Proposed DI excerpt — Notifications as the repeated pattern:
// Infrastructure: live implementation of the existing port.
services.AddSingleton<NotificationsService>();
services.AddSingleton<INotificationsQueries>(sp =>
sp.GetRequiredService<NotificationsService>());
// Head: change only the live-side argument; keep the existing scaffold marker binding.
services.AddSingleton(sp => new NotificationsQueriesRouter(
sp.GetRequiredService<INotificationsQueries>(),
sp.GetRequiredService<MockNotificationApiService>()));
Prerequisites made explicit: add a bundled-file operation to an appropriate platform port for FR settings. Ordinary System.IO may remain inside Infrastructure with paths from IFileSystemService; its current API does not abstract file operations. Add an Application-facing source for AppProperties.FlightSearchPushTags before moving notifications. Do not introduce another preference service if an existing boundary can be extended coherently.
Demonstration: after a slice, resolve the global port and scaffold router separately; each sees the intended live/mock policy. Run the prefetch wave, background/resume it, and confirm the existing step outcomes and Search contents. A skipped/no-data step is evaluated against its contract, not forced to “Succeeded” by changing data. Preserve CacheContentService_BackgroundingTests.
Phase 3 acceptance and testability
Marker: Cross-domain feature test marker. Status: implemented with automated validation recorded in the phase evidence; the integrated device/prefetch/Search walkthrough remains outstanding.
What can be validated now: Each domain has focused tests in the Phase 3 PR; the whole marker still requires the integrated walkthrough evidence.
| ID | Pass condition | Validation / evidence |
|---|---|---|
| P3-A1 | Content, Member, Jumpseat/KCM, Notifications, Subscriptions and FlightSearch resolve to their focused services; scaffold routers retain source policy. | Per-slice DI/architecture tests; no remaining domain logic in DataManager for these slices. |
| P3-A2 | FR override precedence, banner events, member report-once, Jumpseat classification, empty-audience no-HTTP and independent notification caches pass. | Named existing characterization fixtures + targeted new cases in the walkthrough table. |
| P3-A3 | Subscription toggle sends once; search keeps cancellation, lookup outcomes and KCM leg mapping. | Handler request counts + FlightSearchMapper/AuthGuard fixtures. |
| P3-A4 | Prefetch runs through the new ports; background/resume does not duplicate work; expected success/skip/failure outcomes remain distinguishable. | CacheContentService_BackgroundingTests + integrated wave and Search walkthrough. |
| P3-A5 | Platform-only reads/settings/UI have explicit ports or remain head adapters; Application orchestration has no concrete persistence/registry dependency. | Architecture rules + source inventory; no hidden head references. |
What this phase does not yet prove: Cross-domain reads and commands are ready for regression. Documents still use their old implementation until Phase 4; auth lifecycle remains transitional until Phase 5.
Phase 4 — Documents
| Piece | Source members | New home |
|---|---|---|
Catalogue fetch, SWR, single-flight (IDocumentsQueries.GetDocumentListAsync) |
GetDocumentListAsync / shared fetch |
DocumentCatalogService (Infrastructure) owns the hydration-aware shared producer; CachedRemoteQuery persists the raw response and IDocumentsApiClient owns wire mapping |
Hydration into LiteDB (ProcessDocumentListResponse) |
ProcessDocumentListResponse |
DocumentCatalogHydrator (Infrastructure) — dedup guards BUG-1995/2208 kept as tests; persists via IDocumentDatabase; raises DocumentsVersion |
| Local-store queries + mutations | local/live lookup, save/delete | DocumentStoreService helper behind the complete DocumentQueries facade; command implementation composes store/cache and excludes native opening after callers migrate |
File cache lifecycle (IDocumentCacheQueries) |
cache enumeration/eviction/preferences | DocumentCacheService — use IFileSystemService paths; ordinary System.IO stays in Infrastructure or add operations explicitly; add the missing preference adapter |
| Prefetch passes | category and tag prefetch | DocumentPrefetchService; PrefetchTagOptions class moves head → Application (configurator stays head) |
Open/navigate (OpenDocumentAsync, OpenFileRequest, Shell.Current + GetService<WebViewPageViewModelFactory>) |
native document open | head SharedActionsNavigator (already IDocumentActionNavigator). IDocumentsCommands.OpenFileRequest removed after SharedActionsService.cs is re-pointed |
Exit: Documents hub, category pages, cache page, favorites hydration and the UAL “AppContractLink” prefetch behave as before on both sims; DataManager_ProcessDocumentListTests, DataManager_DocumentCacheTests, DataManager_PrefetchDocumentsTests are re-homed onto the new classes (not deleted).
Phase 4 walkthrough — one port, composed document responsibilities
The existing IDocumentsQueries contains catalogue/version, local lookups, live lookup fallback, and SaveDocument. Two classes cannot each implement half of that interface. Introduce one complete facade and delegate internally:
// Proposed structure; partial excerpt — all existing port members must be implemented.
public sealed class DocumentQueries(
DocumentCatalogService catalog,
DocumentStoreService store) : IDocumentsQueries
{
public int DocumentsVersion => catalog.DocumentsVersion;
public Task<List<ScopeItem>?> GetDocumentListAsync(
bool refresh = false, CancellationToken cancellationToken = default) =>
catalog.GetDocumentListAsync(refresh, cancellationToken);
public DocumentItem? GetDocumentByFileId(string fileId) =>
store.GetDocumentByFileId(fileId);
// Delegate remaining members, including SaveDocument and async live lookup fallback.
}
Implementation refinement: the document producer spans fetch, raw cache persistence, hydration, version and readiness publication. Hydration cannot run independently for each awaiting caller. The generic cache helper therefore handles raw retrieval/persistence with its memo disabled; the catalogue service owns the complete shared task and its generation-scoped memo. Persisted nonempty data still returns immediately and refreshes in the background, while an existing active memo does not trigger another refresh.
Concrete concurrent-call scenario:
Persisted catalogue exists → return it immediately and start shared refresh R.
Caller A asks refresh:true → joins R.
Caller B asks refresh:true → joins R.
A cancels → only A's wait throws OperationCanceledException.
R succeeds in the current generation → hydrate once; B completes.
Hydration → dedup rows → preserve read state for matching publish date
→ increment DocumentsVersion → readiness message → prefetch scheduling.
Logout before R completes → no hydration, version increment, publication or prefetch for R.
Preserve DataManager_DocumentListCancellationTests, DataManager_ProcessDocumentListTests, DataManager_PrefetchDocumentsTests, DataManager_TaggedPrefetchTests and DataManager_DocumentCacheTests on their new owners. Add late-generation hydration tests.
Navigation example: today’s route is SharedActionsNavigator → SharedActionsService → IDocumentsCommands.OpenFileRequest → DataManager. Move the actual native open into the head navigation implementation, then re-point callers. Do not create navigator → shared actions → navigator recursion. Remove OpenFileRequest from the command port only after the call graph and all tests are migrated; retain a legacy forwarding method only if the inventory needs one.
Cache demonstration: clear one category’s downloaded files → catalogue remains browsable → next open downloads again. RemoveFromCache and DeleteDocument have different meanings. Automatic category prefetch respects opt-in/opt-out; mandatory tag prefetch uses the configured MEC/tag/category match and retains its existing opt-out/PrefetchCount exception. Add the missing preference adapter before moving AppProperties accesses into Infrastructure.
Phase 4 acceptance and testability
Marker: Document lifecycle test marker. Status: implementation and automated results are recorded in the phase evidence; device document/favorites/native-viewer acceptance remains outstanding.
What can be validated now: Catalogue refresh, local hydration, navigation, downloads, file eviction and both prefetch paths can now be accepted together.
| ID | Pass condition | Validation / evidence |
|---|---|---|
| P4-A1 | One complete query facade preserves all port signatures/version behavior, including caller-only cancellation and live lookup fallback. | Container tests + rehomed DocumentListCancellation fixtures. |
| P4-A2 | Concurrent callers share one fetch/hydration; canceling one preserves another; logout blocks late hydration/version/message/prefetch. | Controlled-task tests covering both caller cancellation and session invalidation. |
| P4-A3 | Hydration deduplicates rows and retains read state only for unchanged publish dates; legacy cached payloads still hydrate. | Rehomed ProcessDocumentList tests + upgrade payload fixtures. |
| P4-A4 | Clearing one category removes files without deleting its catalogue or another category; opt-in and mandatory-tag policies remain distinct. | DocumentCache/PrefetchDocuments/TaggedPrefetch suites. |
| P4-A5 | Document hub/category/cache/favorites open flows and native open work, with no navigator recursion; OpenFileRequest is removed only after zero callers. | Call-graph inventory + dedicated-device document and favorites walkthrough. |
What this phase does not yet prove: Document behavior is testable using the current auth bridge. Full narrow-port session lifecycle and final generated-client transport retirement are still pending.
Phase 5 — Authentication split
Exit: IAuthentication referenced only by Authentication, the composition root, and legacy XAML VMs (ratchet allowlist); concurrent 401s produce one refresh; login, silent login, forced expiry (AB#2762), ordered logout, cache-generation invalidation (including logout during a fetch), 401 recovery (AB#2720), and account-locked alert are verified live on both sims.
Phase 5 walkthrough — share one auth instance and preserve logout order
// Proposed replacement for AddSingleton<IAuthentication, Authentication>().
services.AddSingleton<Authentication>();
services.AddSingleton<IAuthentication>(sp => sp.GetRequiredService<Authentication>());
services.AddSingleton<ISessionState>(sp => sp.GetRequiredService<Authentication>());
services.AddSingleton<IAuthTokenProvider>(sp => sp.GetRequiredService<Authentication>());
services.AddSingleton<ICredentialStore>(sp => sp.GetRequiredService<Authentication>());
services.AddSingleton<IAuthSession>(sp => sp.GetRequiredService<Authentication>());
DI acceptance: all five ports resolve to the same singleton; constructing the complete graph must not recurse from auth → logout participants → authenticated client → auth. The existing lazy accessors deliberately avoid cycles; remove each only after its replacement graph is tested.
Current RunLogOutTaskAsync order |
Target responsibility |
|---|---|
| Capture Remember Me / username; clear bearer before changing flags | Head auth session coordinator; close authenticated work and advance cache generation before callbacks can schedule work |
Set IsLoggedIn / IsAuthenticated false |
Publish the session transition after revocation; per-request token reads must not refresh or return the old token during logout |
| Clear cookies, API/Search cache, sign-out image collections, theme/feed and favorites memos | Explicit ordered participants; cache writes and clear share the context barrier |
| Clear app properties and token store; restore Remember Me | Session/token adapters retain the current order |
| Conditionally wipe credentials, optionally retain remembered username | Credential adapter preserves session-recovery versus explicit/rejected logout semantics |
| Register anonymous push context and update crash-report identity | Head participants preserve anonymous identity updates |
FTDT SyncAllAsync runs after successful authentication, not on logout. Keep that login participant explicit instead of moving it into the logout sequence.
Auth rejection demonstration: a 401 containing LockedAccount raises the locked-account UI flow with its existing debounce; BadCredentials rejects credentials; transport failure retains recoverable credentials. The current generic provider discards error bodies, so test the new raw JWT seam before rebinding the existing IAuthRestClient.
Refresh demonstration: concurrent GET 401s for the same rejected token share one refresh. Today’s semaphore serializes forced refresh calls but does not coalesce them; this is new work. Test a refresh completing after logout: it must not reinstall the old session/token. Keep the bearer compatibility bridge until Phase 6 migrates the last generated client.
Phase 5 acceptance and testability
Marker: Session lifecycle test marker. Status: implemented with automated validation in the phase evidence; native keystore, cookie and device session walkthroughs remain outstanding.
What can be validated now: Login, silent login, expiry, account lock, concurrent refresh and ordered logout are now an integrated acceptance surface.
| ID | Pass condition | Validation / evidence |
|---|---|---|
| P5-A1 | All auth ports resolve to one Authentication instance; nonlegacy consumers take narrow ports; construction has no participant/auth cycle. | DI identity/graph tests + shrinking consumer inventory. |
| P5-A2 | LockedAccount, BadCredentials, transient failure and secure-store failure preserve distinct outcomes and credential retention policy. | Raw JWT response fixtures + existing auth tests; account-locked UI walkthrough. |
| P5-A3 | Logout revokes bearer/token access before callbacks start work; generation blocks late fetch/refresh commits; participants preserve the recorded order. | Ordered recording fakes + concurrent logout/fetch/refresh tests. |
| P5-A4 | Login/silent login, forced expiry and account switch refresh data and favorites/theme/feed state correctly. | Dedicated-device test-account walkthrough; session/re-auth/navigation evidence. |
| P5-A5 | Current generated clients still receive correct bearer changes through the temporary bridge until Phase 6 replaces them. | Bridge regression tests; no premature deletion of SetBearerToken fan-out. |
What this phase does not yet prove: The new session lifecycle can be validated across extracted services. RestService/generated transport retirement cannot be claimed until Phase 6.
Phase 6 — Retire RestService
Implementation update — 2026-09-21: the REST aggregate, endpoint compatibility port and bearer bridge are retired on codex/service-layer-phase6. MobileMenu, FTDT, logging and dynamic-content transport now use Infrastructure adapters. See the phase validation record for concrete tests and remaining device acceptance; the acceptance table below defines the gate, not a claim that every device check has run.
Exit: source/IL inventory and rg checks show no IRestService, RestService, or IRestEndpointProvider references outside approved docs/test fixtures.
Phase 6 walkthrough — retire by transport behavior, not by file name
| Remaining path | Target / concrete preservation test |
|---|---|
Batch LogEvents |
LogApiClient : ILogEventSink sends one POST /api/log per queue attempt. ALPALogService owns retry; a shipping failure must not enqueue itself. |
| Favorites generated client | Keep 201-with-body compatibility and no automatic replay of AddFavoriteAsync. |
| Subscriptions generated client | One server-side toggle per command; never replay a timed-out flip automatically. |
| Document API download | POST /v2/api/doc/downloadfile sends Source/Path and receives JSON DocumentDownload; map it through the document API adapter. |
| AutoLogin WebView PDF | Cookie-authenticated binary GET, with content validation and native open. Keep as a documented exception; migrate its endpoint lookup, not into the JWT client. |
| FTDT generated client | Map generated DutyPeriodExpanded to an Application-safe/domain-owned shape before moving IFTDTRestApi. Preserve sync semantics and retry budget. |
| Menu / favorites services | Move from head to Infrastructure using generated-client wrappers, connectivity port and context invalidation. |
| Endpoint compatibility adapter | Remove only after Settings, WebView, auth-result/dynamic-feed clients and all other actual consumers have migrated. |
Demonstration: force a log upload failure and count one transport call, then let the queue schedule its next attempt. Switch environments on an already-created client and inspect the next request host. Change bearer and verify every generated adapter obtains the current token safely; do not mutate shared default headers in a way that mixes concurrent sessions.
Deletion proof: run the symbol inventory and the following candidate search. Each remaining match is classified; comments and test names are not executable dependencies, and a clean text search does not replace the symbol/DI check.
rg -n '\b(IRestService|RestService|IRestEndpointProvider)\b' \
ALPAMobile ALPAMobile.Application ALPAMobile.Infrastructure ALPAMobile.Presentation \
--glob '*.cs' --glob '*.razor'
Generated NSwag adapters are intentional transport paths alongside the general IRequestProvider, explicit raw JWT transport and XML/image/PDF streams. The completion rule is one policy owner per path, not an inaccurate claim that every JSON request goes through one interface.
Phase 6 acceptance and testability
Marker: Transport retirement test marker. Status: implemented; unit/architecture suites and iOS/Android compilation pass. See the phase evidence. Device transport and viewer acceptance remain outstanding.
What can be validated now: Every former RestService path, generated adapter and endpoint consumer is now in scope for integrated regression.
| ID | Pass condition | Validation / evidence |
|---|---|---|
| P6-A1 | RestService/IRestService and the endpoint compatibility adapter have zero executable consumers before deletion; IAuthRestClient is rebound and retained. | Symbol/DI inventory plus candidate text search; fixtures/documentation classified separately. |
| P6-A2 | Generated adapters use the current endpoint/token without shared-header races; FTDT generated types do not leak across Application. | Concurrent-token/endpoint tests + architecture and mapped-contract fixtures. |
| P6-A3 | Batch logs send once per queue attempt; favorite adds and subscription flips do not replay; 201-with-body favorites compatibility remains. | Handler counts and existing REST regression fixtures rehomed to their owners. |
| P6-A4 | Document JSON download and cookie PDF/image/XML exceptions preserve their distinct protocols and contracts. | Protocol-specific handler tests + native document open checks. |
| P6-A5 | Any deferred dynamic-content transport pilot and SiteStore/cache/log-store dispositions are resolved and documented. | Codec/deadline/error regression evidence; no unowned retry or protected cache-write path. |
What this phase does not yet prove: All service/transport behavior can enter the complete regression run. DataManager removal and the stabilized integration candidate still require Phase 7.
Phase 7 — Retire DataManager when the inventory permits
Implementation update: The Phase 7 candidate deletes DataManager, IDataManager, and IAuthContextCache. FtdtQueries uses the existing repository port, and sign-in clears API/document data before publishing incoming identity. The executable consumer inventory must remain empty. Automated evidence and the remaining platform acceptance gates are recorded in the implementation record; whole-app device/UI acceptance and final base-branch integration remain open.
Phase 7 walkthrough — prove the facade can disappear
// Implemented adapter: preserve report-and-empty behavior on repository errors.
public sealed class FtdtQueries(IFTDTDutyPeriodRepository repository) : IFTDTQueries
{
public IReadOnlyList<IDutyPeriod> GetAllDutyPeriodsList() => Read(repository.GetAll);
public IReadOnlyList<IDutyPeriod> GetSoftDeletedDutyPeriodsList() => Read(repository.GetSoftDeleted);
private static IReadOnlyList<IDutyPeriod> Read(Func<IReadOnlyList<DutyPeriod>> read)
{
try { return read(); }
catch (Exception error) { error.Report(); return []; }
}
}
The repository interface already lives in Application; its FTDTDataBase implementation still lives in the head. DI can supply that implementation without adding an Infrastructure → head reference. Do not claim all persistence has moved merely because the query adapter has moved.
Concrete before/after inventory: App no longer injects DataManager for update checks; scaffold routers resolve their live narrow ports; MEC readiness subscribers use the moved message; the two ViewModel classifier calls use the shared classifier; DI aliases no longer resolve the aggregate. Remaining legacy tests are rehomed, not dropped to make deletion compile.
Exit demonstration: build the container, resolve each port and router, run the full domain suite, then verify there is no active aggregate-service consumer. If any remains, name the file, member and blocking work item; keep the facade milestone open. The final base-branch integration still waits for the full isolated-branch acceptance gate in Branch and PR policy.
Phase 7 acceptance and testability
Marker: Complete refactor candidate marker. Status: code candidate implemented; automated validation is tracked in the phase evidence. P7-A4 device/UI acceptance and P7-A5 final integration remain open.
What can be validated now: The entire isolated branch is testable as a release/integration candidate; this marker alone does not authorize the base merge.
| ID | Pass condition | Validation / evidence |
|---|---|---|
| P7-A1 | FTDT queries use the existing repository port and preserve report/empty behavior; no Infrastructure dependency points to the head database. | Repository fake + architecture tests. |
| P7-A2 | App, routers, messages, classifiers and DI have no executable DataManager/IDataManager/IAuthContextCache dependence. | Final symbol/container inventory; any actual remaining facade consumer keeps this criterion open. |
| P7-A3 | Behavioral tests are rehomed with their responsibilities, not deleted to satisfy compilation; architecture documentation reflects final ownership. | Test inventory comparison + build/unit/architecture results. |
| P7-A4 | Integrated branch passes the full login/logout/offline/Search/prefetch/endpoint/document/FTDT/favorites regression and required iOS/Android UI suites. | Tested commit SHA, TRX/UI logs and device evidence; every failure diagnosed and resolved. |
| P7-A5 | Final integration PR targets imp/blazor-hybrid only after stabilization/review; its diff matches the tested revision. | Final PR checklist and revalidation after any base synchronization/conflict resolution. |
What this phase does not yet prove: This is the final whole-app acceptance point. Base integration remains blocked by any unresolved criterion, deferred facade work, incomplete platform evidence or policy/review requirement.
6. Testing Strategy
| Level | What |
|---|---|
| Unit (Infrastructure) | One <Domain>ServiceTests per service against Fake<Domain>ApiClient, InMemoryCacheStore, FakeConnectivityService, and narrow auth fakes. Matrix: live-hit, live-fail→stale, offline→stale, corrupt→live, typed auth/cancellation no-fallback, refresh bypasses memo, raw-factory mapping, and exact cache/search keys. |
| Unit (cache/context) | Generation capture, logout during fetch, atomic cache/index writes, no stale protected data after 401, corrupt payload removal, old-payload compatibility, foreground/background single-flight, caller cancellation that abandons only its wait, and exact shared-task removal. |
| Unit (transport) | AuthenticatedRequestProviderTests, RequestProvider_ContractTests (401 code, safe-read retry budget, no automatic write retry, Retry-After, caller cancellation, raw auth status preservation and bounded transport retry), TransientNetworkErrorTests (moved from DataManager_TransientNetworkErrorTests). |
| Unit (auth) | Existing Authentication tests re-targeted at the narrow ports; concurrent refresh coalescing, secure credential erase, LogoutParticipant ordering, and account-locked event dispatch. |
| Architecture | LayerDependencyRulesTests (+2 rules), source/IL HeadConsumerRatchetTests (inventory shrinks per phase — a PR that grows one is rejected), raw-boundary checks, and options validation/change-source tests. |
| Re-homed, not deleted | existing DataManager_*Tests.cs fixtures move to the class that now owns the behaviour; existing RestService_*Tests.cs fixtures to the client/decorator that does. |
| Live | Per phase exit criteria: use isolated test devices and approved test accounts. Capture prefetch outcomes and Search results; no new seed data in real accounts. Follow the tooling-conditional repository workflow; do not claim device evidence from a build alone. |
7. Validation and final integration gate
| Stage | Evidence required |
|---|---|
Documentation PR to imp/blazor-hybrid |
Docs build/tests, generated navigation freshness, local links/anchors and browser rendering; no claim of runtime refactor validation |
| Implementation PR to the dedicated branch | dotnet build + unit/architecture suite; focused characterization and phase scenarios; attach tested SHA and preserved failure evidence |
| Stabilization on the dedicated branch | Integrated cross-domain login/logout, concurrent refresh, offline/cache, Search/prefetch, endpoint-switch, document and generated-client regressions |
Final PR from dedicated branch to imp/blazor-hybrid |
Full build/unit/architecture validation and iOS/Android UI suites for the integrated candidate; required manual/device scenarios; all blocking failures diagnosed and resolved; reviewer acceptance |
# Refactor implementation candidate (not a requirement to run app code for an HTML edit):
dotnet build ALPAMobile.sln
dotnet test UnitTest/UnitTest.csproj --logger "trx;LogFileName=test.trx"
# UAT/release/integration candidate; requires isolated booted test devices:
./run-ui-tests.sh ios
./run-ui-tests.sh android
# Documentation-only PR:
dotnet run docs/build-nav.cs -- --check
dotnet build DocsTests/DocsTests.csproj
dotnet test DocsTests/DocsTests.csproj --logger "trx;LogFileName=docs.trx"
Read the .trx before re-running a failed suite. Preserve the failing test identity and logs. A UI failure is an integration/release blocker until diagnosed. Follow repository rules for optional live tooling and dedicated simulators; a missing optional bridge is not a new gate. Record unperformed device checks honestly, and complete the final platform regression evidence before integrating the refactor into the base branch. Test only approved test accounts.
8. Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Cache key drift → cold cache + empty search after upgrade | Keys only from SearchContentRegistry.Keys; Phase 2 exit checks the index after fresh login |
IConnectivityService semantics differ from HasConnectiviy (ConstrainedInternet, DEBUG force-offline) |
Phase 0 verification item; adapter fixed before any service uses it |
| 401 recovery regressions (AB#2720) | One shared unauthorized code, safe-read-only one-refresh recovery, concurrent refresh coalescing, and tests for refresh exception/failure |
| Retry multiplication or unsafe write replay | One retry owner per client; safe-read default; no automatic write retry without an idempotency contract; remove/disable overlapping Polly policy before enabling a resilience handler |
| Endpoint drift or failed Debug environment switch | Options validation on start, explicit change-token signal, options-backed IRestEndpointProvider adapter, and Debug/Release hot-swap tests |
| Logout race restores protected data or search rows | Generation/write barrier and atomic cache/index operation; logout participant invalidates before clearing and a fetch-race test proves old work cannot commit |
| Direct JSON → domain mapping bypasses validation | Raw DTOs plus pure RawRepresentationFactory<T> at every external boundary; architecture check and compatibility fixtures |
| Bearer no longer pushed into NSwag clients | Wrappers set the header per call from IAuthTokenProvider; Phase 6 live check on FTDT sync + menu + favorites |
MEC auth-required exceptions today abort the cache fallback (DataManager.cs source evidence) — a Result code could silently change page behaviour |
Phase 2 pilot keeps the rethrow semantics via a throw in the service when Error.Code == "auth.required"; §11 Q2 decides the end state |
ILogoutParticipant order matters (bearer cleared before auth events; cache/token/credential cleanup order) |
Explicit Order property; test asserts the sequence Authentication.RunLogOutTaskAsync performs today |
Legacy XAML VMs break when DataManager methods become delegations |
Preserve signatures for actual remaining users; measure the shrinking symbol inventory and link AB#2764 only where a real dependency remains |
Dynamic-feed plan builds ContentResolutionContext.User on IAuthentication before Phase 5 |
Sequence rule in §9 |
Two CachedRemoteQuery policies drift back into per-service copies |
Start with a shrinking allowlist for current direct callers, including SiteStore and the log queue store. Remote-query reads/writes eventually use the coordinator; log-queue persistence has its own explicit boundary |
9. Sequencing with the dynamic-feed refactor
The Dynamic Feed Orchestrator Refactor Plan is already committed. DynamicContentHost.razor and the recent dynamic-host fix are present at this tip. Re-inventory remaining Presentation work instead of treating its historical Phase 0–4 list as all pending. LocalizedDynamicPageLoader, factories and feed error/empty states must keep their current behavior during the transport pilot.
Its user/session context work should consume ISessionState when this plan introduces that port in Phase 5. Resolvers continue using existing query ports. Do not introduce resolver-local copies of the remote-query cache. Shared dynamic-feed/site persistence (SiteStore) must participate in the generation barrier; the source audit includes it even though it is outside DataManager.
10. Completion Criteria
- Global domain query ports resolve to focused implementations, with document/navigation and FTDT head adapters explicitly composed through Application ports. Scaffold marker ports still resolve to the existing routers. No domain port resolves back through
DataManager. IRestService,RestService, and (after the compatibility gate)IRestEndpointProviderno longer exist;IAuthContextCacheis replaced by the logout participant.IAuthenticationis[Obsolete]; Infrastructure and Presentation depend only on the four narrow ports.- Remote-query cache writes pass the context barrier. Direct callers (
SiteStore, logging store, prefetch status readers) have migrated or have a documented non-query persistence boundary; security-trimmed cache/index writes cannot bypass the coordinator. IRequestProviderowns general hand-written JSON transport; generated NSwag adapters retain their own explicit policy ownership, raw auth transport is explicit,DynamicContentApiClientkeeps its custom wire codec until parity is proven, and podcast XML, image streams and the WebView cookie-authenticated PDF remain documented transport exceptions.- Every external DTO/JSON boundary has a pure raw-to-domain factory and cache payload compatibility tests; no service deserializes external JSON directly into domain models. Existing cache schemas/codecs remain compatible.
- Aggregate-service consumer inventories are empty, or a named deferred-facade milestone remains open. Do not mark the whole plan complete while that milestone still needs work.
- Build, unit suite, arch tests and both UI suites pass;
CacheContentServicewave and Search index verified live on both platforms.
11. Open Questions (iterate before Phase 0)
| # | Question | Recommendation |
|---|---|---|
| Q1 | Keep ObservableCollection<T> and bool refresh on the 16 ports, or move to IReadOnlyList<T> + CancellationToken now? |
Keep. Inventory actual consumers; do it as a follow-up per port once the services exist. Also leaves ISubscriptionsQueries’s command members alone. |
| Q2 | Auth-required end state: services keep throwing (today, DataManager.cs source evidence) or return null + ISessionState gate on the page? |
Preserve each domain’s characterized contract through extraction: MEC throws, notifications may return null, and guest/reference fallback is explicitly classified. In Phase 5, decide per registry policy; never turn auth.required into a generic null or stale fallback implicitly. |
| Q3 | Move CacheContentService to Infrastructure (needs IConnectivityService) in Phase 3, or leave in the head? |
Move its cross-domain orchestration to Application-facing code after platform events are ported; keep persistence, transport, and cache implementations in Infrastructure. |
| Q4 | One shared CachedRemoteQuery singleton (memo keyed by cache key) or one instance per service? |
One shared, thread-safe helper and one shared cache-context coordinator; registry keys are globally unique, and memo entries carry auth generation. |
| Q5 | Pilot domain: MEC (4 query members / 6 cache partitions, in the prefetch wave) or Jumpseat (4 reads + NSwag client + Cloudflare rules)? | MEC proves the extraction recipe; Jumpseat proves SDK wire compatibility and scoped challenge handling in Phase 3. |
| Q6 | Keep the NSwag-generated clients (FTDT, PilotComms, MobileMenu, Jumpseat) behind wrappers, or replace with IRequestProvider calls? |
Phase 3 refinement: retain PilotComms/Jumpseat SDK contract types and pure mappers, but send through IRequestProvider so auth, disposal and retry ownership remain shared. Tests pin routes, JSON bodies, date formats, successful-null behavior and scoped Jumpseat 403/429 rules. FTDT/MobileMenu transport is reviewed separately in Phase 6. |
| Q7 | Logout participants (Phase 5 sub-step) in scope, or defer? | In scope — it is what removes Authentication’s logout cache/platform/lazy-IServiceProvider coupling; login FTDT sync remains separately owned; without it the auth split is cosmetic. |
| Q8 | Work items: one Feature per phase under Feature AB#2087 (Presentation Extraction) / Epic AB#2081, or one Feature with 8 tasks? | Confirm the project backlog hierarchy before creating items; use phase work items and domain child tasks, with AB#2764 linked only for actual remaining facade consumers. |
| Q9 | BannerImageConverter HTTP: extend IImageCacheService with a stream API now, or separate work item? |
Separate work item; not on this critical path. |
| Q10 | Dynamic-feed companion plan | Already committed; refresh remaining-work inventory against DynamicContentHost and sequence session context through §9. |
| Q11 | Delete IRestEndpointProvider when ApiEndpointOptions lands? |
No. Keep the options-backed adapter until source/IL inventory proves zero consumers, then remove it in Phase 6 with host-switch regression coverage. |
| Q12 | Add Microsoft.Extensions.Http.Resilience immediately? |
No. First establish one retry owner and safe-method/idempotency rules around the current provider. Introduce the Microsoft handler only after the existing Polly/provider policy is removed or disabled and contract tests cover the resulting budget. |
12. Source evidence and verification scope
Source links below are pinned to b536554468e4b11c89ad7966bdeb922c25d7e4d1 so examples remain reviewable after the branch advances. They require repository access. File/member evidence is a fresh static source review; proposed types and acceptance tests above have not been implemented or executed by this documentation change.
The 24-member authentication contract and 23 direct test fake declarations were checked. The old “80 consumers” and “13 legacy VMs” were lexical file counts that included comments, declarations and strings; they are not retained as a dependency inventory. Phase 0 produces that inventory through symbol/DI analysis.