KMP Phase 5: startup processor, runtime generations, restart-free reinitialization (R2) - #252
KMP Phase 5: startup processor, runtime generations, restart-free reinitialization (R2)#252stslex wants to merge 37 commits into
Conversation
Measured startup-stage and lifetime matrices at dev@c935227d, the graph-reader/capture inventory, restore/rollback call paths, the Room 3 close-terminality finding with the device entry-gate protocol, the chosen AppRuntime/GraphGeneration/AppScopeLifetime/StartupProcessor architecture, its invariant-preservation map, test plan, commit decomposition, and the stale-claim register for closeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
…GATE RED The Phase 5 mandatory Room entry gate, run on a real device (sdk_gphone64_arm64, API 34, arm64-v8a) with the production BundledSQLiteDriver and the production replacement code for BOTH paths (restoreFromSnapshot, rollbackToPreRestoreBackup). Measured: the swap is real on disk (fresh-handle sentinel read + inode change), and the read through the same AppDatabase object / DAO captured before close throws SQLException code 21 'Connection pool is closed' — Room 3 close() is terminal for the object; the Room 2.8.4-era 'captured DAOs follow the reopen for free' claim (kmp-migration-assessment.md:546) does not hold. It never serves stale pre-swap data: the failure is loud. Known-negative executed and reverted: bypassing the swap turns both tests red at the file-identity (inode) assertion. The committed test pins three properties: swap-is-real, fail-loud-never-stale, and a green-flip tripwire for a future Room that supports same-object reopen. Spec updated with the dated gate result (§7.1); implementation is STOPPED at this gate per the locked protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Independent fresh-context review of the spec: CONFIRM (no locked invariant relaxed), with three binding implementation conditions recorded in §16 — disposal ordering gated on the old UI region leaving composition, reinit-mode RestartRequired scoped to injected failures while the reopen gate is red, and the androidTest harness seam for AppUiGenerationsHolder — plus the reviewer's fan-out and stale-doc corrections folded into the plan. Implementation remains STOPPED at the §7.1 RED gate pending the maintainer decision (options in the PR description). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Unit Test Results 247 files 247 suites 13m 9s ⏱️ Results for commit d8a4465. ♻️ This comment has been updated with latest results. |
Records the dated maintainer decision with stable IDs (R1 descope / R2 db generation / H1 runtime host / H2 graph-owned self-replacement — R2 chosen, H1 ownership carried), the replacement invariant, the runtime-owned replacement transaction with the Running → Quiescing → ReplacingFile → BuildingGeneration → Preflight → Publishing state machine, quiescence over live captures (UI, collectors, in-flight workers), terminal-generation and post-close failure semantics, the DatabaseSnapshotProviderImpl mechanics split behind a DatabaseReplacement seam, the per-generation GREEN device gate and failure-injection matrix, and the reframed permanent characterization role of SameInstanceReopenAfterSwapDeviceTest. Supersedes spec v1's same-database architecture and marks review v1 (§16) non-authoritative, carrying forward its still-applicable findings; review v2 (§17) gates the implementation commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Review v2 (fresh-context, adversarial): CONFIRM — no binding condition violated, no STOP condition fires. Six binding implementation conditions recorded in §17 and folded into §4/§8/§9/§11: the snackbar NonCancellable deferred-commit live capture (new Quiescing drain sub-step + audit row), corrected worker-failure semantics (FAILED not retried; drain-to-close construction race), operation-identity-scoped coalescing, a CoroutineContext transaction marker for re-entrancy, RestartProcess scoping (ladder is RebuildInProcess-only; transactions startable from a terminal generation), and the explicit cold-start mutex rule. Notes folded: App() wrapper encloses the VM resolution, theme-independent interstitial, DatabaseReplacement in androidMain of core:data:backup:api, DataStore count, defaulted 4th root. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
… lifetimes
AppScopeLifetime (core:core commonMain) becomes the 4th create() bound-
instance root of the app graph: one generation-root SupervisorJob from which
every scope-owning app-scope singleton derives its childScope — per-consumer
supervisor isolation identical to the five anonymous
CoroutineScope(SupervisorJob() + dispatcher) sites it replaces, plus the one
property they lacked: a deterministic cancelAndJoin for the replacement
machine's Quiescing stage. Migrated: RestoreDialogChoiceObserver,
DriveBackupAuth, SnapshotExportRunnerImpl, and the two BaseApplication
startup chores. No anonymous scope remains in production
(git grep 'CoroutineScope(' exit criterion).
StartupProcessor extracts BaseApplication.onCreateGraphBootstrap as an
order-preserving refactor with typed outcomes (Proceed / RouteToRecovery /
RestartRequired): both runBlocking preflight boundaries, the S1→S2 ordering
and short-circuits, the RouteToRecovery and low-RAM planner guards, chore
failure policies, and the observer-before-Activity arming are unchanged and
now pinned by StartupProcessorTest (6 tests). The TestApplication no-op
bootstrap seam is untouched. MetroTestRule passes and cancels a per-test
lifetime; buildAppGraph defaults the root for the JVM identity tests (the
default IS the pre-Phase-5 behavior); AppGraphIdentityTest pins the root's
=== identity; AppScopeLifetimeTest (5 tests) pins the lifetime contract.
Phase 5 spec §8.2/§8.3; review v2 conditions applied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
AppRuntime (app/app, internal) is the application-owned host of the R2 handover unit: RuntimeGeneration(id, dbGeneration, database, graph, lifetime, viewModelStore) published atomically as one immutable value. Generation 1 builds lazily on first read (cold-build ordering preserved; mutex-free per the cold-start rule). Graph-only reinitialization hands the SAME open AppDatabase into the next generation under the relaxed quiesce order: Transitioning → UI-region disposal await → worker drain (drain, not cancel — the periodic chain survives) → snackbar in-flight-resolve drain → VM-store clear → candidate build + preflight (old reactors still alive; overlap harmless by bus identity) → atomic publish → outgoing lifetime cancelAndJoin. Any pre-publish failure republishes generation N fully serving; SaveableStateHolder slots restore its back stack. App() becomes the generation shell (spec §8.7): the ENTIRE body — including the AppRootViewModel resolution — composes inside SaveableStateProvider(id) + key(id) + a runtime-owned LocalViewModelStoreOwner; a DisposableEffect registered first in the region signals 'the UI let go' last. Completed transitions drop the old saved slot (no resurrection); aborted ones restore it. AppUiPhase/AppUiGenerationsHolder are the narrow app:common seam; AppReinitializationHost (core:core commonMain) is the root-bound intent contract — the iosMain AppReinitializer now REQUIRES a host by constructor (no TODO, no silent no-op; Phase 7 binds the real one), and AppRuntime implements it on Android. SnackbarManager gains the in-flight-resolve tracker the Quiescing drain awaits; the backup unique-work names are hoisted next to the new awaitBackupWorkersIdle drain. Harness: MetroTestRule publishes Generation(1, per-test ViewModelStore); TestApplication overrides the stream — instrumented tests never touch the production runtime. Proofs: AppRuntimeTest (9 JVM pins: same-db handover, candidate-unpublished- before-preflight, abort-leaves-N-serving with reactors alive, worker-drain timeout abort, deterministic old-lifetime join, single-flight + stale- expected coalescing, attached-UI gating); full app:app Regression device suite 42/42 green on emulator (incl. BackStackStateRestorationTest, the unchanged restoration oracle). Phase 5 spec §8.1/§8.7/§8.8; review v2 conditions 1 (drain sub-step), 6 (cold-start mutex rule) and the VM-resolution placement pin applied. The file-swap replacement transaction is the next commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
The R2 core (spec §8.4/§8.5). DatabaseSnapshotProviderImpl stops closing or swapping the published database: its swap methods split into validateSnapshotForRestore (pre-close gates, same checks/order/taxonomy) and replaceLiveDatabaseFile (pure file mechanics — sidecars, copy, atomic rename, NO close); rollback recomposes as getPreRestoreBackupFile + replace + deletePreRestoreBackup (hasPreRestoreBackup merged into the file accessor). closeAppDatabase lives beside the database (app:app stays Room-free) and enters AppRuntime as an injectable close verb. AppRuntime implements the new DatabaseReplacement seam (core:data:backup:api androidMain) and enters every generation's graph as the 5th create() bound-instance root; the two swap callers reroute — RestoreRecoveryCoordinator (S1 rollback + undo) and the restore flow, which also extracts into RestoreLatestBackupUseCase per the domain use-case rule. Two policies select the ending. RestartProcess (Android production, the default): validate → close (terminal) → replace, no quiescing, no phase change, startable from an already-terminal generation — byte-equivalent to the pre-split methods including the post-close-failure no-restart shape (review v2 condition 5). RebuildInProcess (instrumentation now, Phase 7 iOS): the full machine — strict Quiescing (UI await → worker drain → snackbar drain → VM clear → lifetime cancelAndJoin; every fallible step pre-close, aborts republish generation N intact) → close → ReplacingFile → BuildingGeneration (full production factory) → Preflight under a CoroutineContext transaction marker (a coordinator rollback from inside Preflight inlines into the CURRENT transaction — condition 4) → Publishing. Post-close failure ladder per the locked rules: fresh generation from the swapped file, or rollback + one more fresh generation, else the explicit Fatal outcome — the closed generation is never re-served (phase stays Transitioning). Same-operation requests coalesce onto the in-flight outcome; a different operation queues and gets its OWN result (condition 3). AppRuntimeReplacementTest (12 JVM pins) covers the §11.3 injection matrix through the factory/policy/provider seams: quiesce timeout, pre-close validation failure, close, file-replacement failure → rollback ladder, db-construction failure → ladder, inline S1 rollback + bounded retry, double preflight failure → Fatal, RestartProcess byte-equivalence + terminal-generation restart, same-op coalescing, different-op isolation. Full suite re-verified: repo testDebugUnitTest + detekt green; device: app:app Regression 42/42, database 30/30 (characterization gate reruns the production sequence via the split mechanics, still 2/2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
…ted suite RuntimeGenerationSwapDeviceTest — the §11.2 GREEN gate: the full RebuildInProcess transaction over the COMPLETE production database factory (buildAppDatabase: production driver + migrations chain), the real Metro buildAppGraph, and the real StartupProcessor preflight. One flow, two consecutive cycles (restore → rollback, three generations), proving every §11.2 point: inode-changing production swaps; terminal generations fail LOUD (pool-closed, never stale); freshly built AppDatabase + freshly resolved DAO see NEW and not OLD; graph dependencies resolve from the new DB generation; repeated cycles with monotonically advancing ids. The known-negative (swap bypassed via a graph-only transition) was executed — red at the inode assertion — and reverted per the gate protocol. UiGenerationSwapTest — the §8.7 UI boundary against the composed shell: a generation swap resets Nav3 to Home, the old stack is unreachable, and an Activity recreation AFTER the swap restores the NEW generation's state (the old generation's saveable slot was removed — no resurrection). DialogStateAcrossGenerationsTest — dialog exactly-once across a replacement: a pending RestoreSuccess published by generation N is visible to N+1 (DataStore-derived), and ONE acknowledgement clears it for both. Harness: MetroTestGraphHolder.install() becomes the generation-swap seam (clears the previous generation's ViewModelStore, publishes an incrementing id — stable within a test for recreation, fresh across installs). AppRuntimeTest gains the distinct-navigator-identity pin. Device evidence (sdk_gphone64_arm64, API 34, arm64-v8a): app:app Regression 45/45, core:data:database 30/30. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Spec §8.6: WorkManager caches the factory process-wide, so the by-lazy capture pinned generation 1's graph for every future worker. The holder is now read ONCE per createWorker and all six deps come from that single returned graph — a worker is coherently bound to exactly one generation (never torn across two), and workers created after a replacement get the current generation. In-flight workers remain the replacement transaction's drain concern. MetroWorkerFactoryTest gains the generation-swap pin (holder deps swapped between calls → second worker gets the second graph's deps); stale work-runtime 2.10.0 KDoc cite updated to 2.11.2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
The final closeout commit. Spec gains §18 (the complete forced-gate verification record: host battery 4030 tasks / 2461 tests / 0 failures with zero Paparazzi movers across all 13 golden modules; forced detekt, lint-rules, and iosSimulatorArm64 compile+KSP; connected Smoke 43/43 and Regression 77/77 per the ui_tests.yml invocations; both device gates with their executed-and-reverted known-negatives; the commit map; the recorded residual properties) and §19 (final independent review: CONFIRM, with both MUST-FIX findings applied — AppScopeLifetime.childScope puts the lifetime-parented supervisor on the winning side of the context plus, and previousGenerationId is rememberSaveable). Raw instrumentation XMLs + reproduction commands land in kmp-phase-5-evidence/. Stale-claim register (§15) executed: kmp-migration-assessment gains the dated R2 supersession note (Room 2.8.4 reopen claims, Nav2 ResetToRoot order, the never-run spike → measured RED); phase-4 spec §6 marked superseded; manifest WorkManager comment de-Hilted; performance.md pins the measured AppCreate boundary (fires AFTER the graph bootstrap); architecture.md's bootstrap section describes the runtime host + five create() roots + StartupProcessor; ci-cd/lint-rules hook text matches the measured hook; Nav2-era wording fixed across AppFeature / Feature / FeatureAssisted / MetroStoreProcessor / NavigatorEventBus / AppDialogHost / AppDialogFeature / coordinator + repository caller KDocs; tech-debt.md records the measured inert-SupervisorJob fact; backup-recovery.md points the restore step at the runtime-owned replacement transaction. Review-driven test hardening rides along: suspend-path ordering pins in StartupProcessorTest (9 tests) and old-stack absence assertions in UiGenerationSwapTest (re-run green on device). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
… ladder Addresses all six REQUEST_CHANGES findings on the replacement/transition machinery in one coherent (bisect-green) cut — the fixes interlock through AppRuntime, so they land together: - Finding 1 (self-cancelling transaction): every transition is submission-owned — callers submit and await a CompletableDeferred while the body runs on the runtime's never-cancelled hostScope. Caller cancellation abandons only the await; the Settings Store disposed at Transitioning and the undo initiator inside the outgoing lifetime both complete their transactions. Post-commit state/dialog effects ride caller-supplied beforeMutation/onCommitted hooks executed on the transaction's coroutine (coordinator, observer, use case updated). - Finding 2 (quiesce not a closed barrier): worker admission is a leased gate — MetroWorkerFactory acquires BackupWorkLease atomically with the deps under admissionLock; quiescing closes admission and awaits every outstanding lease (constructed-before-RUNNING included); timeout aborts BEFORE close; blocked acquirers park and bind to the fresh generation. The WorkInfo snapshot drain is deleted. - Finding 3 (foreign dispose released the gate): UI acknowledgement is generation-id-bound with per-id attachment counts; only the outgoing id's count reaching zero releases a transition; multi-attachment safe. The silent no-op holder defaults are removed (abstract, load-bearing). - Finding 4 (phase-conflating failure seam): DatabaseReplacement returns phase-aware DatabaseReplacementResult (Committed / RejectedBeforeMutation / FailedAfterMutation / FatalNoGeneration); callers delete recovery assets only on pre-mutation rejection; post-PONR cleanup is runtime-owned. - Finding 5 (leaking ladder): staged construction closes an owned DB when the graph factory throws; never-rename-after-failed-close; rolledBack is set before consuming the source in BOTH rollback paths; Fatal is a real published state that throws from every holder and never converts back. - Findings 6/7 (state-machine defects): atomic same-operation single-flight registration under submissionLock; graph-only transitions keep the outgoing ViewModelStore intact until publish, unwind construction and preflight throws to Serving, and reject nested rollback deterministically (GraphOnlyTransition marker) instead of deadlocking; one immutable Published value backs both phase faces. JVM suites rewritten to pin each finding (32 runtime tests + reworked coordinator/observer/interactor/worker suites). detekt green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
…est claims - AppRuntimeUiHandshakeDeviceTest: a REAL AppRuntime behind the whole app shell via MetroTestGraphHolder.runtimeDelegate — the production uiPhases stream and the production attach/dispose gate, no harness stand-ins. One graph-only reinitialize() against live composition proves the quiesce awaits the UI region's actual disposal, the successor re-keys App() at the root, the same database object crosses the handover, and post-swap recreation restores only the new generation. The submission runs off-thread while the test thread pumps frames (the compose rule owns the frame clock — a blocked test thread starves recomposition and fakes a disposal timeout). - Known-negative (executed and reverted, not committed): with TestApplication's dispose callback severed, the same test goes red with the bounded "ui region did not dispose in time" abort and the outgoing generation keeps serving — known-negative-ui-handshake-severed-dispose.xml. - RuntimeGenerationSwapDeviceTest: claims narrowed to what it actually proves (inode swap, terminal close, fresh-Room coherence over empty quiesce populations); the UI handshake and lease drain are explicitly NOT its claims. - Evidence refreshed on API 34: app:app connected 46/0 (handshake test included), core:data:database connected 30/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
…vidence - New spec section 20: findings-to-fixes map for all six REQUEST_CHANGES findings, exact per-suite claims (what each JVM/device test proves and what it does NOT), 2026-08-23 forced re-verification numbers, and the Phase 7 obligation delta (submission API only, UI-gate analogue, admission-lease equivalent, stale-slot drop). - Truth pass on stale claims: section 9's racing-worker bullet superseded by closed admission; section 18 residuals (b) and (c) marked FIXED with pointers; section 19 retitled as an in-repo review artifact (not a GitHub approval) and subordinated to section 20; commit map extended. - Evidence README: current (2026-08-23) battery numbers (2609/0 host, Regression 78/0, Smoke 43/0, full app:app 46/0), the composed real-handshake gate row with its committed known-negative XML, and the three-mutation known-negative register. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
|
REQUEST_CHANGES addressed — all six findings fixed in
Composed real-handshake device proof: Re-verification (2026-08-23, forced): host battery 3265/3265 tasks, 2609 tests / 0 failures; detekt + Kept as directed: |
Round-2 mandate 7: queued snackbar models carry the generation epoch current at enqueue; delivery discards models from an older epoch (the documented ED11 / D-OPEN-10 interruption semantics, logged) so a callback whose closure captured generation N's repositories can never execute inside generation N+1. advanceGenerationEpoch() is called by the runtime ONLY on committed handovers — an aborted transition never advances, so the queue is preserved and delivers when N resumes. The stamp-at-enqueue rule keeps the requeue path safe by ordering: requeues run during quiesce, before any advance. Pinned by three new SnackbarManagerTest cases (discard-at-delivery with callbacks never running; abort-preserves; exactly-once re-enqueue). Known-negative N3 (filter disabled) runs the discard pins red — executed and reverted, XML in kmp-phase-5-evidence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Round-2 mandates 2+4 (spec §8.5a): RestoreStateRepository gains restore_mutation_interrupted — written by the restore transaction's effects when the swap failed or ended unknown after the point of no return with every recovery asset preserved. The Scenario-1 pre-flight reads it FIRST and routes straight to the failure path without a schema peek: a peek against the untouched OLD file would succeed and produce a false "restore succeeded" dialog plus a fake undo offer. Idempotent; scoped to the restore attempt (cleared with clearRestoreInProgress); same DataStore file, wire-format key. Pinned by three RestoreStateRepositoryImplTest cases (round-trip, clear-scoping, idempotence). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Round-2 REQUEST_CHANGES: ownership and terminal semantics consolidated into ONE transaction protocol (spec §8.4 rewritten; record in §21) — the round-1 callback lambdas are deleted, not extended. 1. Source ownership transfers at submission: the restore source is STAGED into a runtime-owned copy inside the non-suspending submission frame (no cancellation point between registration and transfer); the runtime deletes the staged copy on every terminal outcome. A cancelled caller's finally-delete can never strand the transaction. No NonCancellable caller awaits. 2. Typed transaction effects (DatabaseReplacementEffects) replace the unrelated lambdas: onBeforeMutation inside the mutex + exactly one terminal method per transaction (rejected / committed / recovered / failed-after-mutation / fatal), executed on the transaction coroutine for every outcome including internal escapes. A failing onCommitted surfaces as Committed(effectsError) — never a silently clean commit. One durable journal entry (§8.5a) backs the RestartProcess failure path. 3. Result truth: Committed = the REQUESTED operation committed. New RecoveredByRollback for a restore recovered onto pre-operation data (restore-FAILURE semantics; the inline-S1-rollback path no longer reports a false Completed). A requested rollback that commits stays Committed. 4. Asset preservation: the coordinator's delete-on-FailedAfterMutation branch and its test are REMOVED; every non-commit S1 outcome preserves every file and marker; a two-coordinator process-restart test proves the recovery path survives and completes on the next launch; the journal keeps the restart from lying RestoreSuccess. 5. PONR = the START of every irreversible action (teardown / close invocation / rename). Outgoing close-throw is Fatal (RestartProcess: FailedAfterMutation) — never RejectedBeforeMutation, never a republish, never a rename; candidate/orphan/inline close throws stop the ladder Fatal; candidate jobs are cancelled-and-joined before their DB closes. 6. Complete teardown: strict replacement clears the outgoing ViewModelStore and joins the lifetime BEFORE close (unjoinable job → Fatal without closing); graph-only publishes N+1 only after N's teardown reaches the committed boundary; post-PONR failures never resurrect a partially disposed N. 7. Admission: the worker lease moves to the FIRST operation inside doWork (suspending; the factory captures nothing; constructed-but- never-started workers hold no lease). UI attachment admission closes ATOMICALLY with the zero observation (retire CAS — a late attach is refused; aborts un-retire; commits retire forever). The runtime advances the snackbar epoch on commit only. 8. Fatal is terminal under concurrency: liveness rechecked inside the transition mutex (a queued B after A's Fatal does nothing); replace/reinitialize after Fatal return Fatal; publishTransitioning cannot overwrite Fatal; every submitted deferred completes exactly once, internal CancellationException included. Pinned by 32 replacement + 14 graph-only JVM tests, the 5-test composed seam gate (REAL RestoreLatestBackupUseCase + REAL AppRuntime + actual temp files), reworked coordinator/interactor/worker suites (147 caller tests), and known-negatives N1 (result truth) and N2 (close-throw Fatal) executed red and reverted with XML evidence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
…ound Four independent adversarial lenses (protocol interleaving, mandate compliance, test vacuity, caller semantics) ran against the landed round-2 protocol; every confirmed finding is fixed: - Terminal effects now execute UNDER the transition mutex on every path (submission, escape, staging-failure, inline) — a successor transaction's beforeMutation could previously interleave with the predecessor's pending compensation and lose its crash-safety marker or the shared preserved snapshot. New pin: T2 provably blocked behind T1's suspended terminal compensation. - Phase-aware Fatal dispatch: a transaction that resolved Fatal without crossing its own PONR (queued behind another's Fatal) performed nothing — no compensation effect runs, so a caller can no longer journal a mutation that never happened (which would later force a rollback of a committed restore) nor delete the fatal transaction's recovery assets. - No silent boot loop: an UNCOMMITTED failure-path rollback returns the new PreflightOutcome.RecoveryRetryPending — the launch continues (RestartRequired would loop restart->retry->fail->restart forever with zero feedback), a truthful RestoreFailure dialog is published without touching any recovery asset, and the next launch retries. - Staging debris: a mid-copy staging failure deletes its partial file. - Vacuity hardening: packaged-effects discriminators (capture without invoking; nothing may run post-await), order-sensitive unconfined lease acquirer, submission-frame staging pin under a standard host dispatcher, the UiAdmissionGate retire-CAS hammer, the missing candidate-jobs-joined-before-close pin, and a recorded three-way undo ordering (clear -> publish -> acknowledge). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
- §8.4 rewritten IN PLACE as the governing round-2 state machine: submission + staged source ownership, typed effects, PONR at the START of teardown/close-invocation/rename, atomic UI-retire quiesce, first-op lease admission, generation-tagged snackbar queue, the teardown-before-publish boundary for graph-only transitions, result truth (Committed vs RecoveredByRollback), and Fatal-under-mutex. - §8.5 seam signatures updated to the typed-effects/result taxonomy; new §8.5a documents the durable restore-mutation journal; §8.6 rewritten for the capture-free factory + doWork-first admission. - Stale-claim sweep per the recon inventory: §3/§4 live-capture rows, §9 concurrency bullets (the round-1 factory-lease supersession note itself superseded), §10 invariant map, the §11.3 matrix (rewritten with the round-2 locked outcomes incl. new rows), §12 test plan, §13 commit ledger, §17 binding-condition supersession notes, §18 delta note and residual register, and a supersession banner on §20. - New §21: the round-2 record — corrections -> mechanisms, exact per-suite proof claims, the adversarial verification round (terminal effects under the mutex, phase-aware Fatal dispatch, the RecoveryRetryPending no-boot-loop outcome, vacuity hardening), the one recorded residual (RestartProcess close-throw journal window, documented deliberately), and the current Phase 7 obligations. - Evidence README: round-2 known-negative register (N1 result truth, N2 close-throw-Fatal, N3 epoch filter — executed red and reverted, XMLs committed). architecture.md and backup-recovery.md updated off the retired pre-runtime seam descriptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Post-hardening numbers: forced host battery 3265/3265 tasks, 2648/0 tests; device Regression 78/0 (46 app:app incl. the composed handshake gate + 30 core:data:database + 2 others) and Smoke 43/0 on the API-34 emulator, re-run on the hardened protocol; affected-KMP iOS compile (:core:data:backup:api) forced green; raw Regression XMLs refreshed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
|
Round-2 REQUEST_CHANGES addressed — the callback lambdas are deleted and ownership/terminal semantics are consolidated into one transaction protocol (spec §8.4 rewritten in place; §21 = the round-2 record with per-suite proof claims in §21.2).
Adversarial verification round (§21.2a): a 4-lens pass (protocol interleaving / mandate compliance / test vacuity / caller semantics) ran pre-submission; every confirmed finding is fixed in Verification (forced): host battery 3265/3265, 2648/0; device Regression 78/0 + Smoke 43/0 (API-34, re-run on the hardened protocol); detekt/lint/lint-rules green, zero suppressions added; Spec §8/§9/§11.3/§17/§18/§20 truth-passed against the new protocol (the round-1 record carries a supersession banner). PR remains a draft. |
…pt journal Round-3 blocker 1 + 3 + 6. The two independent booleans had a gap that produced a FALSE success: `restore_mutation_interrupted` was written only by the TERMINAL effects, so a process death after the close began but before them left the OLD, still-valid database on disk with `restore_in_progress` set and no interruption recorded — and the cold-start schema peek, run against that healthy old file, published RestoreSuccess for a restore that never happened. - RestoreAttempt(id, kind, phase, context, rollbackSnapshotPath) with Prepared -> Committed -> resolved, persisted in ONE atomic edit before the point of no return. Only the owning id may advance or clear it; a different unresolved attempt cannot claim the slot (this is what refuses a second restore before anything irreversible); legacy and unparsable states read as Prepared. - A Prepared/unknown attempt takes the recovery path WITHOUT a schema peek — the peek is a genuine verification only once the mutation is durably known to have committed. - Durable commit ordering: rename -> promote reservation -> record Committed -> only then consume a rollback asset. A failed record keeps every asset and leaves the journal Prepared, so the next launch conservatively rolls back rather than claiming an unprovable success; the failure is surfaced on the outcome, never swallowed. - Rollback-slot preparation moves INTO the serialized transaction: the runtime reserves a per-attempt snapshot after validation and before PONR, promotes it on commit, and discards only its own on rejection — so two concurrent restores can no longer overwrite each other's undo slot and a rejected attempt no longer destroys the previous one. preserveCurrentDb (single canonical path, caller-owned) is gone. - EVERY terminal compensation failure now folds onto the result's effectsError; a committed-but-unrecorded restore reports FAILURE. - Candidate teardown is unified: VM clear -> cancel AND bounded JOIN -> close only after the join; any failure stops the ladder with no later rename. buildGeneration's orphan path joins before closing instead of cancel-then-close. 316 tests green across the four affected modules (18 new journal tests, 21 coordinator, plus the runtime/settings suites); detekt clean with no new suppressions (the provider surface shrank instead). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Round-3 blockers 4 and 5. UI admission (blocker 4): - UiAdmissionGate hands out TOKENS instead of counting attachments. Release is identified by the token, so a grant released after its generation was retired and its id later reopened cancels out nothing — the ABA that let a stale region's release open the gate under a LIVE one is gone. Release is idempotent; a retired generation refuses admission outright. - Admission is taken DURING COMPOSITION via a RememberObserver, and the generation region composes its content only when granted. Effects run at apply time — after children have already resolved their Stores and ViewModels — so the old DisposableEffect could not stop a retired generation from touching its graph. onForgotten/onAbandoned cover both ways a composition can end, including the abandoned one a retirement between composition and apply produces. - Store jobs are now joinable by the generation: AppCoroutineScopeImpl takes the generation job and puts its SupervisorJob on the WINNING side of the context plus (fixing the recorded tech-debt where the written supervisor was silently discarded and every Store job parented to the composition LifecycleOwner). BaseStore.init threads it through the one construction chokepoint, dispose() is idempotent, and onCleared() now actually ends the Store's work — so a runtime teardown cancels AND JOINS Store finally blocks before the generation's database closes. Snackbar handover (blocker 5): - The epoch advances BEFORE the successor is published, closing the window where N+1's collector was live while N's queued models still passed the delivery filter. - The epoch travels with the model: delivery hands out DeliveredSnackbar and a requeue re-enqueues with the ORIGINAL epoch, so a model the host died holding can never be re-stamped as N+1 and executed in the successor. - Resolve accounting is ONE linearizable value, and the drain fences admission atomically with its zero observation — no new routing can start behind the fence. Aborts unfence and preserve; commits advance and discard. Pinned by a token/ABA/atomicity suite incl. a 3000-iteration retire-CAS hammer, a real BaseStore.launchDefault probe whose finally touches the database, a composed retirement-between-composition-and-frame test, and five new snackbar tests. The agents' mutation runs killed each new pin (plus-order reversal, non-idempotent dispose, re-stamping requeue, always- admit beginResolve, non-awaiting fence, collapsed resolve counter). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
- §8.4 rewritten in place: token-based UI admission taken during COMPOSITION (a refused region composes and resolves nothing), the snackbar fence that closes admission atomically with its zero observation plus the epoch-before-publish ordering, teardown that joins generation-parented Store jobs before the close, and the single candidate-teardown path. - §8.5a rewritten as the attempt journal (phase table, ownership rules, reservation lifecycle, durable commit ordering); new §8.5b for the safe retry vs terminal DB-free recovery split. - New §22: blockers -> fixes -> the exact test pinning each WITH its proof boundary, the durable-journal crash table (seven crash points and what the next launch concludes at each), the known-negative register, and the residual/Phase 7 delta. - Evidence README gains the round-3 known-negative table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
A fresh-context review over crash ordering, concurrent asset ownership, admission and test vacuity confirmed seven defects with concrete traces. All are fixed here; the tests that pinned the old behavior are corrected into pins for the new invariants. - An interrupted-but-COMMITTED rollback was re-driven on the next launch: it looked for the preserved file the rollback itself had consumed, failed, and left the attempt unresolved FOREVER — which then refused every future restore and undo, because their beginAttempt saw a foreign owner. Such an attempt now finishes its bookkeeping and the launch continues (new PreflightOutcome.RecoveryCompleted). CRITICAL, production. - The scenario-1 recovery rollback re-claimed the journal with a null rollbackSnapshotPath, ERASING the pointer to the only file holding the true pre-attempt database; a second interruption then fell back to the canonical slot — an older snapshot — silently reverting untouched data. The path is now carried through the re-claim. - The recovery ladder recorded the CALLER's attempt as Committed for a rollback, so the very next preflight read a rolled-back database as a successful restore and published RestoreSuccess. The ladder no longer runs the caller's commit bookkeeping at all. - The ladder rolled back onto the canonical slot instead of the attempt's own reservation, overwriting an intact live database with older data and then deleting the reservation. It now applies the reservation when it has one and consumes only the file it applied. - promoteRollbackReservation deleted the canonical slot BEFORE the move, so a failed promotion destroyed the undo slot while the journal still named a reservation the runtime then deleted. It now stages first and never pre-deletes; a failed promotion keeps the reservation. - UiAdmissionGate.admit carried its verdict in a captured flag mutated inside a MutableStateFlow.update lambda — on a losing CAS retry the flag kept the LOSING iteration's value, issuing a token for a generation that awaitRetired had already reported clear. The verdict is now read off the committed state. - A refused generation region was cached against the id alone, so an aborted transition's same-id re-publish never re-asked for admission and left the app blank. The grant is now keyed on the published phase. AppRuntime crossed the LargeClass ceiling as a result, so the cohesive quiescence concern (the three barriers, the outgoing teardown and the one candidate-teardown path) moved to GenerationQuiescer — no suppression. 1717 host tests green, detekt clean, app:app device suite 49/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
The adversarial review's vacuity lens showed three of the new guards were not actually pinned. Each is closed here, and the fixes are proven by the mutations that now kill them. - The generation-job seam had NO test: `generationJob` defaulted to null, so deleting the second argument at the one production call site still compiled and silently un-parented every Store job from its generation (teardown would then return while a DB-touching `finally` was pending). The parameter is now REQUIRED — dropping it is a compile error, which is stronger than any test could be — and null is spelled out where no generation exists. - The promote-before-record ordering, described as the one order that keeps every crash window truthful, was unpinned: swapping the two steps kept the suite green. An interleaving test now records the provider's promote and the effects' commit into one log and asserts swap < promote < record. - The retire-CAS hammer could not observe the gap it named: its racer released its own grant before the count was read, so a two-step observe-then-retire gate passed 3000/3000. The racer now KEEPS its token and the assertion states the real invariant — a granted token and a "clear" verdict are mutually exclusive. Known-negative R3-C (two-step retire) now fails it at iteration 29; evidence committed. Also drops the EmptySuperCall that Android Lint flagged on the new BaseStore.onCleared override. 1718 host tests green, lintDebug green, detekt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Refreshes the evidence README with the round-3 numbers and adds the two
things the final verification pass produced that the spec did not yet carry.
- Forced host battery: 8m29s, 3265/3265 executed, 2688 host tests / 0
failures (2174 testDebugUnitTest + 387 testAndroidHostTest + 127 test,
counted from the raw JUnit XMLs across all three host scopes). detekt and
:lint-rules:test forced, 127 custom-rule tests, zero new suppressions.
- Device: Regression 81/0 (was 78 — the whole delta is UiAdmissionRaceTest),
Smoke 44/0, Room same-instance characterization 2/2 unchanged.
- Known-negative R3-C row (the two-step retire, which the rewritten hammer
now kills at iteration 29) and R3-D (below), with the note that three
round-3 pins were non-discriminating until the adversarial review reached
them.
R3-D is the one the device gate found rather than the review. Making
`generationJob` REQUIRED stops the argument being dropped, but not the value
being wrong: `remember { null }` still compiles and still un-parents every
Store job. §22.3b records the seam pin that closes it, and the reason the fix
supplies the `appDeps` contract instead of softening it — a `?: null` fallback
in the processor would have turned the red gate green while re-opening exactly
the defect blocker 4 exists to prevent.
Docs and evidence only; no production or test code changes.
|
Round-3 REQUEST_CHANGES addressed — five bisect-green commits on the same draft PR. The durable record is now attempt-scoped and every gate is a real barrier. Governing text: §8.4 / §8.5a / §8.5b rewritten in place; §22 is the round-3 record with each test's exact proof boundary and a crash-point table.
The adversarial review earned its keep. A fresh-context pass over crash ordering, concurrent asset ownership, admission and test vacuity confirmed eleven defects — two of which would have shipped as a bricked feature or silent data loss:
It also showed three of my own new pins were not discriminating: the generation-job seam had no test (a one-token revert restored the defect with the suite green — the parameter is now required, making that a compile error), the promote-before-record ordering was unpinned, and the retire-CAS hammer could not observe its own gap (its racer tidied up before the assertion; it now keeps the token, and KN R3-C fails it at iteration 29). One more hole was found by a gate, not by the review. Making The tempting fix was a Known-negatives executed red and reverted, XMLs committed: R3-A (ignore the attempt phase → false Gates, all forced ( Everything else is unchanged: Android production stays PR remains a draft. |
Round-4 blockers A, B, C and E — one serialized ownership story from the reservation to the resolved journal, correct at every process-death point. A. Reservation promotion and consumption are crash-safe. - promoteRollbackReservation COPIES instead of moving: the reservation — the file the still-`Prepared` journal names — survives every crash point of the promotion, closing the window in which a death between the old move and the durable `Committed` record left the journal pointing at a missing path and misdirected recovery onto the canonical slot's OLDER snapshot (or, with no canonical, wedged the launch on a file of unknown provenance). Stale `.promoting` debris is deleted deterministically and is never read by recovery. - The runtime deletes the reservation only AFTER `Committed` is durable (commitMutation step 4); the committed cold-start finalization cleans a retained copy up idempotently. - A journal-named rollback source is AUTHORITATIVE: when it is missing, the transaction rejects with a typed error — the canonical slot belonging to another attempt is never substituted (executeReplacement, and the ladder's recoverViaRollback, which now also refuses to substitute for a failed explicit-source rollback). After a CLEAN commit the canonical slot provably holds this attempt's own promoted pre-image (promote < record < consume), so post-commit ladder recovery legally uses it — same proof as the Committed cold-start rule. - MutationPlan carries a typed SourceConsumption (None / CanonicalSlot / ExactFile) instead of the consumeSource boolean: a committed rollback consumes EXACTLY the file it applied. Pre-R4, a reservation-sourced recovery rollback consumed the CANONICAL slot — destroying the previous restore's still-valid undo asset while orphaning the file actually used. - keepReservation now covers every outcome whose terminal compensation failed (RejectedBeforeMutation with effectsError included): whenever the journal may still be unresolved and naming the reservation, the file survives. B. Cold-start classification: RetrySafe is REMOVED. A pre-PONR rejection of the recovery rollback proves only that THIS rollback did not mutate — never what the original `Prepared` attempt did to the live file — so every rollback outcome short of a clean durable commit is now RecoveryRequired: no Main UI, no planner, no observer, no cleanup chore; assets and journal preserved. The one in-process consequence is deliberate and pinned: a graph-only reinitialize whose preflight finds an unresolved `Prepared` attempt aborts (outgoing keeps serving) instead of publishing a fresh generation over unproven data — locked invariant 4. C. Legacy owner isolation. beginAttempt converts the id-less pre-R3 `restore_in_progress` marker into an owner-scoped record atomically, and only the synthetic legacy owner id may claim it; resolveAttempt requires that same id — an arbitrary attempt id can no longer erase the legacy journal (pre-R4, any refused attempt's rejection compensation did exactly that, forgetting an interrupted restore entirely). ScenarioOneRollbackEffects now CHECKS its re-claim result instead of mutating the live database unjournaled, which is what made the legitimate legacy recovery complete Prepared → rollback → Committed → resolved instead of failing its own bookkeeping. E. Finalization is replay-safe by ordering: every data-bearing write lands BEFORE the attempt resolves. handleRestoreSuccess marks undo availability, publishes, cleans the retained reservation, and resolves LAST — a death anywhere replays the whole idempotent sequence next launch (pre-R4 the resolve came first, and a death after it hid a valid undo snapshot forever). The committed-rollback replay branch takes its availability verdict from GROUND TRUTH (canonical file existence), because the journal cannot say which file the rollback applied; a reservation-sourced recovery no longer revokes the previous restore's valid undo. The undo and scenario-1 effects follow the same resolve-last discipline. For a Committed attempt whose peek fails, the recovery rollback sources from the canonical slot (its promotion is durably proven) rather than the possibly-cleaned-up reservation path. The replay branch deliberately does not re-publish the interrupted rollback's dialog: Kind.Rollback does not record recovery-vs-undo intent, and that at-most-once feedback is the documented residual. New pins: missing-explicit-source typed rejection with the canonical untouched (sentinel bytes); exact-file consumption with the canonical surviving; reservation kept on rejected-with-failed-compensation; ladder Fatal when the reservation vanished (never the older canonical); reservation-survives-until-resolve; copy-based promotion against REAL files (DatabaseSnapshotProviderImplTest); atomic legacy conversion + wrong-owner refusal against real DataStore; every-non-commit-is-RecoveryRequired (RejectedBeforeMutation included); finalization ordering, finalization-crash, and retained-reservation cleanup; committed-rollback ground-truth flag both ways; legacy recovery end-to-end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Round-4 blocker D: after PONR, a teardown or join that fails ENDS the transition terminally — it never publishes N+1 over surviving work from N and never republishes N beside a leaked candidate. - Outgoing teardown failure post-PONR (ViewModelStore.clear threw, or the lifetime's cancelAndJoin timed out on an unjoinable DB-bound job) was "publishing the candidate anyway": N's work survived the publication of N+1, and the epoch advance discarded N's queued snackbar models while their producers were still live. Now: best-effort the candidate's own release, aggregate the failure, publishFatal — no epoch advance, no publication, the UI gate stays retired (the worker-gate reopen inside publishFatal exists solely to wake parked acquirers into the Fatal check; no lease is ever granted). - The candidate-preflight-failure path DISCARDED tearDownCandidate's verdict and aborted back to Serving N: an unjoinable candidate job — which shares the LIVE database — survived beside the republished generation, and a later replacement (whose teardown joins only the OUTGOING lifetime) would close that database under it. The verdict is checked; false is terminal. - releasePartialGeneration computed the join verdict and IGNORED it for shared-database candidates (ownsDatabase = false), so a partial graph whose constructor had already handed the lifetime to a consumer with an unjoinable job was reported as an ordinary "construction failed" abort. It now returns the distinct PartialCandidateUnwindException, which the graph-only caller maps to Fatal — the failed-unwind signal is never an ordinary construction abort. The rebuild machine already checked all three (tearDown → publishFatal, the ladder's tearDownCandidate verdict, OrphanCloseException → LadderFatal); the gaps were graph-only-scoped, and the fix is too. The source-owner selection policy (R4 invariant 2) moves from AppRuntime into ReplacementMechanics as pure functions (selectRollbackOperationSource / selectRecoverySource) — AppRuntime had crossed the LargeClass ceiling and the policy is cohesive, testable logic; no suppression added. Pins (mandated tests 7-10): VM-clear throw → Fatal, no N+1, epoch unchanged, admission refused; unjoinable outgoing lifetime → same, with the lease acquirer failing loud; preflight failure + unjoinable candidate → Fatal, never a republished Serving N; partial construction + unjoinable child → terminal, not an ordinary abort. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Four hunts (crash ordering, source-owner identity, teardown return values, outcome truth / test vacuity) over the round-4 tree. Production RestartProcess checked clean on every prong; every confirmed finding is RebuildInProcess-scoped (instrumentation + the Phase-7 host) and fixed here. Each pin below ran RED against the pre-fix production (XMLs committed as r4-review-red-prefix-*.xml) and green after. Spec §23.5 is the record. - CRITICAL: the INLINE scenario-1 rollback dropped its submitted sourcePath and hard-coded the canonical slot — a journal-named reservation was ignored while ANOTHER attempt's older snapshot was applied AND consumed (invariants 2+3), with the flag policy then mis-deciding. The inline branch now resolves through the same selectRollbackOperationSource policy as the top level: explicit path honored, missing path a typed rejection, exact-file consumption. - HIGH: the inline rollback never ran onBeforeMutation, so the journal stayed Committed/Kind.Restore and a death inside the inline mutation replayed as a FALSE RestoreSuccess with a phantom undo. It now runs the caller's onBeforeMutation before anything irreversible — the scenario-1 re-claim converts the slot to Prepared/Rollback, so every death replays truthful bookkeeping. - HIGH: the post-clean-commit ladder recovery rolled back while the journal still read Committed — the NEXT preflight (no crash needed) or the next launch peeked the rolled-back file and published RestoreSuccess. recoverViaRollback(afterCleanCommit) now durably UN-commits first (the caller's onBeforeMutation re-claim), applies the canonical WITHOUT consuming it, and grants ONE fresh attempt after a preflight that re-drove the recovery inline — bounded, because that attempt runs over an empty journal. - MEDIUM: RestoreTransactionEffects.onRecoveredByRollback unconditionally cleared undo availability even when the recovery applied the reservation and the previous restore's canonical undo remained valid — the cross-owner invalidation invariant 3 bans. Ground-truth verdict now (canonical file existence), and the stale seam KDoc is corrected. - LOW: a generation-1 build whose orphan close threw resolved to a RETRYABLE rejection, letting the retry open a new handle beside the unknown-state one and later rename over it. OrphanCloseException and PartialCandidateUnwindException are terminal in both escape resolvers, pre-PONR included. - LOW: the post-PONR teardown paths could hang unboundedly inside the transition mutex — the ViewModelStore clear had no timeout. The clear now dispatches detached to the main dispatcher and is awaited within the drain budget; a timeout reads as a failed clear and the machine reaches its terminal verdict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Rewrites the governing sections IN PLACE and records round 4: - §8.4: an incomplete graph-only teardown is TERMINAL, never publish-anyway; the failed-unwind signal is distinct from an ordinary construction abort. - §8.5a: copy-based promotion (the journal-named reservation survives every crash point; deleted only after the durable record, with idempotent committed cold-start cleanup); journal-named sources authoritative for a Prepared attempt with the Committed-implies-canonical ordering proof; exact typed SourceConsumption; keepReservation on every unresolved-journal outcome; the ladder's owner rules. - §8.5b: RetrySafe removed — every non-commit recovery-rollback outcome is terminal recovery; the replay-safe finalization ordering; the ground-truth availability verdict; the at-most-once rollback-dialog residual with its exact boundary. - §23: the round-4 record — corrections → mechanisms → the red-on-base pin for each (23.1), the per-death-point crash-state matrix for one restore attempt (23.2), the three executed-and-reverted known-negatives (23.3), the deliberate residuals with exact boundaries (23.4), and the fresh-context adversarial review's findings → fixes (23.5). Evidence: r4-red-on-base-*.xml (all 11 mandated tests + supporting pins proven RED at 936ab69 via a worktree run), known-negative-r4{a,b,c}.xml (executed red and reverted), r4-review-red-prefix-*.xml (the six §23.5 pins red against the pre-fix production). README carries the round-4 finals: forced battery 15m31s, 3265/3265, 2712 host tests / 0 failures, zero Paparazzi movers; detekt + :lint-rules:test forced, zero new suppressions; device Regression 81/0, Smoke 44/0, Room same-instance characterization 2/2 unchanged; affected KMP iOS compile green with link/runtime honestly unverified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
|
Round-4 maintainer correction addressed — four bisect-green commits on the same draft PR. §8.4/§8.5a/§8.5b rewritten in place; §23 is the record with the per-death-point crash-state matrix (§23.2) and the adversarial-review record (§23.5). Every mandated test was proven RED at
The mandated fresh-context adversarial review earned its keep (4 hunts: crash ordering, source-owner identity, teardown return values, outcome truth/vacuity). Production
Known-negatives (executed red, reverted, XMLs committed): R4-A explicit-path→canonical fallback re-enabled; R4-B "publish the candidate anyway" restored; R4-C the invalid safe-retry classification restored. Gates (all forced): host battery exit 0 — 2712 host tests / 0 failures, 3265/3265 executed, zero Paparazzi movers; No Phase 7 work; no swappable DAO/repository indirection; deliberate residuals with exact boundaries in §23.4. PR remains a draft. |
R4.1 — the three proven defects plus the missing liveness proof. Every new pin ran RED at 7c82c36 (r41-red-on-base-*.xml, worktree run) or against the executed-and-reverted unbounded-clear mutant, and is green here. Blocker 1 — a committed canonical rollback was repeatable after a crash. The finalization is now SOURCE-AWARE: `rollbackSnapshotPath` is the durable discriminator (null = the canonical was applied; non-null = the exact named source), never file existence alone. A canonical-sourced committed rollback finishes the canonical's consumption idempotently and clears availability — a death between the commit record and the consume can no longer leave the same undo offered again, where replaying it after later writes would erase them. An explicit-source one consumes exactly its named file, preserves a surviving canonical (the previous restore's undo) with its availability, and clears only when the canonical is actually absent. The attempt resolves LAST and the branch replays idempotently on any mid-way failure. Blocker 2 — a requested rollback's successful retry was committed as anonymous compensation: the canonical was consumed while the REAL journal stayed Prepared/Rollback, and the very next preflight — finding an unresolvable Prepared attempt with no source left — went Fatal. The retry now commits through the ORIGINAL effects (`Committed` recorded before the exact-source consumption; a failed record keeps the source and the unresolved journal, surfaced on the outcome — never a clean Completed), runs the same bounded candidate ladder, and returns Completed. The anonymous commit remains ONLY for the compensating rollback of a failed Restore, where recording the Restore as committed would be false. Blocker 3 — the inline rollback closed the candidate database before its ViewModelStore was cleared or its lifetime joined: a candidate job's DB-touching finally could run against the closed handle. The inline branch now routes invalidation through THE candidate teardown protocol (a suspend disposal callback backed by GenerationQuiescer.tearDownCandidate: VM clear → cancel + bounded join → close), only then swaps; `candidateDisposed` keeps attemptGeneration from tearing the candidate down twice. A failed clear/join/close stops the ladder Fatal with zero renames after, nothing published, journal and assets preserved. Liveness — the wedged-main pin: a dispatcher that ACCEPTS but never RUNS the clear now proves the machine reaches Fatal within the drain budget (deferred completes, no successor, epoch unchanged, admission retired, the worker acquirer wakes and fails loudly). The old unbounded implementation was executed as a mutant and hangs this test (known-negative-r41-unbounded- clear.xml), then reverted. AppRuntime re-crossed the LargeClass ceiling from these fixes; the cohesive non-publishing pieces moved out (InFlightReplacement, GraphOnlyTransition and releasePartialGeneration to ReplacementMechanics, DerivedStateFlow to RuntimeGeneration.kt) — no suppression added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Spec §24 records R4.1: the three proven protocol defects → fixes → the exact red pins (each proven red at 7c82c36 via the worktree run, or against the executed-and-reverted unbounded-clear mutant), plus the truth pass: - §8.5b: the committed-rollback finalization is SOURCE-AWARE — `rollbackSnapshotPath` is the durable discriminator; the "cannot say which file the rollback applied" claim is removed. - §23.2: the `Committed canonical rollback: record → consume` death row and the committed-rollback sub-matrix; the matrix is explicitly a COMPOSED proof (no literal two-launch process-death integration test exists). - §23.4: the null-source residual reworded to cover any `Prepared` attempt (legacy marker, interrupted canonical-sourced rollback, torn write). - Evidence README: the round-4 red-on-base evidence stated exactly (21 failures total: 20 named tests + 1 parameterized invocation); the R4.1 red-on-base and mutant evidence; the R4.1 finals — forced battery 32m50s, 3265/3265, 2720 host tests / 0 failures, zero Paparazzi movers; forced detekt + :lint-rules:test, zero new suppressions; device Regression 81/0, Smoke 44/0, Room characterization 2/2 unchanged; iOS compile green with link/runtime honestly unverified. Evidence files: r41-red-on-base-runtime.xml, r41-red-on-base-coordinator.xml (six pins red at 7c82c36), known-negative-r41-unbounded-clear.xml (the old unbounded implementation hangs the liveness pin to its timeout). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
|
R4.1 final correction addressed — two commits (
The missing liveness proof: the previous immediate-throw test could not distinguish Truth pass (also folded into the spec/README/PR body): the round-4 red-on-base evidence is 21 failures total (20 named + 1 parameterized invocation); the crash-state matrix is explicitly a composed proof (no literal two-launch process-death integration test exists) and now carries the
Gates (all forced): host battery exit 0 — 2720 host tests / 0 failures, 3265/3265 executed, zero Paparazzi movers; PR remains a draft. |
R4.2 blocker A. The protocol conflated "the file mutation happened" with "the mutation became durably provable": commitMutation returned Completed(effectsError) when the promotion or onMutationCommitted failed, and runTerminalEffects then dispatched onCommitted for every Completed — letting the production committed effects resolve the still-Prepared attempt, clear undo availability, publish success and acknowledge the initiating action, erasing exactly the state conservative recovery needs (§8.5a). The protocol phase is now EXPLICIT: commitMutation returns CommitResult.Durable | NotDurable. A NotDurable failure — the promotion or the durable record — is never a committed terminal: - RestartProcess: FailedAfterMutation. Journal Prepared, reservation retained, onFailedAfterMutation dispatched; the next launch recovers. - RebuildInProcess, primary commit: the transaction diverts straight into the bounded recovery ladder — a restore rolls back onto its KEPT reservation (deterministic RecoveredByRollback; no candidate ever serves the unprovable file), a requested rollback retries its source and its durable record once. - RebuildInProcess, recovery commit: a persistent record failure ends Fatal with the journal and every asset preserved. - Inline scenario-1: FailedAfterMutation — the recovery effects may publish feedback but can neither resolve the Prepared attempt nor clear availability. An effectsError on Completed now has exactly ONE origin: a failure OF the terminal onCommitted callback after a DURABLE commit — a different phase, still folded onto the result, never confused with a failure that prevented the commit from becoming provable. completedOrRecovered loses its commitEffectsError channel accordingly, and the settings caller's Committed(effectsError) branch is re-documented (a pre-durable failure can no longer reach it). Production-shaped pins (each RED at 8379353 — r42-red-on-base-runtime.xml — or against an executed-and-reverted mutant): the persistent-record retry whose REAL committed terminal would resolve/clear/publish/ack (never invoked; Prepared + source + availability survive; bounded Fatal); the one-shot record failure on a restore (no committed terminal before a LATER recovery obtains a durable record; the preflight observes Prepared; exactly-once counts) and on a requested rollback (exactly one committed terminal — pre-fix the stale Completed dispatched a SECOND, acknowledging a record that never landed); the promotion failure under both policies; the inline record failure that cannot erase the Prepared journal; and the focused pin that the durable phase — not the file mutation — selects the terminal. KNOWN-NEGATIVE R4.2-KN (the old dispatch restored on the retry path) kills the production-shaped pin (known-negative-r42-predurable-dispatch.xml), reverted clean. Also in this commit: - R4.2 blocker B: the @Suppress("UNCHECKED_CAST") added by 39e9155 is REMOVED via a typed ProbeViewModelFactory (Class.cast) — the pre-existing sibling factory converts too; `git diff -U0 936ab69..HEAD -- '*.kt'` adds zero suppressions. - Liveness truth: the wedged-main pin now uses a REAL queueing dispatcher and, after the Fatal verdict, EXECUTES the abandoned clear to prove the documented residual (the late clear changes nothing). The unbounded-clear mutant was re-executed against this exact test — it hangs it (known-negative-r42-unbounded-clear-queued.xml) — and reverted. - AppRuntime re-crossed the LargeClass ceiling; completedOrRecovered moved to ReplacementMechanics — no suppression added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
Spec §25 records R4.2; §8.4's terminal-selection paragraph and §8.5a's durable-commit-ordering paragraph are rewritten in place: - §25.1 blocker A: CommitResult.Durable|NotDurable, the four call-site mappings, and the eight production-shaped pins — ALL red at 8379353 (r42-red-on-base-runtime.xml), the one-shot RESTORE proof after the adversarial review's vacuity fix (its two added axes: the recovery swap precedes the first candidate preflight; the recovered outcome carries no stale pre-durable effectsError). - §25.2 blocker B: the 39e9155 suppression removed via a typed factory; zero added suppressions verified against the FULL range 936ab69..HEAD. - §25.3 liveness truth: the queueing dispatcher is literal (runnables retained, executed after the Fatal verdict — the residual proven); the unbounded mutant fails the pin at the deferred-completion assertion (known-negative-r42-unbounded-clear-queued.xml). - §25.4: the review's outcome (no invariant counterexample; one vacuity finding, fixed) and the named canonical-integrity residual (mid-copy promotion failure + unvalidated rollback sources — outside the locked invariant, surfaces as a failed swap or recovery routing, never a false success). Evidence README carries the R4.2 finals: forced battery 9m37s, 3265/3265, 2725 host tests / 0 failures, zero Paparazzi movers; forced detekt + :lint-rules:test exit 0; device Regression 81/0, Smoke 44/0, Room characterization 2/2 unchanged; affected KMP iOS compile exit 0 with link/runtime honestly unverified. Known-negative and red-on-base XMLs committed beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz
|
R4.2 addressed — two commits ( Blocker A — pre-durable commit failures were dispatched as committed terminals.
The only remaining origin of Production-shaped proofs — all eight pins red at Blocker B — the zero-suppression claim is true again. The one Liveness truth. The wedged-main pin now uses a REAL queueing dispatcher (runnables retained), and after the Fatal verdict it EXECUTES the abandoned clear — the documented residual proven literally: the late clear changes nothing. The unbounded-clear mutant was re-executed against this exact test — it fails it at the deferred-completion assertion ( Gates (all forced): host battery exit 0 — 2725 host tests / 0 failures, 3265/3265, zero Paparazzi movers; PR remains a draft. |
|
@claude review |
|
Claude finished @stslex's task in 11m 13s —— View job Claude finished the reviewTodo list
SummaryI checked the PR's own protocol claims against the code (not just the description), focusing on the durable-commit dispatch ( I did find a few real issues, none of which block the protocol's core correctness claims: 1. [Major] Crashlytics reporting silently skipped for the dominant recovery path — 2. [Minor] Asymmetric re-entrancy guard, latent deadlock trap — 3. [Minor] Over-broad visibility on transition-only snackbar controls — 4. [Minor, documented risk] 5. [Nit] 6. [Nit] Dead field — 7. [Nit] Stale doc comment — One agent flagged a "cold-start Not re-litigated: the previously-documented residuals the PR body already discloses (the null-source Given this PR's own extremely thorough multi-round adversarial process, none of the above look like blockers for the underlying protocol; #1 (Crashlytics gap) is the one I'd actually want fixed before merge since it's a real production-observability regression for the most common recovery path. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 85a3e48f07
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| val currentGeneration: RuntimeGeneration | ||
| get() { | ||
| check(!isFatal) { "runtime is Fatal — no generation is serving; recovery required" } | ||
| return currentOrNull ?: synchronized(buildLock) { |
There was a problem hiding this comment.
Block access after a generation's database closes
Under ReplacementPolicy.RebuildInProcess, executeRebuildTransaction closes the outgoing database while leaving currentOrNull pointing to that generation and isFatal false until a successor or fatal outcome is published. During that interval, any direct holder read through BaseApplication.appGraph returns the closed generation; there is also a check/use race if publishFatal runs after this check. The UI and worker gates do not protect other graph-holder readers, so they can resolve repositories backed by a terminal Room instance. Track the post-close state atomically and make this accessor fail instead of returning the outgoing generation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Verified — the mechanism is real, but the scope inverts what the fix should be.
Under RebuildInProcess the window you describe is narrow: executeRebuildTransaction closes at AppRuntime.kt:549 and republishes or goes Fatal within the same mutex hold.
On production it is much wider, and deliberately so. BaseApplication builds the runtime with the default ReplacementPolicy.RestartProcess, and runRestartProcessSwap never calls publishTransitioning(), never quiesces, and never retires the UI — it closes the database, swaps the file, and returns Completed(generation = null). The runtime stays Serving(outgoing) over a terminally-closed Room handle, and BackupClickHandler.scheduleAppRestart() then waits RESTART_DELAY_MS = 2_000 before AppReinitializer calls Runtime.getRuntime().exit(0). So the real exposure is ~2 seconds of live UI over a dead handle, plus an open workerGate in that window, since close() is only ever called from GenerationQuiescer.quiesce.
That is not a regression from this branch. The pre-split restoreFromSnapshot closed the live database itself — its old KDoc said so, and told the caller it must restart — and §8.4 line 327 states the intent outright: RestartProcess "preserves shipped behavior exactly: no Quiescing".
So making currentGeneration fail after close would not protect the path that is actually exposed; it would only tighten a path Android never takes. The accurate response is to record the production window as a named residual in §18 alongside the saveable-slot one, rather than to harden the accessor.
Preparation for the project-wide comment trim. A read-only pass over all 718
Kotlin/Gradle files that carry non-trivial comments harvested every fact that
(a) a reader cannot recover from the code and (b) documentation/ did not
already state, proving (b) with a grep per candidate. 388 facts survived that
filter and are written here, in each document's own voice and section.
Also breaks the 29 places where a document delegated to a KDoc as its source
of truth ("see the graph's KDoc for the alternatives", "the KDoc explains why
they cannot be", "AppIconButton's KDoc rounds 12 to Radius.small and says
why"). Those pointers would dangle once the comments are trimmed, so each
document now carries the fact itself.
New: documentation/core-utils-invariants.md, for the core/core helpers whose
properties lived only in their KDoc.
No code changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2VpinQ97D1BYBRkSd7qp6
The comments had grown into essays — design rationale, measurement tables, alternatives-considered, review history — and reading the code meant reading past them. Documentation is the source of truth; a comment is a pointer. Applies the style of d7692d0 to the whole project: 717 files, comment lines 24615 -> 7827 (-68%). The largest surviving comment block in the tree is nine lines; it was sixty-eight. Repo-wide comment-to-code ratio 0.248 -> 0.089. Kept: SPDX headers, TODO/FIXME, guards where doing the obvious thing silently breaks something, and a one-line KDoc on public API. Deleted rather than compressed: claims that contradict the tree, among them PerformedExerciseDao's "no FK cascade onto set_table" (SetEntity declares exactly that cascade), AppMotion.travel's 37.9% (measured 37.1/37.5), AppNavBar's "both are `out`", and BackupWorkDrain's pointer to a method that does not exist. Every fact worth keeping was written into documentation/ first (b221d27). Two imports go with the KDoc that was their only reader — AppGraph's DispatchersBindingContainer and BackupAuth's AuthResolution, both referenced solely from `[Symbol]` links. They are the only executable change in the commit, and kmp-phase-2-probes.md's note that AppGraph's import is "KDoc-linking only" is updated to match. shell_gate.py's hair-s exception quoted AppColors.kt's now-deleted header, so its attribution is reworded; check 9 still passes and the f52462c known negative still goes red. Verified mechanically: comments stripped from both sides of all 717 files, the remaining token streams are byte-identical except those two import lines. detekt green, compileDebugKotlin + compileDebugUnitTestKotlin green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2VpinQ97D1BYBRkSd7qp6
There was a problem hiding this comment.
Automated review pass (Claude Code) over the full branch: 12 area reviewers, each finding then put to two independent refutation lenses, and every surviving item re-verified by hand against the tree.
Gates reproduce green locally: detekt 0 issues, 751 unit tests across app:app, core:ui:mvi, feature:recovery, feature:settings, core:ui:kit, 0 failures/errors/skips.
The comment trim is clean. Of 22,671 deleted lines exactly 5 survive a non-comment filter, and all 5 are benign: three are lines whose trailing comment was shortened (AppTheme.kt:41, AppRuntimeTest.kt:373, UiLayerNoDataRule.kt:54 all keep their code) and two are the KDoc-only imports the commit message already names. That matches d8a44658b's own claim, independently.
Three findings already have fixes written (staged locally, not yet pushed to this branch, so they are not visible in the diff above): the Scenario-1 recovery export, the anchors the trim invalidated, and the AppCoroutineScopeImpl entry the spec contradicted itself about.
The three inline comments below are what is left. All three are Phase-7 / instrumentation scoped, not Android production, because GenerationQuiescer, workerGate.close(), uiGate.awaitRetired and the snackbar fence have no reachable Android call site — every one sits inside runGraphOnlyTransition (iOS-only caller) or the RebuildInProcess ladder, exactly as §1 line 763 grep-pins.
One finding was raised and then refuted by both lenses, recorded so it is not re-raised: "an in-process Fatal strands AppUiPhase.Transitioning forever." It does not hold on production — publishTransitioning() is never called on the RestartProcess path at all.
| show: suspend () -> SnackbarResult?, | ||
| ) { | ||
| // Fence before routing so a quiescing transition cannot miss this callback. | ||
| if (!SnackbarManager.beginResolve()) { | ||
| SnackbarManager.requeue(delivered) |
There was a problem hiding this comment.
Fenced refusal requeues with no suspension point — flagging this one as needs settling, not as a confirmed defect, because my own trace disagrees with the two reviewers that raised it.
When beginResolve() returns false the model goes straight back on the queue and the function returns. The App.kt collector then receives the same model immediately, so the refusal path is a loop with nothing in it that yields.
Why it probably does not bite: the fence only goes up inside GenerationQuiescer.quiesce, which runs uiGate.awaitRetired first — and that cannot succeed until the generation region is forgotten, which is the same main-frame event that cancels the LaunchedEffect holding the collector. A cancelled collector should die at its next receive() rather than spin.
Why it might: receive() on a channel with an element already buffered can take a fast path that does not check job cancellation, and the ED11 deferred delete runs under NonCancellable, so there is a real window where the collector is cancelled-but-running.
Settling it needs a pin, not a patch — a test that fences with a non-empty queue and a cancelled collector and asserts the collector terminates. Unreachable on Android today either way (fenceSnackbarResolves has no production call site), so this is a Phase-7 obligation rather than a merge blocker.
| return RecoverySourcePlan.Apply(canonical, SourceConsumption.None) | ||
| } | ||
| return when (mutation.consume) { | ||
| is SourceConsumption.ExactFile -> RecoverySourcePlan.Stop( |
There was a problem hiding this comment.
selectRecoverySource cannot tell "exact-file source was not applied" from "it was applied and only the durable record failed."
Both arrive here with reservation == null and consume = ExactFile, and the branch stops unconditionally. So an explicit-source requested rollback whose onMutationCommitted throws once — a transient DataStore write failure — goes Fatal instead of taking the one bounded source-and-record retry §8.4 mandates for a requested rollback.
Concretely: an interrupted restore leaves attempt A Prepared naming reservation R; recoverFrom calls rollbackToPreRestoreBackup(sourcePath = R, ...); the swap succeeds; recordAttemptCommitted(A) throws; commitMutation returns NotDurable; recoverViaRollback lands here and stops. The live file already holds the correct pre-restore image at that point, so Fatal is stricter than the protocol requires.
One of the two lenses refuted this on the grounds that stopping is the conservative reading of §8.5a — which is defensible. Recording it because the reason the branch stops is a lost distinction, not a decision: MutationPlan carries no flag for whether replaceLiveDatabaseFile already succeeded, so the code could not take the retry even if the protocol wanted it to.
RebuildInProcess only, so no Android production impact.
|
|
||
| fun admitUiGeneration(id: Int): AppUiAdmissionToken? { | ||
| if (retiredIds.contains(id)) return null | ||
| return runtimeDelegate?.admitUiGeneration(id) |
There was a problem hiding this comment.
The harness turns a real admission REFUSAL into a granted token, which makes the admission invariant untestable against a real AppRuntime.
runtimeDelegate?.admitUiGeneration(id) ?: StaticToken(id) — the elvis fires in two different situations that mean opposite things: no delegate installed, and the delegate answered null on purpose. The retiredIds guard above only covers harness-simulated retirement, not the real gate's verdict.
The consequence is concrete: mutate UiAdmissionGate.admit to always return null — the exact defect the invariant exists to rule out — and AppRuntimeUiHandshakeDeviceTest, the branch's only test that puts a real runtime behind the real shell, still passes, because the harness manufactures a grant. Against production wiring the same mutant blanks the app.
Secondary: that path also increments staticAttachments, so outstandingAdmissions(id) reports a grant the real gate never issued, and the harness's own leak accounting is wrong in the runtime mode where it matters most.
Fix shape: distinguish the two cases — if (runtimeDelegate != null) return runtimeDelegate.admitUiGeneration(id) before the static fallback.
Bot review triage (automated verification pass)Every item from the
1 — reproduced exactly. 3 — reproduced. 6 — reproduced. 7 — closed, incidentally: the comment trim in Not re-litigated: the residuals the PR body already discloses — the null-source One bot-adjacent claim I could not confirm and am recording as refuted: a reviewer raised "an in-process Fatal strands |
Phase 5 — startup processor, runtime generations, restart-free reinitialization
Implements maintainer decision R2 (2026-08-22) plus three rework rounds. Governing spec:
documentation/feature-specs/kmp-phase-5-startup-processor.md— §8.4/§8.5a/§8.5b hold theCURRENT protocol (rewritten in place each round); §20–§25 are the per-round records; raw
evidence (known-negative and red-on-base JUnit XMLs) in
documentation/feature-specs/kmp-phase-5-evidence/.Status: the R4.2 correction is addressed (durable-phase-aware terminal dispatch, on top
of R4.1 and the round-4 maintainer correction). Still a draft; not marked ready.
The protocol, as it stands now
never-cancelled host scope; restore sources STAGED into runtime-owned copies inside the
non-suspending submission frame; ALL caller compensation on one typed
DatabaseReplacementEffectsobject —onBeforeMutationinside the mutex, exactly oneterminal method per transaction, failures folded onto the outcome (
effectsError), never asilently clean commit.
(
Prepared → Committed → resolved), claimed atomically before anything irreversible;Preparedat cold start means "outcome unknown" and never yields a peek-driven success.Owner-isolated end to end (R4): the id-less pre-R3 legacy marker converts atomically under
its one synthetic owner id, an arbitrary attempt can neither claim nor clear it, and the
scenario-1 re-claim CHECKS its result instead of mutating unjournaled.
INSIDE the transaction; promotion onto the canonical undo slot COPIES, so the journal-named
file survives every crash point; the reservation is deleted only after the durable
Committedrecord, and a retained copy is cleaned idempotently by the committed cold-startfinalization. A journal-named source is AUTHORITATIVE — missing means a typed rejection and
terminal recovery, never a silent substitution of another attempt's canonical slot — and a
committed rollback consumes EXACTLY the file it applied (typed
SourceConsumption), so areservation-sourced recovery leaves the previous restore's undo valid.
RetrySafeis removed. A pre-PONR rejection of therecovery rollback proves only that THIS rollback did not mutate — never what the original
Preparedattempt did — so every rollback outcome short of a clean durable commit isRecoveryRequired: zero DB-bound arming (no planner, no repositories, no dialog observer),no Main UI, every asset preserved, the explicit recovery surface. A graph-only reinitialize
over an unresolved
Preparedjournal aborts rather than publishing over unproven data.dialogs) lands BEFORE the attempt resolves; a death anywhere replays idempotently; the
committed-rollback replay takes its availability verdict from ground truth (canonical file
existence). The one documented at-most-once residual — the interrupted rollback's
user-facing dialog — has its exact boundary in §8.5b.
Committed= the REQUESTED operation committed;RecoveredByRollback= serving on PRE-operation data (restore-FAILURE semantics, no undooffer); phase-aware Fatal dispatch; Fatal is terminal under concurrency.
replacement joins the lifetime (Store jobs included — they parent to the generation) before
close; graph-only publishes N+1 only after N's teardown COMPLETES, and a failed teardown or
unjoinable candidate is FATAL — never publish-anyway, never a republished N beside leaked
candidate work, never an epoch advance while N's producers live
(
PartialCandidateUnwindExceptionmakes the partial-build failed-unwind distinct from anordinary construction abort).
doWork; token-basedUI admission granted during COMPOSITION with an ABA-safe atomic retire; snackbar models
generation-tagged at enqueue with the epoch advancing BEFORE publication.
R4.1 — the final bounded correction (spec §24)
RecoveryCompletedfinalization is SOURCE-AWARE from the journal's ownrollbackSnapshotPathdiscriminator (null = canonical applied → finish its consumptionidempotently + clear availability; non-null = consume exactly the named file, preserve a
surviving canonical and its availability) — never inferred from file existence; resolve
LAST; idempotent replay.
Committedrecorded through the ORIGINAL effects before the exact-source consumption (failures
surfaced, never a clean
Completed; asset + journal retained on a failed record), samebounded ladder, outcome
Completed. Anonymous compensation remains only for the rollback ofa failed Restore.
cancel + bounded join → close) before any file swap; a failed clear/join/close is Fatal
with zero renames after; no double teardown.
the machine reaches Fatal within the drain budget (deferred completes, no successor, epoch
unchanged, admission retired, worker acquirer fails loud); the old unbounded implementation
was executed as a mutant and hangs the pin.
R4.2 — durable phase vs terminal dispatch (spec §25)
commitMutationreturns the EXPLICIT protocol phase (CommitResult.Durable | NotDurable):a failure in reservation promotion or the durable record is never dispatched as a committed
terminal — pre-R4.2 the
Completed(effectsError)shape let the productiononCommittedeffects resolve the still-
Preparedattempt, clear availability, publish success andacknowledge, erasing exactly the state conservative recovery needs.
NotDurablemaps toFailedAfterMutation(RestartProcess), the bounded recovery ladder (RebuildInProcess — arestore rolls back onto its kept reservation; a requested rollback retries source + record
once, persistent failure → Fatal with everything preserved), or
FailedAfterMutationfromthe inline branch. The only remaining origin of
Completed.effectsErroris a failure OF theterminal callback after a DURABLE commit.
@Suppressadded by39e9155cis removed (typedProbeViewModelFactory); thezero-new-suppressions claim is verified against the FULL range
936ab699..HEAD.Fatal verdict, proving the documented residual literally.
Commits
Rounds 1–3 (
95458856…936ab699) as recorded in spec §20–§22. Round 4 + R4.1:1dacb403fix(backup): make restore attempt ownership crash-durable(blockers A, B, C, E)3e9aee6efix(runtime): terminalize incomplete graph-only teardown(blocker D)e9459025fix(runtime): close the defects the round-4 adversarial review confirmed7c82c368docs(kmp): reconcile round-4 corrections and evidence39e9155cfix(runtime): close the remaining rollback protocol gaps(R4.1)83793531docs(kmp): reconcile final rollback evidence and PR truth(R4.1)f2d3c81afix(runtime): never dispatch a pre-durable commit failure as committed(R4.2)85a3e48fdocs(kmp): record the R4.2 durable-phase correction(R4.2)Round-4 discriminating tests — proven RED on the pre-correction head
All 11 mandated round-4 tests (plus supporting pins — 21 failures total: 20 named tests +
1 parameterized invocation) were run against a worktree at
936ab699and FAILED there; thesix R4.1 pins were proven the same way against
7c82c368(raw XMLs:kmp-phase-5-evidence/r4-red-on-base-*.xmlandr41-red-on-base-*.xml). Spec §23.1/§24 mapeach correction to its exact pin; §23.2 is the crash-state matrix for one restore attempt
across every process-death point — a COMPOSED proof (file-level pins against the real
provider, ordering pins against the runtime, classification pins against the coordinator); no
literal two-launch process-death integration test exists.
Known-negatives (executed red and reverted; XMLs committed)
Round 4: R4-A explicit-path→canonical fallback re-enabled; R4-B "publish the candidate
anyway" restored after a failed teardown; R4-C the invalid safe-retry classification
restored. R4.1: the unbounded-clear mutant (the old
withContext(mainDispatcher)form)hangs the new liveness pin to its timeout. Earlier rounds' N1–N3, R3-A–R3-D and the
severed-dispose device XML stand.
Verification (final, all forced)
Host battery (
assembleDebug testDebugUnitTest verifyPaparazziDebug lintDebug assembleDebugAndroidTest --rerun-tasks --no-build-cache --no-configuration-cache): exit 0,3265/3265 tasks, 2725 host tests / 0 failures, zero Paparazzi movers.
detekt +
:lint-rules:test: exit 0, zero new suppressions (no@Suppress, no baseline orconfig change in the round-4 diff). Device (API-34 arm64): ui_tests.yml-form
Regression 81/0 and Smoke 44/0; Room same-instance characterization
2/2 unchanged. Affected-KMP iOS compile: exit 0 (link/runtime remains honestly UNVERIFIED — no
host before Phase 7). A fresh-context 4-hunt adversarial review ran over crash ordering,
source-owner identity, teardown return values and outcome truth/test vacuity; its outcome is
recorded in spec §23.5 and every confirmed finding is fixed in
e9459025; the three R4.1protocol defects and their fixes are recorded in §24.
Android production behavior
MainActivityuntouched; cold-start order identical; restore success keeps result-then-restart;RestartProcessremains the production policy; Metro create() roots, Nav3 shell, recoveryordering unchanged. The saveable-slot one-frame death window stays documented-deferred
(§18(a)); no Phase 7 work; no swappable DAO/repository indirection (the only repository touched
is the journal's own
RestoreStateRepository). §23.4 lists the deliberate residuals with theirexact boundaries.
Do not merge — Ilya merges.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz