Source baselines: WatermelonDB 0.28.0, React Native 0.83.1. This document covers the current in-app profile flow as well as the native lifecycle helpers; source review and standalone tests do not substitute for release-device testing.
formulus/src/profiles/nativeProfileLifecycle.ts exports:
export declare function prepareProfileDatabase(dbName: string): Promise<void>;
export declare function deleteProfileDatabase(dbName: string): Promise<boolean>;
export declare function isProfileDatabaseOpen(dbName: string): Promise<boolean>;
export declare function profileDatabaseExists(dbName: string): Promise<boolean>;
export declare function createProfileId(): Promise<string>;
export declare function restartProfileRuntime(): Promise<void>;All methods use NativeModules.UserAppModule on Android and iOS. Missing native
support or another platform rejects; there is no silent success/no-op fallback.
- The migrated Default keeps
dbName: 'formulus'only when legacy evidence is detected: a database/main sidecar entry, legacy app files, or legacy AsyncStorage state. Do not rename, move, copy, reset, or reconstruct an existing legacy database. An already persisted registry remains authoritative. - A fresh Default, like every new profile, uses
formulus_<lowercase UUID>: hexadecimal8-4-4-4-12, with hyphens. Display names are not database names. Paths, URI names, extensions, uppercase aliases, empty suffixes, whitespace, and trailing newlines reject in both JS and native code. UUID version/variant bits are not restricted. profileDatabaseExistscalls nativedatabaseExists. It returns true if any exact.db,.db-journal,.db-wal, or.db-shmentry exists, including an orphan sidecar. It only lists directory entries: it never reads a DB descriptor, opens SQLite, creates files, marks a name opened/retired, or selects a profile. False requires a successful listing with all four names absent. Uninspectable directories reject rather than imply a fresh install. A directory or even a dangling symlink with one of those exact names conservatively counts as evidence; this is not a database-health check or permission to open that entry. Cleanup still rejects non-regular entries. Results are a point-in-time filesystem observation, not proof of closure, and can be queried for prepared names too.createProfileIdcalls nativegenerateProfileId, returning an unprefixed lowercase UUID. Android usesjava.util.UUID.randomUUID().toString(); iOS usesNSUUID.UUID.UUIDString.lowercaseString. Both generate random v4 UUIDs without a JS random-number dependency. The wrapper validates the full lowercase8-4-4-4-12representation and rejects malformed native responses rather than normalizing them. Generation does not register a profile or create/open a DB; the registry owns collision checks and persistence. See Java UUID and Apple NSUUID.- Await
prepareProfileDatabasebefore constructing any SQLiteAdapter. JSI can open the database inside the adapter constructor, before its initialization promise resolves. This includes Default, retries, fallback adapters, background initialization, and future adapter entry points. Preparation marks the name before JS can initialize the adapter; it does not open SQLite or create files. Preparation is idempotent per name, including across native module instances; distinct names may also be prepared in the same process. It does not assert that an adapter initialized successfully or that its handle closed. Strict-name and retired-name validation still apply. isProfileDatabaseOpenmeans ever prepared in this native process, not a query of actual SQLite handles. A failed initialization still remains guarded. False for B means only that B has not been prepared in this process; it does not prove B is safe to open, or that A is closed. There is deliberately nomarkClosed, reset, or unregister API.deleteProfileDatabasereturns true only after all four exact filenames are confirmed absent; it also succeeds when they were already absent. False means that name was prepared in this process and cannot be deleted until a real cold launch. Invalid names, unexpected file types, and filesystem errors reject. Do not confuse a rejected promise with successful deletion.- Calls are synchronized process-wide across module instances. A name whose deletion was attempted while unopened is also retired for the rest of the process: preparation cannot race with deletion or reopen a partially deleted file set. Deletion can be retried. A deferred deletion does not retire a still running profile.
- Registry code owns durable tombstones. Retain a tombstone on false or any exception, including partial filesystem deletion. Never reuse a deleted name. Native retirement state is intentionally not a replacement for that registry.
restartProfileRuntimeis retained for a possible explicit bootstrap-error retry before database preparation, not profile switching. It reloads RN, not the OS process, and acknowledges scheduling, not completion. Prepared-name guards survive reload; a reload is not a verified SQLite-close barrier. Never put required cleanup after its promise.
- Load/migrate the registry. If there is no registry, combine
await profileDatabaseExists('formulus')with legacy app-file and AsyncStorage evidence before preparing any adapter. Preserveformulusif any evidence exists; otherwise generate an ID withcreateProfileId()and useformulus_<id>for the fresh Default. An existence error must stop/retry migration, not discard legacy identity. This helper does not inspect app files or AsyncStorage; that decision remains with the integrating agent. - At bootstrap, check every recorded live and tombstoned name with
isProfileDatabaseOpenbefore migration or cleanup. A detected prepared name stops bootstrap withE_PROFILE_COLD_LAUNCH_REQUIREDand the localized close/reopen screen: a JS reload cannot safely reconstruct the JS adapter map. - Retry tombstone cleanup before constructing an adapter. Keep failed/deferred tombstones for a later cold bootstrap; never select a tombstoned DB.
- Await native preparation before constructing an adapter.
database.tscaches the initialization promise and retains one WatermelonDBDatabaseper visited profile ID for the lifetime of the JS runtime. Revisits select that instance; they must not reopen the same SQLite name. Failed setup is not retried in the same runtime because it may have left native handles. - A switch unmounts the profile subtree (including WebViews) and quiesces work,
creates the destination's directories, persists selection, then selects the
target runtime and initializes its DB in-process (or reuses its retained
instance), invalidates profile service caches and remounts. It does not
call
restartProfileRuntime, reload JS, or use a WatermelonDB close patch. On a post-commit error (ambiguous registry write or failed destination open) the coordinator rereads the durable registry and resumes in-app only against a profile whose retained DB is healthy or was never opened; a failed adapter is never retried in the same runtime. If the durable selection cannot be opened and the previous profile is healthy and still registered, it is reselected. Otherwise the UI stays on a fail-closed recovery screen. - Deletion persists a tombstone and switches away if needed. A prepared name remains protected from physical deletion even after switching away. Cleanup runs at a later cold bootstrap, before adapter preparation, and keeps the tombstone on false or error; a warm switch does not clear native guards.
A successful in-app switch returns to the target profile without an app or JS restart. An open form/native picker blocks the unmount rather than being silently lost. If commit or target initialization fails after commit was attempted, the profile root shows a brief “restoring” state while the coordinator rereads the registry; it resumes a provably safe profile or stays on the recovery screen. Formplayer is the app-owned bundled build on both platforms (never copied per profile); on iOS its WebView read grant covers the common ancestor of the bundle and Documents so profile attachments render. Profiles are not a security boundary for code supplied by a server or custom app. A JS reload after preparation is different: bootstrap sees prepared names and shows the localized close/reopen screen; an actual process restart is needed to reconstruct a fresh runtime and to retry deferred cleanup.
The native opened-name set is not automatic instrumentation of WatermelonDB. Every production adapter constructor must be gated by preparation; the model assumes one app process owns these database paths, with no extension/secondary process or unguarded filesystem writer. It is not a cross-process lock or a verified close barrier. Retained adapters may remain open while other profiles run; no corruption-free guarantee follows from distinct filenames.
For the permitted plain names (without .db in dbName):
| Platform/adapter | Actual main-file location | Upstream implementation |
|---|---|---|
| Android JSI | context.getDatabasePath(dbName + ".db").getPath().replace("/databases", "") |
JSIInstaller.java, called by DatabasePlatformAndroid.cpp |
| Android Java fallback | Same expression: normally <app data>/<dbName>.db, not <app data>/databases/<dbName>.db |
WMDatabase.java |
| iOS JSI | App NSDocumentDirectory/<dbName>.db |
DatabasePlatformIOS.mm |
| iOS ObjC fallback | App NSDocumentDirectory/<dbName>.db |
WMDatabaseDriver.m |
Deletion addresses only <dbName>.db, <dbName>.db-journal,
<dbName>.db-wal, and <dbName>.db-shm in that directory. It does not glob,
recursively remove directories, call Android Context.deleteDatabase (wrong
location here), or touch another profile. It removes orphan sidecars even when
the main file is already absent. Existing symlinks/non-regular files are rejected
before any unlink. An unreadable/uninspectable directory is an error, not success.
The Android helper uses java.io.File APIs compatible with this app's minimum
Android API 24 rather than java.nio.file APIs introduced on API 26. The iOS
helper uses filesystem metadata and unlink, never recursive removeItem.
Neither implementation reads database contents or opens any SQLite engine.
Sources:
- SQLiteAdapter constructor and public operations
- Native/JSI dispatcher
- JSI adapter creation, destruction hook, and internal unsafeClose
- Database::destroy / destructor
- SqliteDb::destroy / sqlite3_close
- Android WMDatabaseBridge.invalidate
- Android native driver
- iOS native bridge
The JSI dispatcher calls nativeWatermelonCreateAdapter in its constructor.
That creates a C++ Database backed by SqliteDb. Database::destroy takes a
mutex, finalizes cached statements, and asks SqliteDb to close. The latter logs
sqlite3_close failures instead of supplying a reliable success result to JS.
JSI uses WAL; Android also sets synchronous=FULL. The Android Java fallback
opens with ENABLE_WRITE_AHEAD_LOGGING.
Watermelon has an internal JSI unsafeClose, but no public, portable
SQLiteAdapter.close()/Database.close() with an acknowledged all-connections
closed contract. Accessing _dispatcher._db.unsafeClose would couple app code
to private JSI internals, omit fallback connections, and not establish a safe
filesystem-deletion barrier. unsafeResetDatabase is destructive schema/data
reset, not lifecycle teardown, and is not used here.
Android's bridge invalidate() attempts to schedule its JSI destruction hook on
the JS queue; if that callback runs, it calls process-global native destroy
listeners. The Java
fallback driver has close(), but the bridge invalidation implementation does
not iterate its connection map and explicitly close those fallback drivers.
Do not assume dropping the JS singleton closes every native resource promptly.
On iOS the Watermelon JSI early-close hook listens for
RCTBridgeWillReloadNotification. RN 0.83's bridgeless RCTHost uses instance
invalidation rather than that legacy bridge notification. Normal C++ destruction
is still present, but that old notification must not be treated as proof of
bridgeless close timing. We do not manually broadcast it or call private close
methods from the main thread. Upstream DatabaseBridge.cpp itself warns about
asynchronous invalidation overlapping new bridge setup.
The current in-app switch retains the old WatermelonDB instance and selects a new instance for B on first visit, or the retained instance on a revisit. The profile subtree is unmounted and profile work quiesced before selection; retaining an adapter is not evidence that native handles have closed, nor does it prove all stale references or native work are harmless. Reopening a visited SQLite name with a second adapter is avoided by the per-profile JS map, not by native preparation (which is idempotent). A failed setup is not retried in that runtime.
The native static set is a per-name deletion veto, not a process-wide
preparation veto or SQLite handle detector/reference count. Multiple distinct
names may be prepared. Once a name is prepared, its files cannot be deleted by
this helper for the process lifetime, even if it is no longer selected or adapter
setup failed. Process death clears the set; JS reload, activity recreation, time
or registry changes do not. There is no markClosed escape hatch. Registry
bootstrap rejects a JS reload if any recorded name was prepared, before it can
create a duplicate adapter in the replacement JS runtime.
Each call to nativeWatermelonCreateAdapter creates a new C++ Database and
sqlite3_open handle; there is no JSI name-based reuse/deduplication. Normal
in-app switches do not dispose the JS runtime; retained instances stay alive.
On an external reload, runtime disposal may release the old JS host object and
close its connection, but source review does not establish a successful close
barrier for this app:
- Android's host calls
ReactInstance.destroy()before creating its replacement. However, ReactInstance.destroy callsreactQueueConfiguration.destroy()before TurboModule invalidation. ReactQueueConfigurationImpl.destroy quits the JS queue synchronously. Watermelon's invalidation hook then attempts to schedule early closure onto that JS queue. Therefore that hook cannot be treated as a guaranteed successful close on RN 0.83; normal native/runtime destruction must also be investigated. The Java fallback's missing explicit close iteration is a separate concern. - On iOS,
RCTInstance.invalidate()schedules destruction on its JS thread, whileRCTHostimmediately creates a replacement instance. The legacy Watermelon early-close notification is not a bridgeless completion barrier. Slow invalidation or retained references can overlap a new runtime; spending time in B is not a synchronization mechanism that proves A closed before returning to A. SqliteDb::destroy()marks itself destroyed, callssqlite3_close, and merely logs failures. It even logsDatabase closed.after the error branch. That log line alone is not evidence of successful closure. A leaked live connection and a later JSI or system-SQLite fallback opening A are possible concerns, not failures demonstrated by the standalone tests here.
Multiple handles from the same correctly configured SQLite library are not inherently corruption. Nevertheless stale work, exclusive locking, fallback between bundled/system engines, failed closure, and the engine's WAL-reset bug mean the in-app switch cannot be claimed corruption-free from source review. The static guard prevents this helper from unlinking prepared names; it does not prevent all possible SQLite corruption or prove quiescence.
Safeguards and release checks:
- Serialize switches, block open forms/pickers, quiesce writers/sync and
observers, unmount profile UI, then select the destination's retained adapter
or construct it once. Do not use
experimentalUnsafeNativeReuse, manually close through private Watermelon APIs, or bypass a lock error with another engine. - Do not checkpoint, inspect, open/close, or "repair" a live Watermelon file via a separate SQLite connection/engine. Neither existence checks nor UUID generation creates such a connection. No sidecar removal on warm switches.
- Test A → B → A in release, including JSI/fallback, background transitions, migrations/setup failures, in-flight work and native handle behavior. The expected path is warm first-visit B and retained A on return, with no duplicate A adapter. Instrument actual handle identity and close return values in a development build where feasible. A clean UI transition or helper-test pass is insufficient. If needed, run integrity checks via the owning adapter after quiescence, not an independent live engine.
- A fresh JS runtime after preparation is not an in-app switch: bootstrap requires a real cold launch rather than reconstructing adapters against names already prepared in the native process.
A separate source-level unsafe interleaving exists in Watermelon 0.28's Android
JSI lifecycle. DatabasePlatformAndroid.cpp
has one process-global destroyListeners vector. onDestroy appends to it;
destroy invokes every listener, then clears the vector. Neither function
filters by runtime, bridge, or DB name, and neither locks that vector.
JSIInstaller.cpp
routes JSIInstaller.destroy() directly to it. Each callback in
DatabaseBridge.cpp
holds a weak reference to its own Database, but only checks whether that object
still exists; it does not check which runtime requested destruction.
Consequently, if an old runtime's destroy call arrives after new B registers, that old call can destroy live B too. Concurrent registration/destruction would also race on the vector. This risk is independent of A and B having different filenames; retaining instances without reloading avoids this particular reload ordering on normal switches. This is a concrete hazardous ordering in the dependency source, not a reproduced failure of this app's single-host RN 0.83 reload path. RN's Android host orders instance destruction before replacement creation and synchronously shuts down its JS queue; that limits overlap but also raises the dropped-early-close-hook concern above. Do not infer from either observation that arbitrary delayed/headless/multiple-runtime callbacks are safe.
On iOS, onDestroy registers for RCTBridgeWillReloadNotification with
object:nil and queue:nil. A notification would invoke callbacks for every
registered live Database, not just one host. The current bridgeless reload does
not rely on that legacy notification; do not manually emit it to "fix" close
ordering, especially once replacement setup could be running.
The current flow does not reload RN between profiles or manually invoke Watermelon's destroy hooks. That avoids a reload-triggered teardown during a normal switch, but does not establish safe ordering for external/dev reloads, headless or multiple runtimes, or prove retained connections cannot interfere. Keep a single host; test native JSI and fallback behavior on devices. Distinct names alone are not a teardown guarantee, and these helper tests do not execute upstream hooks or establish corruption immunity.
- How To Corrupt: deleting a hot journal / mispairing files (§1.3–1.4)
- How To Corrupt: POSIX close(), multiple SQLite copies, unlink/rename while in use (§2.2–2.5)
- WAL: the WAL file is persistent database state (§4)
- WAL: automatic checkpointing and last-connection closure (§3.1, §6)
SQLite explicitly warns against unlinking or renaming an open DB, independently removing hot journals/WAL from a database being retained, and independently opening/closing a live DB through another SQLite copy. POSIX file-close locking semantics can interfere with other connections. Thus this module does not open system SQLite to probe a bundled Watermelon connection, checkpoint it, or attempt to determine whether it is busy. It does not read the SQLite header either.
SQLite's WAL guidance to open/close through SQLite to safely remove a WAL applies to preserving/recovering the database, not a reason to introduce another engine here. Our operation deliberately destroys the entire tombstoned database file set only when it could not have been opened by a conforming adapter in this process. A hot journal left by a crash is retained for recovery for every non-deleted profile. For a tombstoned profile, the entire data set is intentionally discarded, with the registry preventing reopening after partial deletion.
The current official WAL page also documents the WAL-reset bug, fixed in
SQLite 3.51.3 (backports 3.44.6 and 3.50.7). The app's pinned @nozbe/sqlite
3.46.0 predates those fixes. Upgrading/reviewing that dependency is a separate
follow-up; these lifecycle helpers do not fix that engine bug.
restartProfileRuntime remains exported for a possible explicit bootstrap retry
before database preparation. It is not a full app restart and never clears native
prepared-name guards. It is not used for switching; after preparation, a new JS
runtime cannot safely reconstruct the retained adapter map and registry bootstrap
shows the cold-launch screen. Native preparation itself still permits distinct
names; the reload restriction belongs to bootstrap, not the native guard.
The app already owns a ReactHost in MainApplication. UserAppModule calls
ReactHost.reload("Formulus bootstrap retry") on the UI thread. The
public ReactHost contract
and ReactHostImpl reload pipeline
show surface stopping, context destruction, ReactInstance.destroy(), new
instance creation, and restarting attached surfaces. This path is not gated by
developer support; async failures are handled by the host's error handling.
Activity.recreate() alone is insufficient because the application host can
retain the JS runtime. No process-killing/relaunch intent trick is used.
The new ObjC UserAppModule is explicitly registered in the Formulus Xcode
Sources build phase using RCT_EXPORT_MODULE. It dispatches
RCTTriggerReloadCommandListeners on the main queue:
- RCTReloadCommand implementation: dispatch to listeners is not
RCT_DEV-gated. - RCTHost: registers as a reload listener; the command invokes
_reloadWithShouldRestartSurfaces:YES, invalidates the old instance, creates another, and restarts attached surfaces. - RCTInstance.invalidate: suspends the surface presenter, schedules TurboModule invalidation/resource destruction on the JS thread, and terminates that JS thread.
- RCTRootViewFactory: owns/starts the
RCTHostused by the existing AppDelegate/SceneDelegate factory setup.
This is actual host/runtime invalidation, not a view remount, and does not use
exit() or depend on Metro. iOS invalidation is asynchronous and may overlap
replacement startup. There is no completion callback to infer that every SQLite
handle has closed. In particular, neither restart path clears the native
prepared-name set. Do not confuse this with a real OS process restart.
DevSettings.reload in RN 0.83.1
is a no-op outside __DEV__, so it is intentionally not used.
No Android SDK, React Native dependencies, Pods, or added test dependencies are
needed. These exercise the actual production filesystem/guard helper classes,
not a separate implementation. From formulus/android/ (create the output
folder first):
javac -Xlint:all -d app/build/profile-lifecycle-tests app/src/main/java/org/opendataensemble/formulus/ProfileDatabaseLifecycle.java app/src/test/java/org/opendataensemble/formulus/ProfileDatabaseLifecycleTest.java
java -cp app/build/profile-lifecycle-tests org.opendataensemble.formulus.ProfileDatabaseLifecycleTestFrom formulus/ios/ on macOS (create the output folder first):
clang -fobjc-arc -fblocks -Wall -Wextra -Werror -framework Foundation Formulus/ProfileDatabaseLifecycle.m Tests/ProfileDatabaseLifecycleTests.m -o build/profile-lifecycle-tests/ProfileDatabaseLifecycleTests
build/profile-lifecycle-tests/ProfileDatabaseLifecycleTestsThe suites cover strict names; all four suffixes and orphan WAL; neighboring files; existence probes without preparation; 128 lowercase v4 UUIDs per platform; I/O errors, symlinks/non-regular files and deletion retries. The current native contract permits A and B to be prepared by the same or separate helpers, repeats A idempotently, and vetoes deletion of either prepared name (including after module replacement). Cold child processes clean tombstone files before preparing legacy or UUID target names and cannot reset the parent's guard.
Each platform runs 64 races in separate fresh child processes: 32 prepare/delete, 16 two-helper same-name, and 16 two-helper different-name preparations. Different names can both prepare; neither prepared name can be deleted in that process. Fresh processes test process-scoped state without adding a reset API. These tests use ordinary filesystem fixtures, not shared live SQLite connections. The UUID sample checks do not prove collisions impossible. The iOS suite is a standalone macOS Foundation executable, not a simulator/device test. Neither suite validates real RN module recreation, UI transitions, or Watermelon teardown.
A dependency-free Node 24 check stripped the wrapper's TypeScript syntax and executed it against a mocked native bridge. It passed forwarding, strict-name rejection, boolean deferred/success propagation, native error propagation, and unsupported-platform/missing-module failures. The extended check also passed existence forwarding without preparation, UUID forwarding/invalid-native-response rejection, and missing-new-native-method failures on both platform branches. This is not a real React Native integration test.
With dependencies subsequently available, the wrapper also passed:
# From formulus/, using installed tools without an implicit package install:
./node_modules/.bin/prettier src/profiles/nativeProfileLifecycle.ts --check
./node_modules/.bin/eslint src/profiles/nativeProfileLifecycle.ts
./node_modules/.bin/tsc --noEmit --skipLibCheck --strict --target esnext --module esnext --moduleResolution bundler src/profiles/nativeProfileLifecycle.tsThis is targeted wrapper typechecking, not the entire app's typecheck.
plutil -lint formulus/ios/Formulus.xcodeproj/project.pbxproj and
git diff --check passed. Full RN app native builds were not run. Pods remain
absent and only macOS Command Line Tools (not full Xcode) are selected. The
standalone JVM tests do not compile the Kotlin RN bridge; the macOS Foundation
tests do not compile the iOS RN module. The initial pnpm lint/format attempt
(before dependencies were available) timed out during dependency resolution;
subsequent direct installed-tool validation above passed.
After installing the package dependencies and platform toolchains described in
formulus/README.md, run these from formulus/ with Metro stopped. Native
methods require rebuilding/reinstalling the binary; JS reload cannot add them.
Use a test device/backup before testing deletion. Install over the existing build
with compatible signing to test migration; do not uninstall or clear app data.
# Android: connected ARM device, SDK/JDK installed.
# The preandroid script vendors Notifee and applies the required Android patches.
pnpm run android --mode release --no-packager
# iOS: full Xcode selected and a simulator installed.
bundle install
(cd ios && bundle exec pod install)
pnpm run ios --mode Release --scheme Formulus --no-packagerThe --mode, --scheme (iOS), and --no-packager flags were verified with the
installed CLI's run-android --help / run-ios --help; these rebuild commands
were not executed here. Use the CLI's --device option for iOS device testing
with signing configured. For an Android build without installation, after the
Notifee vendor/patch steps use (cd android && ./gradlew :app:assembleRelease).
- Build/run release Android and iOS with Metro stopped. Verify the native methods are registered in bridgeless mode, persisted profile selection loads, in-app switches do not call native reload, and old profile UI/services cannot issue work after the selection commit/unmount.
- On both platforms test a DB-only legacy install, an orphan sidecar-only install,
legacy-file-only and AsyncStorage-only installs, and a fresh install. Confirm
only detected legacy Default retains
formulus; fresh Default uses a generated UUID name. An unreadable existence probe must not silently choose fresh Default. Inspect the version-pinned paths and ensure existing Default data is unchanged. - Exercise real JSI and deliberately forced fallback adapters. Test first-time A → B, returning B → A, and repeated visits: switch in-app without JS reload, keep one WatermelonDB instance per visited profile, and do not reopen A on return. Verify WebViews unmount and old-profile work stops before selection. Force an external/dev reload after preparation and confirm registry bootstrap shows the close/reopen screen rather than creating a second adapter. Fully terminate/relaunch and confirm tombstone cleanup precedes adapter construction. Include setup failure, writes/sync/attachments in flight, rapid requests and background transitions.
- Verify active deletion returns false and leaves all files untouched, including after JS reload. Also verify a just-prepared DB whose setup fails is protected.
- Switch away, tombstone the old profile, confirm cleanup of its prepared DB remains deferred, then actually terminate the app process and relaunch. Confirm cleanup occurs before adapter construction and removes all four files before registry marks native cleanup complete. Check non-DB profile data cleanup and retention of incomplete tombstones on partial failure.
- Simulate process death and I/O failure partway through file cleanup; confirm the registry retries without reopening the tombstoned name. Verify startup never selects a tombstone even if only sidecars remain.
- Exercise background/resume, failed selection commits, repeated switch requests, external reloads, and Android API 24. Recovery and cold-launch screens must not expose old profile actions or dismiss back to live UI. Test any retained RN retry only for pre-preparation bootstrap errors; no retry loop may bypass the registry's cold-launch preflight.
- Confirm all adapter entry points (including any future headless service or secondary process) respect preparation and the single-owner assumption.
WebView isolation remains a separate risk: unmounting both WebViews is not proof that cookies, localStorage, IndexedDB, caches or origin-scoped state are partitioned by profile. Review shared origins and namespaces for custom apps and Formplayer, and manually test A → B → A for data/session leakage on both platforms. Neither the native guard nor retained DB instances provide browser-storage isolation. No end-to-end native/browser validation is claimed here.