# FTDT Rest-Rules Sync — Test Plan (AB#2219 / WI-1908)

> **TLDR:** Covers the CAN §700.43 positioning field and USA §117.25(e)/(f) pre-FDP rest
> fields activated in AB#2219. No schema migration is needed (server or local) — both fields
> are nullable/safely-defaulted and the sync protocol is drop-in backwards compatible. The one
> real risk is a **dual-representation split**: the calc engine reads typed properties
> (`ImmediatePositioningDuration`, `PreFDPRest`) while `RestRulesInputs.razor` binds a separate
> flat UI-mirror (`ImmediatePositioningMinutes`, `PreFdpSleepOpportunityHours`,
> `PreFdpSelfDeclaredInsufficient`) with no auto-sync between them — a sync pull that forgets
> to populate the flat side makes correctly-synced data *look* lost to the user even though
> nothing was actually dropped. This was found and fixed in AB#2219 (see §3). A second,
> more severe issue was found via live device testing against production (§6): the generic
> reflection merge in `FTDTDutyPeriodService.PushAsync` silently erased locally-entered rest-
> rules data on the very first save, whenever the backend's create/update response omitted a
> field present in the request — also fixed and re-validated live. A **third, broader** issue
> was found 2026-07-24: production's regular background-sync `GET` pull *also* omits both new
> fields from its response (not just the create/update response AB#2235 covers), so the same
> safe-defaults fallback silently wipes correctly-entered data on the member's very next app
> launch — filed as **AB#2364**. The backend gap is **fixed on dev and still open on
> production** (verified live 2026-07-28 — dev returns all three fields on `ver=1.0`, production
> still omits them; see §6c), and the client-side defense-in-depth mitigation (PR #1884,
> `FTDTDataSyncHelper.PreserveRestRulesFieldsIfServerOmitsThem`) is implemented and
> **live-validated 2026-07-27**: a real duty period's Sleep Opportunity Hours / Insufficient
> Rest now survive a full cold app relaunch even though the server response still omits them.
> Test matrix below covers automated (unit), manual (device), and completed live-validation
> coverage.
>
> **Date:** 2026-07-08 (updated 2026-07-09 — live validation; 2026-07-24 — cold-relaunch
> regression found, AB#2364; 2026-07-27 — client-side fix live-validated, backend gap re-confirmed
> still open; 2026-07-28 — PR #1884 merged, backend fix verified on dev, value round-trip blocked)
> **Branch:** `feat/AB2219-rest-sync-01`
> **Related:** [BACKEND-HANDOFF-1908-CAN-REST.md](BACKEND-HANDOFF-1908-CAN-REST.md) ·
> [UI-READINESS-1908.md](UI-READINESS-1908.md) · WI#2198 (§117.25 live validator engine — separate, not covered here) ·
> AB#2235 (PushAsync fix) · AB#2342 (seed-sync exclusion) · WI#2356 (2026-07-24 authenticated
> re-validation) · AB#2364 (GET-pull field wipe, found by WI#2356) · PR #1884 (client-side fix,
> `fix/AB2364-ftdt-get-merge-01`, **merged to `imp/blazor-hybrid` 2026-07-28**) ·
> AB#2375 (no ErrorBoundary — unhandled API failure kills the Blazor circuit) ·
> AB#2376 (dev pilotcomms failing, blocks Blazor UI testing on dev)

---

## 1. Scope

This plan covers **data integrity for the sync path** of two field sets:

| Field set | Typed property (engine-facing) | Flat property (UI-facing) | Applies to |
|---|---|---|---|
| CAN §700.43 | `IDutyPeriod_CAN.ImmediatePositioningDuration` (`TimeSpan?`) | `IDutyPeriod.ImmediatePositioningMinutes` (`int?`) | CAN_UnAug, CAN_UnAugR, CAN_Aug, CAN_AugR |
| USA §117.25(e) | `IDutyPeriod_USA.PreFDPRest.SleepOpportunityHours` (`double?`) | `IDutyPeriod.PreFdpSleepOpportunityHours` (`decimal?`) | UnAug, UnAugR, UnAugSD, Aug, AugR |
| USA §117.25(f) | `IDutyPeriod_USA.PreFDPRest.SelfDeclaredInsufficient` (`bool`) | `IDutyPeriod.PreFdpSelfDeclaredInsufficient` (`bool?`) | UnAug, UnAugR, UnAugSD, Aug, AugR |

It does **not** cover the §117.25 rule-engine logic itself (violation calculation) — that is
WI#2198's scope. This plan is about whether the *data* survives every path it can take:
local entry → local save → sync push → server persist → sync pull → local save → local
display, across every combination of feature-flag state.

---

## 2. Migration strategy summary

*(Full research: see conversation history / BACKEND-HANDOFF-1908-CAN-REST.md "Database /
Migration Considerations" and "NuGet Package Backwards Compatibility" sections.)*

- **No schema migration required**, server or local. All three new API fields are nullable
  or safely-defaulted (`false`). Confirmed independently from three angles:
  1. Backend handoff doc states it directly.
  2. LiteDB is schemaless — new nullable properties on an existing document deserialize as
     null/default. The app's own `_migrations` marker system (`FTDTDatabase.cs`) exists for
     an unrelated problem (type-rename breaking polymorphic BSON discriminators), not this.
  3. `ApplyBaseDomainToDTO`/`ApplyBaseDTOToDomain` — the shared per-sync helpers — needed no
     structural change, only added field assignments.
- **Sync is delta, not full-refresh.** `FTDTDataSyncHelper.ImportDutyPeriodsFromLastDateAsync`
  calls `GetDutyPeriodsUpdatedAtAsync(lastDate, memberNumber)` — only duty periods the server
  reports as updated since the last sync timestamp are pulled. A duty period that predates
  this feature and is never touched again will **not** automatically backfill the new fields
  unless the server bumps its `UpdatedAt`/version.
  - Escape hatch: **Settings → FTDT Manual Sync** resets `FTDTSyncTimeStamp` to `2012-01-01`
    and runs `SyncAllAsync()` — a full historical re-pull. Use this in testing rather than
    assuming an ordinary sync will surface old records' new field values.
- **Old-server / old-client compatibility** is drop-in: absent fields deserialize via
  Newtonsoft `NullValueHandling.Ignore` to C# defaults (`null` / `false`), which are the
  documented safe values (§700.43 skipped, §117.25(e) falls back to rest-period-length proxy,
  §117.25(f) assumes no declaration).

---

## 3. Data-integrity guards (what protects the user today)

| Guard | Where | What it prevents |
|---|---|---|
| Toggle is a pure visibility/engine-selection switch | `RestRulesInputs.razor` (`@if (FeatureEnabled)`) | Disabling the feature cannot itself execute a write — no `OnChanged`/`ToggleSelfDeclared` handler fires while hidden, so no data is touched by the act of toggling |
| Shadow-mode result isolation | `GatedFTDTCalculationEngine.RunShadow` | New engine runs against a scratch `Results` object; legacy `Results` is restored in a `finally` even if the new engine throws — a shadow-mode comparison run can never leak into what the user sees |
| Schemaless local storage | LiteDB (`FTDTDatabase.cs`) | New nullable fields never require a destructive migration; missing fields on old documents deserialize to null/default |
| Sync push reads engine-facing typed properties | `FTDTDutyPeriodMapper.DutyPeriodToDTO` | Push is correct regardless of feature-flag state, because the UI (`RestRulesInputs.razor`) always writes the typed and flat properties together in lockstep |
| Old-server / old-client compatibility | NSwag DTO + Newtonsoft `NullValueHandling.Ignore` | A server or client on either side of the version boundary degrades to safe defaults instead of throwing |
| **Flat UI-mirror now populated on every sync pull** (fixed in AB#2219) | `FTDTDutyPeriodMapper.ApplyBaseDTOToDomain` | **Before the fix:** every sync pull built a fresh domain object with the typed property correct but the flat UI-mirror `null` — a pilot's entered data would render as blank fields on the very next ordinary sync (different device, reinstall, or routine delta sync), even though nothing was lost server-side. **After the fix:** both representations survive every pull. |
| **Push response merge no longer erases fields the server omits** (fixed in AB#2235, found via live testing) | `FTDTDutyPeriodService.PushAsync` | **Before the fix:** the reflection-based merge that applies the server's create/update response onto the live instance overwrote ANY field with `null` whenever that field was absent from the JSON response — reproduced live on production the moment a duty period with rest-rules data was first saved, because the backend's response doesn't yet echo the two new fields. **After the fix:** a `null`/absent server value is skipped instead of applied, so it can never erase a value the pilot just entered; a genuinely different non-null server value still wins. |

### 3.1 The dual-representation bug (root cause detail)

The calc engine (`FTDTCalculator_CAN.cs`, `FTDTCalculator_USA.cs`) reads only the typed
properties. `RestRulesInputs.razor` binds only the flat properties (Blazor's native `<input>`
binding needs primitive types, not a nested `TimeSpan?`/`PreFDPRest_USA?`). The two are
independent backing fields on the domain model with **no property-changed cross-write** — by
design, per the component's own code comment ("Write both: the typed side drives calculation,
the flat side keeps the existing persistence/UI bindings coherent").

`FTDTDutyPeriodMapper.DTOToDutyPeriod` constructs a **brand-new** domain object on every sync
pull. Before this fix, `ApplyBaseDTOToDomain` (the shared helper that runs for all 9 operation
types) set the typed side per-case but never touched the flat mirror at the base-class level.
Net effect: correct calculations, blank UI, on every device, after every sync — a false
"data loss" symptom. Fixed by adding 3 lines to `ApplyBaseDTOToDomain` (commit `66881ab8`);
regression tests: `DTOToDutyPeriod_CAN_PopulatesFlatUiMirror_NotJustTypedProperty`,
`DTOToDutyPeriod_USA_PopulatesFlatUiMirror_NotJustTypedProperty`.

---

## 4. Test matrix

### 4.1 Automated (unit) — `UnitTest/FTDTDutyPeriodMapperTests.cs`

| # | Scenario | Test(s) | Status |
|---|---|---|---|
| 1 | CAN round-trip preserves `ImmediatePositioningDuration`, all 4 operation types | `RoundTrip_CAN_{UnAug,UnAugR,Aug,AugR}_PreservesImmediatePositioningDuration` | ✅ |
| 2 | CAN null stays null (no positioning occurred) | `RoundTrip_CAN_ImmediatePositioningDuration_NullStaysNull` | ✅ |
| 3 | USA round-trip preserves `PreFDPRest`, all 5 operation types | `RoundTrip_USA_{UnAug,UnAugR,SplitDuty,Aug,AugR}_PreservesPreFDPRest` | ✅ |
| 4 | USA domain-side null `PreFDPRest` maps to safe DTO defaults (not an exception) | `DutyPeriodToDTO_PreFDPRest_NullOnDomain_MapsToSafeDefaults` | ✅ |
| 5 | Old-server response (fields absent) maps to documented safe defaults | `DTOToDutyPeriod_PreFDPRest_AbsentFromOldServer_MapsToSafeDefaults` | ✅ |
| 6 | **Flat UI-mirror survives a sync pull** (the fixed bug) — CAN | `DTOToDutyPeriod_CAN_PopulatesFlatUiMirror_NotJustTypedProperty` | ✅ |
| 7 | **Flat UI-mirror survives a sync pull** (the fixed bug) — USA | `DTOToDutyPeriod_USA_PopulatesFlatUiMirror_NotJustTypedProperty` | ✅ |

Run: `dotnet test UnitTest/UnitTest.csproj --filter FullyQualifiedName~FTDTDutyPeriodMapperTests`

### 4.2 Manual / device — opt-in / opt-out edge cases

These require a live device (MauiDevFlow) against dev, then prod, per WI 2219's step 3. Each
row states the concern and the expected safe behavior.

| # | Scenario | Steps | Expected (no loss/corruption) |
|---|---|---|---|
| 1 | Enter data → sync → toggle OFF → toggle ON | Enter CAN positioning minutes with feature ON. Sync. Toggle feature OFF via DevDashboard/Settings. Relaunch. Toggle feature back ON. | Field reappears with the original value; no re-entry needed. |
| 2 | Enter data → toggle OFF *before* sync | Enter USA sleep-opportunity hours with feature ON. Immediately toggle OFF (do not let a sync run first). Toggle back ON later; let sync run. | Value still present locally (toggle doesn't touch the record) and pushes correctly once synced — confirms the toggle never gates the sync/mapper layer, only the UI. |
| 3 | Feature OFF the whole session | Never enable the feature; create/edit duty periods normally. | No crashes, no unexpected data in `ImmediatePositioningMinutes`/`PreFdpSleepOpportunityHours`/`PreFdpSelfDeclaredInsufficient` — should stay null/false/default. |
| 4 | Cross-device consistency | Device A: feature ON, enter data, sync. Device B: feature OFF (different local override), sync. | Device B's local copy still has the correct data underneath (typed **and** flat, per the fix) even though its UI keeps the section hidden — toggling per-device must not cause device B's next sync push to blank out device A's entered values. |
| 5 | Backend kill-switch flip while data exists | With production data already synced, flip the backend `ftdtRestRulesEngine` flag `on → off → on` (or `shadow`). | No data mutation tied to the flag flip; only engine selection and UI visibility change. |
| 6 | Shadow-mode comparison doesn't leak | Run with `FTDTShadowModeEnabled` true (DEBUG default) across several duty periods, including ones that trigger new-engine exceptions. | User-visible `Results` always match the legacy engine's output; check `FTDT_SHADOW_ERROR`/`FTDT_SHADOW_DIVERGENCE` logs for exceptions, never a corrupted `Results` surface. |
| 7 | Stale local record picks up new fields | Use a duty period created before this feature existed (pre-dates the package bump). Confirm it shows blank rest-rules fields safely. Then use **Settings → FTDT Manual Sync** to force a full re-pull. | Before Manual Sync: blank/default, no crash. After: if the backend has since populated that record's fields (and bumped its `UpdatedAt`), the values now appear. |
| 8 | Old-server / rollback compatibility | Point a build at a pre-7.0.8 server response shape (or mock one) that omits the three new fields entirely. | Deserializes cleanly to defaults; no exception; existing sync behavior for all other fields unaffected. |
| 9 | **Create/update response omits a field the request sent** | Enter USA sleep-opportunity hours, save. Inspect the actual create/update network call (`MAUI network detail`). | Live-validated 2026-07-09 against production: response omits both new fields. Fixed in AB#2235 — value must survive both the immediate post-save reload and a full app relaunch. |

---

## 5. Sign-off checklist

- [x] All automated tests in §4.1 passing (`dotnet test UnitTest/UnitTest.csproj`) — 358/358, 2026-07-09
- [x] Manual scenario #9 (push-response omits field) verified live against **production** — see §6
- [ ] Manual scenarios §4.2 #1–4 verified on iOS simulator (MauiDevFlow) against **dev**
- [x] Manual scenarios §4.2 #1–4 re-verified against **prod** — entry, save, and edit-reload
      confirmed correct (2026-07-09, re-confirmed 2026-07-24); **full-app-relaunch reload
      previously reported correct (2026-07-09) does NOT reproduce as of 2026-07-24 — see AB#2364,
      §6 (2026-07-24)**; cross-device (#4) and backend-kill-switch (#5) still open, need a second
      device / ops coordination
- [ ] Scenario #5 (backend kill-switch) verified with ops/backend coordination
- [ ] Scenario #6 (shadow mode) log-reviewed for at least one full day of DEBUG-build usage
- [ ] Scenario #7 (stale record + Manual Sync) verified with a duty period older than the
      package bump date
- [x] `docs/detail/BACKEND-HANDOFF-1908-CAN-REST.md` and `UI-READINESS-1908.md` delivery
      status confirmed current (done as part of AB#2219, PR 1770)
- [x] AB#2342 (seed-sync exclusion) re-confirmed live under a real authenticated session,
      2026-07-24 — zero create/update/delete calls for `seed:` records during and after
      seeding a scenario — see §6 (2026-07-24)
- [x] **AB#2364 client-side mitigation (PR #1884) live-validated 2026-07-27** — a real duty
      period's rest-rules fields now survive a full cold app relaunch despite the backend GET
      response still omitting them; see §6b. PR #1884 merged to `imp/blazor-hybrid` 2026-07-28.
- [x] **AB#2364 backend fix verified on dev, 2026-07-28** — the GET-pull response now carries
      all three rest-rules fields on `ver=1.0` (no version bump was required). See §6c.
- [ ] **AB#2364 backend fix still absent on production** — re-confirmed 2026-07-28, the same
      endpoint and client build still omit all three fields on prod. Users remain dependent on
      the PR #1884 client-side mitigation until the fix is promoted.
- [ ] **AB#2364 value round-trip not yet proven** — dev returning the fields as `null`/`false`
      is indistinguishable from the original failure mode, which wiped values to exactly
      `null`/`false`. A non-null value must be written, synced and re-pulled intact before
      AB#2364 can close. Blocked on dev by AB#2376; see §6c.

---

## 6. Live validation results (2026-07-09)

Validated via `maui-ai-debugging` skill + MauiDevFlow against a dedicated iOS simulator
(`ALPA-FTDT-Test`), authenticated as member **S3514** against **production**
(`gateway.alpa.org` — confirmed via the Sentry trace headers on every request,
`sentry-environment=production`). Authorized for this account by the account owner.

**What was validated:**

1. Entry: filling `Sleep Opportunity Hours` and toggling `Insufficient Rest` correctly wrote
   through to the calc engine — confirmed live by the violation banner changing from
   `§117.25(e)` to `§117.25(e)/(f)` as each rule engaged.
2. Save → immediate reload (`edit-duty-period`): before the AB#2235 fix, both fields came
   back blank. After the fix, both survive.
3. Save → full app relaunch → reload: confirmed both fields survive a genuine cold LiteDB
   load, not just an in-memory ViewModel artifact.
4. Cleanup: both test records (server ids `168309`, `168310`) were soft-deleted via the
   app's own Delete action; confirmed via network log (`PUT .../dutyperiod/{id}?Deleted=true`
   → `200 OK` for both).

**New finding, not previously known, found only by testing the real save path:**
`FTDTDutyPeriodService.PushAsync`'s generic reflection merge overwrote locally-entered rest-
rules data with `null` on the very first save — before any second sync ever ran — because
production's create-response doesn't echo the two new fields yet. This is a **different, more
severe** defect than the AB#2219 flat-mirror gap (§3.1): it doesn't need a second device or a
delta-sync boundary to trigger, and would silently reproduce for any future field with the
same rollout timing. Filed as **AB#2235**, fixed in the same PR (commit `7a5eeaa9`).

**Separate, unrelated finding (not fixed, flagged for awareness):** the DevDashboard's
"Add Duty Period" test-scenario link (`/ftdt/add-duty-period`, no query string) never supplies
`Scope`, so following that exact link never renders the rest-rules section regardless of the
feature flag — a dev-dashboard wiring gap, not a production defect (real duty-period creation
flows go through `select-op-type`, which does supply both `OpType` and `Scope`).

**Not yet live-validated:** cross-device consistency (§4.2 #4, needs a second simulator/device),
backend kill-switch flip (§4.2 #5, needs ops coordination), shadow-mode log review over a full
day (§4.2 #6), and the stale-record Manual Sync backfill (§4.2 #7, needs a duty period that
predates the package bump).

---

## 6a. Live validation results (2026-07-24) — WI#2356

Validated via `maui-ai-debugging` skill + MauiDevFlow against a dedicated iOS simulator
(`ALPA-FTDT-Dev`), authenticated as member **S3514**. Part 1 ran against **dev**
(`gatewayapi.alpa.org`); Part 2 ran against **production** (`gateway.alpa.org`) after Part 1
confirmed the seed-exclusion guard holds authenticated. Authorized for this account by the
account owner.

**Part 1 — AB#2342 (seed-sync exclusion) re-confirmed, authenticated:**

1. Logged in as S3514 against dev. Seeded the "US — Rest Rules: OK" test scenario via
   `/ftdt/test-scenarios`.
2. Network capture cleared immediately before seeding, then checked through and after the
   seed operation: **zero** HTTP calls of any kind fired — the seeder is a pure local
   (LiteDB) write; no `seed:`-prefixed create/update/delete traffic reached the network layer.
3. This confirms the AB#2342 fix (`8b02a7f6`) holds under a genuine authenticated session, not
   just the unauthenticated structural re-run from 2026-07-23.

**Part 2 — rest-rules DTO fields, real (non-seed) record, production:**

1. Switched the session to production (same S3514 login, `Toggle ENV` + silent re-auth) after
   Part 1 passed — dev's `pilotcomms` notification service was returning persistent `503`s,
   which (see below) made the Blazor Hybrid UI unusable on dev this session.
2. Created a real US Unaugmented duty period via the actual app flow
   (`select-op-type` → `add-duty-period`, matching how production traffic is generated —
   **not** the DevDashboard's blank-query shortcut, which still has the known `Scope`-missing
   gap noted in the 2026-07-09 log) with `Sleep Opportunity Hours = 7.5` and
   `Insufficient Rest = ON`. Violation banner correctly showed `§117.25(e)/(f)`.
3. `POST /api/ftdt/dutyperiod` request body confirmed both new fields sent
   (`PreFdpSleepOpportunityHours: 7.5`, `PreFdpSelfDeclaredInsufficient: true`) →
   `201 Created`. Response body echoed only `Id`/`RecordUUID` — matches the documented
   AB#2235 gap (create response doesn't echo these fields).
4. Immediate post-save reload (`edit-duty-period`): both values survived. AB#2235 fix
   confirmed intact.
5. **Full app relaunch → reload: both values were LOST** — `Sleep Opportunity Hours` blank,
   `Insufficient Rest` toggle OFF. This **contradicts** the 2026-07-09 log's item 3
   ("confirmed both fields survive a genuine cold LiteDB load"). Root-caused live: the
   post-relaunch background sync's `GET /api/ftdt/dutyperiod/byalpaid/S3514/bylastupdatedat/...`
   response for this exact record (`Id 168661`, `RecordUUID 3300958c-3358-4555-8001-ecf43dcb8f09`)
   omits both fields entirely (not present as null keys — absent). The mapper's
   "absent from old server → safe defaults" fallback (built for records that predate the
   feature) fires on this response too, overwriting the correct local values. **Filed as
   AB#2364** — broader than AB#2235, since it isn't create-response-specific and will
   reproduce for any real user's data on their next app launch, not just immediately after
   save.
6. Cleanup: test record (server id `168661`) soft-deleted via the app's own Delete action;
   confirmed via network log (`PUT .../dutyperiod/168661?Deleted=true` → `200 OK`).

**Separate findings, not FTDT-specific (flagged for awareness, filed/tracked elsewhere):**

- The deployed iOS build at session start was stale by ~1 week (`ALPADocs.dll` dated 2026-07-08
  against source last touched 2026-07-15) despite incremental `dotnet build -t:Run` reporting
  success — a `--no-incremental` rebuild was required to pick up current source. See
  `feedback_ios_incremental_build_stale` operating note.
- `TopNav.razor`'s notification fetch (`OnInitializedAsync`) has no `try/catch`, and the app
  has no `ErrorBoundary` anywhere — an unhandled exception there (triggered by dev's
  `pilotcomms` service returning persistent `503`s this session) crashes the whole Blazor
  render circuit irrecoverably; even the built-in "Reload" link doesn't recover it, only a
  genuine WebView/process reload does. Not filed as a ticket yet — flagging for triage.
- MauiDevFlow's CDP bridge did not reliably re-attach across native-page navigation away from
  and back to a `BlazorWebView` host page within the same app process; a full app relaunch was
  needed to get a reliable CDP session. Tooling-side, not an app defect.
- WI#2363 filed (separately, mid-session ask): remove the inaccurate "All times Zulu/UTC"
  caption under the Time Zone field and update the affected tester-walkthrough screenshots.

**Still not live-validated:** same open items as the 2026-07-09 pass — cross-device consistency
(§4.2 #4), backend kill-switch flip (§4.2 #5), shadow-mode log review (§4.2 #6), stale-record
Manual Sync backfill (§4.2 #7) — plus AB#2364 needs a fix and re-validation before this test
plan's rest-rules-fields coverage can be considered complete.

---

## 6b. Live validation results (2026-07-27) — AB#2364 client-side fix + endpoint re-check

Validated via `maui-ai-debugging` skill + MauiDevFlow against the booted iOS simulator, built
from `fix/AB2364-ftdt-get-merge-01` (worktree `AB2364-FTDTGetMergeDom01`, commit `5d7d6f8a`,
PR #1884), authenticated as member **S3514** against **production**. Authorized for this
account by the account owner. Unit tests for the fix (`FTDTDataSyncHelperTests`, 4 new cases)
pass, 4/4.

**Part 1 — GET-pull endpoint, full re-check (all advertised API versions):**

Direct authenticated `curl` against `/api/ftdt/dutyperiod/byalpaid/S3514/bylastupdatedat/...`:

| `ver` | Result |
|---|---|
| `1.0` | `200 OK` — response omits `PreFdpSleepOpportunityHours`/`PreFdpSelfDeclaredInsufficient` entirely, same as 2026-07-24 |
| `1.1` | `405 UnsupportedApiVersion` |
| `2.0` | `405 UnsupportedApiVersion` |
| `3.0` | `405 UnsupportedApiVersion` |

Confirms the 2026-07-24 finding still holds exactly: this endpoint has exactly one version
(`1.0`), and it still doesn't return either field. **Backend fix is still outstanding** — this
is unchanged from AB#2364's original filing.

**Part 2 — client-side mitigation, live cold-relaunch repro:**

1. Via the actual Blazor Hybrid FTDT flow (`/ftdt/dashboard` → toggle Rest Rules Engine ON →
   `/ftdt/select-op-type?Scope=US` → Unaugmented → `/ftdt/add-duty-period`), created a real US
   Unaugmented duty period with `Sleep Opportunity Hours = 7.5` and `Insufficient Rest = ON`.
   Violation banner correctly showed `§117.25(e)/(f)`.
2. `POST /api/ftdt/dutyperiod` confirmed both fields sent (`PreFdpSleepOpportunityHours: 7.5`,
   `PreFdpSelfDeclaredInsufficient: true`) → `201 Created` (server id `168748`). Response body
   omits both fields, same known create-response gap AB#2235 already handles.
3. Immediate post-save reload: both values survived (AB#2235 fix intact).
4. **Full app relaunch** (`simctl terminate` + `simctl launch`, cold LiteDB load + fresh
   background sync): background sync's `GET .../bylastupdatedat/...` response for this exact
   record confirmed to omit both fields (same as Part 1). Re-opened the edit page: **Sleep
   Opportunity Hours = 7.5 and Insufficient Rest = ON both survived** — violation banner still
   showed `§117.25(e)/(f)`. This is the opposite of the 2026-07-24 AB#2364 repro (which lost
   both fields at this exact step) — confirms the client-side mitigation in PR #1884 works
   against a real production account, not just the unit-test fixtures.
5. Cleanup: test record (server id `168748`) soft-deleted via the app's own Delete action;
   confirmed via network log (`PUT .../dutyperiod/168748?Deleted=true` → `200 OK`). A second,
   unrelated stray record (`168747`, created via the native `FlightDutyPeriodListPage` →
   `select-op-type` path before the Rest Rules Engine flag was discovered to be OFF by default)
   was also cleaned up the same way.

**Separate finding, not previously documented:** the native flyout menu's "FTDT US" entry still
routes to the legacy native `ALPADocs.Pages.FlightDutyPeriodListPage` → "+" → native
`select-op-type` flow, which creates duty periods with **no rest-rules fields at all** (not
even a `PreFdpSelfDeclaredInsufficient: false` placeholder was sent... actually a `false` value
was sent, but no `Sleep Opportunity Hours` input exists on that screen). This is a **different
code path** than the Blazor Hybrid `/ftdt/add-duty-period` flow that WI#2356/AB#2364 test —
real members using the flyout-menu native entry point never see the Rest Rules UI at all
regardless of the `RestRulesEnabled` feature flag. Not filed as a ticket yet — flagging for
triage: worth confirming whether the flyout menu is meant to be repointed at the Blazor Hybrid
FTDT flow as part of the UI Refresh cutover (Epic AB#1821 / Feature AB#2087 gating), or whether
both entry points are intentionally meant to coexist during the transition.

**Not yet live-validated:** same items as before — cross-device consistency (§4.2 #4), backend
kill-switch flip (§4.2 #5), shadow-mode log review (§4.2 #6), stale-record Manual Sync backfill
(§4.2 #7). AB#2364's backend half is fixed on dev and still open on production; PR #1884
(client fix) merged to `imp/blazor-hybrid` on 2026-07-28. See §6c for the 2026-07-28 pass.

---

## 6c. Live validation results (2026-07-28) — AB#2364 backend fix verified on dev

**Build:** `imp/blazor-hybrid` @ `a1f7c5d2` (includes PR #1884). iOS 26.4 simulator, clean
install. API client pinned at `ALPA.Services.FTDT.ApiClientNet7_0` **7.0.8**. Account: ALPA ID
**2167997** ("ALA DART Testing"). Automated tier re-run at the same tip: **595/596** — all FTDT
tests green; the single failure is `MenuTabBarSourceTests.GetTabs_AppendsSelectedShortcuts_AfterBaseTabs`,
unrelated to FTDT sync.

### Client package 7.0.9 — published, and functionally identical to 7.0.8

7.0.9 is present and consumable on `ALPASharedLibrary-Feed` (32,544-byte `.nupkg`,
`lib/net7.0/FTDT.ApiClientNet7_0.dll`, single dependency `Newtonsoft.Json 13.0.3`, built
2026-07-28). Compared byte-for-byte against 7.0.8:

- symbol tables identical — the only differing strings are `7.0.8`→`7.0.9` and the PDB GUID;
- **80 differing bytes out of 103,424**, confined to the PE timestamp, the MVID, the version
  literal and the debug directory. No IL-body change;
- `PreFdpSleepOpportunityHours`, `PreFdpSelfDeclaredInsufficient` and
  `ImmediatePositioningMinutes` are present in **both** versions.

This is the expected result and corroborates AB#2364's finding that the defect was backend-only:
the client never needed a change, so 7.0.9 is a version-bump rebuild of identical source.
**Consequence:** the fixed code path is server-side, so the existing 7.0.8 pin in
`ALPAMobile/ALPAMobile.csproj` exercises the same client code and is sufficient to test the fix.
Bumping to 7.0.9 is optional, for traceability of what was validated.

### A/B: production vs dev, same account, same client build, ~10 minutes apart

`GET /api/ftdt/dutyperiod/byalpaid/2167997/bylastupdatedat/01-01-2012?PageNumber=1&Count=10&OrderBy=dpstart&Deleted=true&ver=1.0`

| | Production (`gateway.alpa.org`) | Dev (`gatewayapi.alpa.org`) |
|---|---|---|
| Records returned | 17 across 2 pages | 1 |
| Keys per record | 33 | **36** |
| `PreFdpSleepOpportunityHours` | ABSENT | **PRESENT** |
| `PreFdpSelfDeclaredInsufficient` | ABSENT | **PRESENT** |
| `ImmediatePositioningMinutes` | ABSENT | **PRESENT** |

Production was checked as the **union of keys across all 17 records**, not a spot check — §6b's
2026-07-27 finding reproduces exactly. The dev fix landed on **`ver=1.0`**; no API version bump
was required, which is consistent with the client being byte-identical.

### What is NOT proven — value round-trip

The dev record returned (`RecordUUID 4193edb7-90f0-48e8-b81d-3a119bcc75a4`, `UpdatedAt`
2026-07-10) carries `PreFdpSleepOpportunityHours: null`, `PreFdpSelfDeclaredInsufficient: false`,
`ImmediatePositioningMinutes: null` — that record never had values set. Since the original defect
overwrote good values with exactly `null`/`false`, **a response carrying `null` is
indistinguishable from the failure mode**. The schema is fixed; that real values survive the
GET-pull merge is still unverified. AB#2364 must not close on the schema result alone.

### Blocker — the FTDT Blazor UI is not interactive on dev (AB#2376, AB#2375)

The round-trip could not be completed. Every Blazor page load calls
`/v2/api/pilotcomms/notif/list`, which failed **12 of 12 attempts** on dev across three app
launches. The failure surfaces unhandled, `#blazor-error-ui` goes `display: block`, and Blazor
then **stops dispatching events entirely** — no `@onclick` fires anywhere in the app. Confirmed
dead against `ftdtdash.addDutyPeriodButton9` and the Mock/Live pills via CDP input dispatch,
synthetic bubbling `MouseEvent`, and direct `.click()`. `Page.reload` does not recover it: the
notifications call re-fires on the fresh render and re-kills the circuit.

Root cause is filed as **AB#2375** — there is no `ErrorBoundary` anywhere in the Blazor tree
(`grep -rn "ErrorBoundary"` across `ALPAMobile` and `ALPAMobile.Presentation` returns zero hits)
and `ALPAMobile/Components/TopNav.razor` has zero `catch` blocks while owning the notifications
feed. The dev-side failure is filed as **AB#2376**.

> **Caution on reading 503s in this app.** `ALPAMobile/MauiProgram.cs:145-168` defines a Polly
> fallback that *synthesizes* a `503` with the body
> `"Service temporarily unavailable after multiple retry attempts"` once retries are exhausted —
> retrying on timeouts, iOS `NSURLErrorDomain` -1005/-1001/-1009, and any status ≥ 500. A captured
> "503 + that body" therefore means only *the client gave up retrying*; it does **not** establish
> that the server returned 503. Diagnosing these needs server-side logs or a direct authenticated
> `curl` that bypasses the retry policy.

The legacy native XAML FTDT path is **not** a workaround for entering the values — it never
surfaced the rest-rules inputs.

### Two traps worth knowing before the next pass

1. **The FTDT dashboard data source defaults to MOCK.** It presented with every counter at
   `0:00`. Entering rest-rules values in Mock mode produces a convincing pass with no API traffic
   at all. The pill is backed by the persisted preference `Key_ScaffoldUseLiveData`
   (`ScaffoldDataSourceRouters.cs:25`) and can be set directly — useful, since the UI toggle is
   unreachable while the circuit is dead. **Confirm LIVE before trusting any sync result.**
2. **`dotnet build -t:Run` can report success without recompiling.** A source edit at 17:21
   deployed a DLL stamped 15:26, and the app came up in the wrong environment — nearly producing
   a false negative. Check the deployed DLL's mtime
   (`xcrun simctl get_app_container <udid> org.alpa.alpaMobile`) and use `--no-incremental` when
   a `.cs` change must land.

### Reaching the dev environment on a DEBUG build

Settings → tap the version label **5×** (enables DIAG) → **Toggle ENV**. The header then reads
`MODE: DIAG DEV` and traffic moves to `gatewayapi.alpa.org` / `authapi.alpa.org`. Two caveats:

- `Settings → FTDT Manual Sync` calls `DisplayAlertAsync` **before** `SyncAllAsync`
  (`SettingsPageViewModel.cs:281`). On iOS that is a `UIAlertController`, which is not in the MAUI
  visual tree, so an automation agent cannot dismiss it — **the sync does not start until a human
  taps OK.**
- `DevEnv` is DEBUG-only and in-memory (`AppProperties.cs:271`), never persisted, so **a cold
  relaunch always reverts to production**. Testing relaunch-survival against dev requires
  temporarily defaulting `debugDevEnv = true` and doing a full rebuild.
