Skip to content

[Epic][Android] Android app readiness: build the platform seam now, gate the release on the migration #137

Description

@N1k4G

A review of the current tree against docs/decisions.md for what it would actually take to ship this as an Android app. It is deliberately critical: most of the work is not Android work, and the shape of the plan follows from that.

Evidence is cited as file:line against 01a148a.

Revised 2026-08-28 (2nd pass) after a second review. Direction unchanged. Added: a production-shell hygiene gate (the current dist/ still ships diagnostic hooks and a legacy link), an end-to-end process-death acceptance test, explicit depth ownership rules for the session snapshot, native rotation testing, and a genuine A13/A14 split. §4.4 item 4 is now filed as #138. See the revision comments for the change lists.


1. Where we actually are

Thing State
Native project None. No android/, no Capacitor/Cordova dependency, no Gradle. package.json ships one runtime dependency (pixi.js).
What is deployed The legacy client (pages deploy src/, deploy.yml:91) — 18,935 LOC of plain JS.
Migration client 5,385 LOC of TS. WP-01…WP-07 merged: pure core, planner worker, versioned saves, presentation snapshots, Pixi renderer, site/asset pipeline.
Migration client as a game One wreck slice. game-controller.ts:22-28 clamps the diver to 18–34 m on a fixed 8–106 m route; there is no gas setup, no Rec/Tec/CCR mode, no tank switching, no second site, no drills. wreck-app.ts renders five HUD metrics and five buttons.
Migration client as a build Still a development/diagnostic shell — see §4.8.
PWA / installability Deferred by decision (docs/decisions.md, "PWA — Installation is deferred"). No manifest, no service worker, no icons.
Physical-device evidence None. The gate row "Recorded physical Android/iPhone matrix" says Available; matrix not recorded. WP-06's own artifact states headless Chromium throttled every sampled RAF interval above 33 ms, so it explicitly does not pass or fail a device budget (docs/wp-06-acceptance.md).
Platform decisions already locked Android 10 / API 29+, iOS 16+, portrait and landscape, touch, safe rotation, pause/re-layout; local saves only; no telemetry, ads, or commerce; EN + DE release-blocking. The architecture diagram already names "Browser / Capacitor services" as the platform adapter layer.

So: the platform target is decided, the architecture anticipates it, and none of it exists yet.


2. The central judgement: which client goes in the APK

This is the decision the whole epic hangs on, and it is worth being blunt about.

The legacy client cannot be the shipped Android app, even though it is the only complete one. It fails this repository's own Definition of done for a migrated slice on points that are not negotiable on a mobile store build:

  • "interactive controls and critical status have accessible, localizable, non-canvas representations" — the legacy HUD is painted into a single <canvas> (renderer.js, 10k LOC). TalkBack sees nothing. System font scale does nothing.
  • "every warning has visual equivalence, semantic audio priority, and mute/interruption behavior" — legacy audio has no mute/interruption lifecycle.
  • Its result screens already needed a bespoke canvas scroll offset to be usable on a phone ([Bug] Post-dive and game-over screens overflow the viewport with no way to scroll #120, state.js:64-85) — a workaround the DOM HUD gets for free.

Wrapping it in Capacitor to "have something on Play sooner" would buy a demo and cost a second native path to maintain, a second lifecycle/storage/input integration, and a throwaway store listing that has to be re-qualified from scratch when the migration lands.

Therefore the APK ships the migration client, and the Android release date is a function of migration completion, not of Android work. Roughly 5.4k TS LOC exists against 18.9k JS LOC of behaviour, and the TS is weighted toward model/planner/save rather than gameplay and rendering. Sites, modes, CCR, gas setup, tank switching and drills are all still ahead.

The corollary, and the actual point of this epic: that is not a reason to defer Android work to the end. It is a reason to build the platform seam now, against the wreck slice, so that lifecycle, insets, input, storage, worker/CSP and on-device frame cost are proven on real hardware while the surface is 5k lines — instead of discovering them across a finished 20k-line client.


3. Packaging: Capacitor 8, not TWA/PWA

Recommending we confirm Capacitor, current major (8), pinned to ≥ 8.4 in docs/decisions.md and close the ambiguity.

  • A TWA hands the Android lifecycle, back-button, insets, wake lock and durable storage to Chrome. Those five things are the mobile readiness problem; a TWA is the one option that gives us no control over them. It also makes Play's quality bar depend on a live web deployment and its offline behaviour, and it would ship whatever is deployed — today, the legacy client (§2). Bubblewrap generates the wrapper from a PWA manifest and needs Digital Asset Links for trusted fullscreen; a service worker is what makes the offline story acceptable rather than a hard protocol requirement, so the argument against TWA rests on the runtime-ownership points above, not on that detail.
  • Capacitor is already what the architecture diagram assumes, keeps DiveModel/DivePlanner/save/site schemas free of platform imports (as the architecture section requires), and gives us one JS build for web and native.

Verified 2026-08-28 (record the date, per the existing target-API rule):

  • From 31 August 2026, new Google Play apps and updates must target Android 16 / API 36; an extension to 1 November 2026 can be requested. We have no app yet, so there is no migration burden — start at 36. (Play Console Help)
  • Capacitor 8 requires Node 22+ and ships minSdkVersion 24, compileSdkVersion 36, targetSdkVersion 36. Our floor decision is API 29, so A2 uses minSdk 29 / compile+target 36.
  • The ≥ 8.4 pin is deliberate: SystemBars' native safe-area inset injection arrived in 8.3.0 and was extended to API ≤ 34 in 8.4.0, which is exactly the range our API 29 floor needs (§4.2, A7).
  • Capacitor 8 requires density in the activity's android:configChanges so the WebView is not reloaded on resize. A freshly generated project already carries it — A2/A8 verify it survives, rather than migrating it.
  • Consequence for the repo: package.json currently allows Node 20 ("node": "^20.19.0 || >=22.13.0"). That must become 22+ when the native project lands; CI already runs Node 22.

The same seam serves iOS later; everything in Phase A except the Gradle/Play items should be written platform-neutral. iOS stays BLOCKED_EXTERNAL (no Mac/Xcode environment).


4. Findings

4.1 Persistence and lifecycle — the largest hidden work item

Four distinct problems, not one:

(a) The storage port is synchronous and cannot host the native store. KeyValueStore is getItem(): string | null / setItem(): void / removeItem(): void (save-repository.ts:13-17), and LocalSaveRepository.load()/save() are synchronous on top of it. Capacitor Preferences — the store that survives what mobile OSes do to WebView local storage — is Promise-based. So this is not an adapter swap; the port, the repository and the startup path all become async, startup must await the initial load before constructing the controller, and writes need serialising so the 5-second autosave cannot race a pause-triggered save. Also: save() already returns a persisted flag for a refused write, and wreck-app.ts:191-200 discards it — on mobile, degraded persistence has to be visible to the player.

(b) A saved DiveState does not resume a dive. SaveGame carries {schema, version, savedAtEpochMs, state} and nothing else (save-game.ts:22-27) — the decompression model only. Route position, displayed diver depth, facing, torch, elapsed real time and held controls all live in GameController (game-controller.ts:56-63) and reset to defaults when a controller is recreated. tests/wreck-slice.spec.js:85-98 asserts only that restored depth is within 5 m; nothing checks route position, facing or torch. This needs a versioned game-session snapshot alongside the pure DiveState, with explicit ownership rules — see A4b, which is where the two competing notions of "depth" get resolved.

(c) Neither client persists on the signal mobile actually delivers. Legacy saves periodically and on beforeunload (game-loop.js:1261); the migration client saves on pagehide (wreck-app.ts:163-171). On Android, a backgrounded app that is then reclaimed may deliver neither. visibilitychangehidden (plus freeze) is the terminal signal, and today visibilitychange is wired to audio suspend only (wreck-app.ts:158-162). The legacy client gets only this web reliability fix — no Capacitor seam is retrofitted into it.

(d) There is no controller pause contract. GameController has start() and destroy() and nothing between (game-controller.ts:88,127). MAX_FRAME_SECONDS = 0.1 (:30) does stop a resume frame from time-warping the simulation, so this degrades gracefully rather than corrupting state — but the locked decision requires "safe rotation, and pause/re-layout", and a genuine pause()/resume() must: clear the transient #pressed set (a pointer held when Android backgrounds may never deliver its pointerup, leaving an input logically stuck on return), stop and re-request RAF deliberately, block new simulation steps, reset the timing baseline, flush the durable save as far as the platform allows, suspend audio and release the wake lock.

None of (a)–(d) is proven by its own unit test. They can each be green while the real reclaim path is broken, which is why A5b exists as a single end-to-end gate.

4.2 Layout, insets and orientation

  • index.html:5 has no viewport-fit=cover, and there is no env(safe-area-inset-*) anywhere in the migration client. .wreck-controls sits at bottom: 1.25rem (diagnostic.css:352-361) — directly under the Android gesture bar on a modern phone.
  • 100vh is used throughout (diagnostic.css:14,23,211,422,429), including .wreck-shell { height: 100vh } in the ≤720px query. In mobile browsers that is the largest viewport, so the controls sit below the fold while the URL bar is showing. Needs dvh/svh.
  • Android WebViews below version 140 return incorrect env(safe-area-inset-*) values — the top inset comes back as 0 or off by a few pixels. Capacitor's SystemBars reads the real insets from WindowInsetsCompat and injects parallel --safe-area-inset-* custom properties (insetsHandling: "css", the default), which is why A7 writes each edge as var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) rather than env() alone.
  • No orientation handling in the shell. The renderer is correct here — pixi-renderer.ts:108-112 explicitly invalidates the cull window on rotation, and GameController drives resize from a ResizeObserver (:97-107). The layout is the gap: one @media (width <= 720px) block, no landscape treatment, and no rotation test at either level.

4.3 Touch input

  • No overscroll-behavior anywhere and no touch-action on the viewport or canvas (diagnostic.css:274,279; it is set only on the control buttons, :370). Double-tap zoom and pan are live over the play area. Pull-to-refresh is a real hazard for the browser client specifically — a default Capacitor WebView does not have it — so this is web hardening that also happens to be correct, not an Android-only fix.
  • The four hold controls do use pointer capture correctly (wreck-app.ts:337-350), so simultaneous ascend+fin works. Nav touch targets are 3.2 rem ≈ 51 px, above the project's internal 44 px standard. These two are already right and should stay right.
  • The control surface is five buttons. Full parity needs tank 1–6, gas info, fast-forward, torch, CCR setpoint ±, bailout, help/learn, drill options. The legacy touch.js (370 LOC) is a working reference for all of it.

4.4 Existing mobile accessibility defects

Four defects that exist today. These are not "verify on device later" items — they are wrong now:

  1. The phone layout hides NDL. diagnostic.css:440-442 hides .wreck-hud div:nth-child(4) at ≤720px; from the construction order in wreck-app.ts:236-240 (depth, time, gas, ndl, zone) the fourth metric is no-decompression limit. A safety-relevant readout must never be dropped to make a layout fit — re-rank or condense instead.
  2. The mute button is below the touch standard. .audio-control is 2.3rem ≈ 37 px (diagnostic.css:257-259), against this project's own 44 px internal standard — the same class of defect as [UX] Gas-setup touch targets are 38-41px, below the 44px minimum #121 in the legacy client.
  3. The entire app is a polite live region. index.html:9 sets aria-live="polite" on <main id="app">, and every per-frame HUD value updates inside it. With TalkBack that is continuous speech. The warning already has its own role=alert assertive region (wreck-app.ts:243-246), which is the correct pattern; the blanket live region should go.
  4. The status chip contradicts the warning — now filed as [Bug][A11y] Migration HUD status chip stays "Simulation running" during a warning — state encoded only by colour #138. updateHud() re-sets it to "normal" unconditionally (wreck-app.ts:322), and there is no warning-status string in either locale, so during a failure the chip turns red but still reads "Simulation running". That makes colour the only carrier of the state change, which the locked accessibility default forbids outright — the same defect class [UX][A11y] Farbfehlsichtigkeit: HUD-Zustände nicht nur über Rot/Grün kodieren #39 fixed in the legacy client.

4.5 Performance evidence

  • The evidence protocol is excellent and already written (300 warmed samples, p95/p99, long-frame count, three runs, same-session before/after). What is missing is any Android number at all.
  • A frame budget must not be invented before a baseline exists. Split into measure, then decide.
  • antialias: true at resolution: min(dpr, 2) (pixi-renderer.ts:66-71) is an untested fill-rate cost on a 1080p+ phone, which makes a real device capture immediately useful. Note deliberately: [Cleanup] setQualityTier() and placementCount have no callers #128 removed the quality-tier API precisely because nothing drove it. Do not re-add tiers speculatively — measure first, and only then re-introduce a knob that evidence demands.

4.6 Platform integration

  • Android back / gesture-back is unhandled — in a Capacitor WebView it exits the app. Mid-dive, that is data loss.
  • No screen wake lock. This game has long stationary phases by design (a 5-minute safety stop, deco stops, F while holding depth). The screen sleeps during exactly the moments the player is meant to be watching the computer.
  • Module worker under a native scheme is unverified. createPlannerWorker() builds new Worker(new URL(...), { type: "module" }) (planner-worker-client.ts:37-41). src/_headers allows worker-src 'self', but that is a Cloudflare response header — it does not apply to Capacitor's packaged local assets. A native policy is a separate artefact from the deployment headers, not a mirror of them. The planner is not optional, so a failure here is architectural.

4.7 CI and review loop

  • pr.yml installs Chromium only (:43) and playwright.config.js declares no projects — while docs/decisions.md claims "CI exercises Chromium, Firefox, and WebKit." The doc is ahead of the pipeline. Note also that adding Playwright's Firefox and WebKit engines does not demonstrate "the latest two major releases" of each shipping browser; those are two different claims needing two different mechanisms (A14a/A14b).
  • screenshots.mjs only shoots the legacy client, from a file:// URL, with an iPhone UA (scripts/screenshots.mjs:19,23-31). No per-PR phone-viewport shot of the client we are shipping — and a Playwright viewport can never show system bars, safe areas or native WebView behaviour (A13a/A13b).
  • No Android build in CI. docs/decisions.md already names "an Android debug build once native projects exist" as a target check.

4.8 The production shell is still a diagnostic shell

Only one development affordance is actually guarded out of production: the canvas reference adapter, behind import.meta.env.DEV (renderer.ts:55). Everything else ships in every build:

  • The safety gate renders a link to /src/diving-simulator.html (wreck-app.ts:121). Inside an APK that packages only dist/, that path does not exist — a dead link on the first screen the player sees. If someone "fixes" it by copying src/ in, the APK ships the entire legacy client instead.
  • bootstrap.ts:128 assigns window.plannerWorkerDiagnostic = verifyPlannerWorker() unconditionally, spawning a second planner worker purely for diagnostics on every start.
  • index.html:7 still titles the app "Diving Simulator Migration Diagnostic" — which on Android becomes the WebView document title.

The DoD already requires that "development renderer selection and diagnostics are absent from production". The renderer half is satisfied; the diagnostics half is not. This must be a gate on the exact bytes that go into the APK, not a manual check (A3).

4.9 Store and legal

Mostly external and already catalogued in the gates table. One correction to flag early: the beta-exit criterion asks for "at least 99.5% crash-free sessions where aggregate store diagnostics are available"Android vitals does not report that number. It reports user-perceived crash rate: the percentage of daily active users who experienced at least one crash while actively using the app. A different denominator, so the criterion cannot be evidenced as written and needs a normative amendment (C6).

No third-party crash SDK is needed, and none should be added. No ads, purchases, analytics or consent SDK also means the Data Safety form is close to all-negative and the bundle stays clean under the DoD's final clause.


5. Plan

Three phases. Phase A is the immediate workstream: the software-only items are READY today; the device-dependent items (A12a, and the on-device halves of A6/A7/A8/A10/A13b) become READY once A11 resolves which hardware exists. Under the project's own state vocabulary, a missing device is BLOCKED_EXTERNAL, so A11 is deliberately first among equals.

Phase A — Platform seam, built against the wreck slice

# Issue Depends on Effort
A0 Define the platform contracts before any Capacitor code. A PlatformServices object assembled in bootstrap.ts carrying an async persistent store, an app-lifecycle service, a back-navigation service and a wake-lock service; browser implementations stay DOM-based, native ones use Capacitor. GameController never imports Capacitor. This is what stops the seam degrading into scattered Capacitor.isNativePlatform() checks, and it is why wreck-app.ts:139 constructing LocalSaveRepository(window.localStorage) directly has to move. M
A1 Record the packaging decision in docs/decisions.md: Capacitor 8, pinned ≥ 8.4, TWA/PWA rejected with reasons, dev app id, minSdk 29, compile/target 36 with the 2026-08-28 verification date, Node 22+ S
A2 Add the Capacitor 8 Android project + npm run android:*; id monster.gorman.scuba.divingsimulator.dev; raise engines.node to 22+; confirm the generated manifest keeps density in android:configChanges; no signing material in source A0, A1 M
A3 CI: Android debug build as a compile gate, packaging the same verified dist/ — build once, run checks against those bytes, record the hash, sync that directory into the native project; never a second implicit Vite build. Plus a production-shell hygiene gate over those bytes: no legacy-client link, no plannerWorkerDiagnostic, no diagnostic branding, no other development hook (§4.8). Production CI also rejects a Capacitor config carrying a dev server.url or cleartext live-reload A2 M
A4a Async persistence contract: convert the KeyValueStore port and LocalSaveRepository to async, await the initial load before constructing the controller, serialise/dedupe writes, surface a degraded-persistence state when persisted is false A0 M
A4b Versioned game-session snapshot alongside DiveState, with explicit ownership rules: DiveState.depthM is the last authoritative simulation tick; the session field is named viewDepthM/controlledDepthM and is the continuous player position between fixed steps. Restore must never let one silently overwrite the other. Same question to answer for elapsedRealS — if it only drives bubble animation, it probably should not persist at all. Also: route position, facing, torch A4a M
A5 GameController.pause()/resume(): clear transient input, deliberate RAF stop/restart, no simulation steps while paused, timing-baseline reset, save flush, audio suspend, wake-lock release. Wire to the lifecycle service (Android pause/resume natively, visibilitychange/freeze on web). Legacy client gets only the web save-signal fix A0, A4b L
A5b Process-death acceptance test — the gate the whole chain exists for. Start a dive → change route position, view depth, facing, torch → background the app → let the save complete → kill the process via adb → cold start → the session restores with route, view depth, facing, torch and model state intact, and no control is still logically held. A4a/A4b/A5 can each be green while this fails A2, A5 M
A6 Android navigation policy, not a confirm dialog: back dismisses the topmost overlay first → during a dive opens a paused menu → only exits at a true top-level screen. Tested with both hardware/system back and gesture back on device A2, A5 M
A7 Viewport hardening: viewport-fit=cover, 100vhdvh/svh, overscroll-behavior, touch-action on the play surface, and safe-area padding written as var(--safe-area-inset-*, env(safe-area-inset-*, 0px)) per edge so API ≤ 34 WebViews get the injected value rather than the buggy env() one. Keep insetsHandling: "css". Verify on the API 29 floor device and a current cutout/gesture device A2 M
A8 Rotation at two levels. Browser e2e for responsive layout invariants; plus an emulator/device test that rotates during a live dive and re-checks the session invariants — a Playwright viewport change cannot exercise the Activity/WebView path that configChanges governs A4b, A7 M
A9 Screen wake lock: acquire only while foregrounded and diving, release on pause, re-acquire on resume, no-op where unsupported A0, A5 S
A10 Native worker + CSP smoke test, early. Acceptance is runtime, not build: launch the packaged app, the planner worker returns a forecast, no policy violations, unexpected navigation and external content blocked. Any HTML meta CSP for native is defined as its own artefact, separate from _headers A2 M
A11 Record the physical device matrix (an API 29 floor device, a current mid-range, a flagship). Until this resolves, the device-dependent items are BLOCKED_EXTERNAL by the project's own definition S
A12a Baseline measurement only, no pass/fail. Three 300-frame captures per named device across a scene × orientation matrix: at minimum one wreck-interior scene and one lighter exterior scene, each in portrait and landscape — the visible world window changes how much the renderer draws, so a single number per device would hide the worst case A2, A11 L
A12b Then set thresholds, in p95/p99 and frames over 16.67/33.33 ms. Play's Slow Sessions metric (a session where >25% of frames are slow, at 20 fps and 30 fps targets) is a good beta companion but needs live sessions, so it cannot be the pre-release gate A12a S
A13a dist/ review screenshots at an Android phone viewport, per PR — needs nothing, can land immediately S
A13b Emulator capture showing system bars, safe areas and native WebView behaviour, which no Playwright viewport can demonstrate A2, A7, A8 M
A14a PR CI runs the current Playwright Chromium/Firefox/WebKit engines for the functional/semantic suite; Pixi pixel-diff stays Chromium-only M
A14b A release compatibility matrix against named real-browser versions — a different evidence class from engine coverage, and the one that actually backs the "latest two majors" claim A14a M
A15 Fix the mobile accessibility defects in §4.4 (1–3): stop hiding NDL, raise the mute button to 44 px, remove the blanket aria-live. (Item 4 is #138) S
A16 Policy, needed before the next slices merge. Amend the Definition of done for a migrated slice with mobile clauses (touch parity for every intent, ≥44 px targets, on-device frame evidence, text scaling, TalkBack, rotation-safe), and write the mobile input & layout contract carrying forward what the legacy client learned the hard way (WP-014/015/016, #120, #121, #125) S

Phase B — Rides with every migration slice

Mobile readiness is a property each migrated slice carries, not a work package that happens once. The policy that makes that true is A16.

# Issue Effort
B3 Full touch control surface — tanks 1–6, gas info, fast-forward, torch, CCR SP±/bailout, help/learn, drill options — with touch.js as the behavioural reference. Cannot move earlier: it depends on those features existing in the migration client at all L
B4 Text scaling + TalkBack pass over the DOM HUD on device; focus order and live warning announcement. A verification pass, now that A15 and #138 have fixed the known defects M
B5 Per-site on-device frame evidence as each site lands from WP-08 onward, against the A12b thresholds M each

Phase C — Release hardening (mostly BLOCKED_EXTERNAL)

# Issue State
C1 Final identity, publisher and EU trader-disclosure decision → production app id, display name, icon BLOCKED_EXTERNAL
C2 Play account, App Signing ownership, upload key in a protected environment; release workflow produces an AAB; no keystore in source or evidence BLOCKED_EXTERNAL
C3 Re-verify the target API requirement immediately before every submission and record the date (the 2026-08-28 check is in §3) READY at A2
C4 Data Safety form, content rating, and store listing copy checked against the claims register Blocked by C1
C5 First-run safety acknowledgement, versioned and persisted through the A4a store, with the Safety & Methodology screen reachable after the gate rather than only before it READY at A4a
C6 Amend the beta-exit criterion to something Android vitals actually reports: a maximum user-perceived crash rate over the beta, a stated minimum sample, and zero reproducible release-blocking crash/lifecycle defects. Play's bad-behaviour thresholds (1.09% overall, 8% per device model) are the outer limit at which Play penalises an app — not a quality target; the owner should set a materially stricter internal figure. No third-party crash SDK Owner decision; docs/decisions.md is normative
C7 Save forward-compatibility check and mobile rollback drill (halt staged rollout) Blocked by C2

6. What I recommend not doing

  • Do not Capacitor-wrap the legacy client to get to Play sooner (§2), and do not retrofit the full lifecycle seam into it — it gets the web save-signal fix and nothing more.
  • Do not build a PWA/TWA path alongside Capacitor.
  • Do not add quality tiers, resolution scaling or AA toggles before A12a exists. [Cleanup] setQualityTier() and placementCount have no callers #128 removed exactly that API because nothing drove it.
  • Do not adopt Play's 1.09% threshold as the quality bar — it is the penalty line, not the goal.
  • Do not add a crash/analytics SDK to satisfy the beta gate — fix the metric definition instead (C6).
  • Do not package src/ into the APK to make the legacy link in §4.8 resolve. Delete the link.
  • Do not let the Android shell block on migration completeness.

7. Open questions

  1. Confirm Capacitor 8 (≥ 8.4) (A1) — the one decision gating the rest.
  2. Work-package number. docs/decisions.md is normative and WP numbering belongs to the strangler slices; this runs alongside them. Proposing its own package (e.g. WP-A) rather than a WP-08+ slot — the number is the owner's call.
  3. C6 wording — an amendment to a normative document, so it needs explicit sign-off rather than a quiet reinterpretation. Including the internal crash-rate figure.
  4. A4b scope: which session fields are contractually resumable, and specifically whether elapsedRealS is one of them.
  5. Which devices are physically available for A11 — this is what converts several Phase A rows from BLOCKED_EXTERNAL to READY.

8. Main risks

  • On-device frame cost on the API 29 floor device is unknown and could force renderer rework. The largest unquantified risk in the project; A12a exists to retire it early.
  • Persistence and lifecycle are a genuine subsystem, not an adapter. A0/A4a/A4b/A5 are the critical path, A5b is the gate that proves them together, and under-scoping them is how this epic would fail.
  • Worker/CSP under the native scheme (A10) — the planner is not optional; hence "early, and runtime-verified".
  • Schedule honesty: Android packaging is weeks; Android release is gated on migration completion plus external identity/legal gates. Never present it as "the app is close because the shell builds."

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions