Skip to content

KMP Phase 5: startup processor, runtime generations, restart-free reinitialization (R2) - #252

Open
stslex wants to merge 37 commits into
devfrom
feature/kmp-phase-5-startup-processor
Open

KMP Phase 5: startup processor, runtime generations, restart-free reinitialization (R2)#252
stslex wants to merge 37 commits into
devfrom
feature/kmp-phase-5-startup-processor

Conversation

@stslex

@stslex stslex commented Aug 22, 2026

Copy link
Copy Markdown
Owner

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 the
CURRENT 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

  • One serialized replacement transaction (spec §8.4): submission-owned bodies on the
    never-cancelled host scope; restore sources STAGED into runtime-owned copies inside the
    non-suspending submission frame; ALL caller compensation on one typed
    DatabaseReplacementEffects object — onBeforeMutation inside the mutex, exactly one
    terminal method per transaction, failures folded onto the outcome (effectsError), never a
    silently clean commit.
  • A crash-durable attempt journal (§8.5a): at most one unresolved attempt
    (Prepared → Committed → resolved), claimed atomically before anything irreversible;
    Prepared at 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.
  • Crash-safe reservation lifecycle (R4): the rollback snapshot is reserved per-attempt
    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
    Committed record, and a retained copy is cleaned idempotently by the committed cold-start
    finalization. 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 a
    reservation-sourced recovery leaves the previous restore's undo valid.
  • Terminal classification (R4): 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 — so every rollback outcome short of a clean durable commit is
    RecoveryRequired: 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 Prepared journal aborts rather than publishing over unproven data.
  • Replay-safe finalization (R4): every data-bearing write (undo availability, persisted
    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.
  • Result truth (§8.4): Committed = the REQUESTED operation committed;
    RecoveredByRollback = serving on PRE-operation data (restore-FAILURE semantics, no undo
    offer); phase-aware Fatal dispatch; Fatal is terminal under concurrency.
  • Teardown terminality (R3+R4): PONR = the START of every irreversible action. Strict
    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
    (PartialCandidateUnwindException makes the partial-build failed-unwind distinct from an
    ordinary construction abort).
  • Closed admission (R3): worker leases as the first operation inside doWork; token-based
    UI 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)

  • Committed canonical rollback is no longer repeatable after a crash: the
    RecoveryCompleted finalization is SOURCE-AWARE from the journal's own
    rollbackSnapshotPath discriminator (null = canonical applied → finish its consumption
    idempotently + 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.
  • A requested rollback's successful retry commits as the requested operation: Committed
    recorded through the ORIGINAL effects before the exact-source consumption (failures
    surfaced, never a clean Completed; asset + journal retained on a failed record), same
    bounded ladder, outcome Completed. Anonymous compensation remains only for the rollback of
    a failed Restore.
  • The inline rollback disposes the candidate through THE teardown protocol (VM clear →
    cancel + bounded join → close) before any file swap; a failed clear/join/close is Fatal
    with zero renames after; no double teardown.
  • The bounded-clear liveness proof: a dispatcher that accepts but never runs the clear —
    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)

  • commitMutation returns 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 production onCommitted
    effects resolve the still-Prepared attempt, clear availability, publish success and
    acknowledge, erasing exactly the state conservative recovery needs. NotDurable maps to
    FailedAfterMutation (RestartProcess), the bounded recovery ladder (RebuildInProcess — a
    restore rolls back onto its kept reservation; a requested rollback retries source + record
    once, persistent failure → Fatal with everything preserved), or FailedAfterMutation from
    the inline branch. The only remaining origin of Completed.effectsError is a failure OF the
    terminal callback after a DURABLE commit.
  • The one @Suppress added by 39e9155c is removed (typed ProbeViewModelFactory); the
    zero-new-suppressions claim is verified against the FULL range 936ab699..HEAD.
  • The liveness pin uses a REAL queueing dispatcher and executes the abandoned clear after the
    Fatal verdict, proving the documented residual literally.

Commits

Rounds 1–3 (95458856936ab699) as recorded in spec §20–§22. Round 4 + R4.1:

SHA Commit
1dacb403 fix(backup): make restore attempt ownership crash-durable (blockers A, B, C, E)
3e9aee6e fix(runtime): terminalize incomplete graph-only teardown (blocker D)
e9459025 fix(runtime): close the defects the round-4 adversarial review confirmed
7c82c368 docs(kmp): reconcile round-4 corrections and evidence
39e9155c fix(runtime): close the remaining rollback protocol gaps (R4.1)
83793531 docs(kmp): reconcile final rollback evidence and PR truth (R4.1)
f2d3c81a fix(runtime): never dispatch a pre-durable commit failure as committed (R4.2)
85a3e48f docs(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 936ab699 and FAILED there; the
six R4.1 pins were proven the same way against 7c82c368 (raw XMLs:
kmp-phase-5-evidence/r4-red-on-base-*.xml and r41-red-on-base-*.xml). Spec §23.1/§24 map
each 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 or
config 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.1
protocol defects and their fixes are recorded in §24.

Android production behavior

MainActivity untouched; cold-start order identical; restore success keeps result-then-restart;
RestartProcess remains the production policy; Metro create() roots, Nav3 shell, recovery
ordering 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 their
exact boundaries.

Do not merge — Ilya merges.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WWXhGN9th6NP3MWu4No3nz

stslex and others added 3 commits August 22, 2026 20:22
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
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Unit Test Results

  247 files    247 suites   13m 9s ⏱️
2 261 tests 2 261 ✅ 0 💤 0 ❌
2 264 runs  2 264 ✅ 0 💤 0 ❌

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
@stslex stslex changed the title KMP Phase 5: startup lifecycle spec + Room entry gate — GATE RED, maintainer decision needed KMP Phase 5: runtime generations + restart-free reinitialization (R2 db-generation) Aug 22, 2026
stslex and others added 7 commits August 22, 2026 22:07
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
@stslex stslex changed the title KMP Phase 5: runtime generations + restart-free reinitialization (R2 db-generation) KMP Phase 5: startup processor, runtime generations, restart-free reinitialization (R2) Aug 22, 2026
stslex and others added 3 commits August 23, 2026 00:57
… 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
@stslex

stslex commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

REQUEST_CHANGES addressed — all six findings fixed in 95458856 + 366997da, docs/evidence truth pass in 52429c8c. Full map in spec §20.1 (fixes) and §20.2 (exact per-suite proof claims); highlights:

Finding Fix Pinned by
1 — self-cancelling transaction Submission ownership: transactions run on the runtime's never-cancelled hostScope; callers submit+await; post-commit effects ride beforeMutation/onCommitted hooks on the transaction's coroutine; every escape resolves via a per-transaction PONR tracker (no stranded Transitioning) AppRuntimeReplacementTest: Settings-Store cancel mid-await, undo-initiator-inside-outgoing-lifetime (hook runs, no deadlock)
2 — quiesce not a closed barrier BackupWorkLease: deps+lease atomic under the admission lock; quiesce awaits every lease incl. constructed-before-RUNNING; timeout aborts pre-close; parked acquirers bind to the successor. Snapshot drain deleted lease-drain abort + reopen + real parked-thread rebinding (JVM); release-in-finally (BackupWorkerTest); one-lease-per-admission (MetroWorkerFactoryTest)
3 — UI gate accepts foreign disposes Per-generation-id attachment counts; only the outgoing id's zero releases; no-op holder defaults removed (abstract) wrong-id / stale-id / multi-attachment negatives (JVM) + the composed device handshake below
4 — phase-conflated failure seam DatabaseReplacementResult (Committed / RejectedBeforeMutation / FailedAfterMutation / FatalNoGeneration); asset deletion only pre-mutation; post-PONR cleanup runtime-owned coordinator/use-case suites: FailedAfterMutation & Fatal preserve every asset/marker
5 — leaking ladder Staged construction closes an owned DB on graph-factory throw; never-rename-after-failed-close; rolledBack-before-consume in BOTH rollback paths; Fatal throws from every holder, never converts to Serving ladder suite incl. orphan-DB close, rolled-back retry, Fatal reads
6/7 — state-machine defects Atomic same-op single-flight under the submission lock; graph-only aborts keep the outgoing ViewModelStore intact (probe-ViewModel asserted); nested rollback in graph-only preflight rejected deterministically (no mutex deadlock); one immutable published value behind both phase faces AppRuntimeTest (12 tests)

Composed real-handshake device proof: AppRuntimeUiHandshakeDeviceTest runs a REAL AppRuntime behind the whole app shell (MetroTestGraphHolder.runtimeDelegate — production stream, production callbacks). Known-negative executed+reverted: severing the dispose callback turns it red with the bounded Aborted("ui region did not dispose in time") — raw XML committed (known-negative-ui-handshake-severed-dispose.xml). RuntimeGenerationSwapDeviceTest's claims narrowed to its honest boundary (inode/close/fresh-Room over empty quiesce populations).

Re-verification (2026-08-23, forced): host battery 3265/3265 tasks, 2609 tests / 0 failures; detekt + :lint-rules:test green (zero suppressions added); Regression 78/0, Smoke 43/0 on the API-34 emulator; :core:data:backup:api iOS compile green. Spec §19 retitled — the in-repo review records are artifacts, not GitHub approvals.

Kept as directed: RestartProcess production default, Metro create() roots, Nav3 shell, cold-start ordering; no Phase 7 work; the saveable-slot one-frame death window stays documented-deferred (§20.3). PR remains a draft.

stslex and others added 6 commits August 23, 2026 03:17
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
@stslex

stslex commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

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).

Correction Mechanism Pinned by
1 — source ownership at submission Staging into a runtime-owned copy in the non-suspending submission frame; terminal cleanup on every outcome; no NonCancellable awaits Submission-frame pin under a STANDARD host dispatcher (+UNDISPATCHED caller); the composed gate's genuine caller finally-delete; mid-copy debris cleanup
2 — runtime-owned compensation Typed DatabaseReplacementEffects (onBeforeMutation in-mutex + exactly one terminal method, UNDER the mutex, on the transaction coroutine, escapes included) + the durable restore_mutation_interrupted journal (§8.5a); Committed(effectsError) never silently clean Packaged-effects discriminators (capture WITHOUT invoking → nothing runs post-await → manual invoke runs the writes); marker+caller-killed+timeout gate; T2-blocked-behind-T1's-suspended-compensation pin
3 — result truth Committed = requested op only; new RecoveredByRollback (restore-failure semantics; the inline-S1-rollback outer path no longer lies Completed) RecoveredByRollback, never Completed ×2 + the integration gate's "no success lie"; known-negative N1 (4 pins red)
4 — asset preservation Delete-on-FailedAfterMutation branch + test REMOVED; non-commit rollbacks preserve everything AND return the new RecoveryRetryPending (no restart → no silent boot loop; truthful failure dialog) Two-coordinator process-restart gate; preserve-everything pins; StartupProcessorTest continuation pin
5 — PONR at the start PONR = teardown start / close INVOCATION / rename; close-throw → Fatal (RestartProcess: FailedAfterMutation + journal), never a cleanup-safe rejection; candidate/orphan/inline close throws stop the ladder; candidate jobs joined before their DB closes close-throw-Fatal + no-rename pins; known-negative N2 (5 pins red); candidate-join order pin
6 — complete teardown Strict clears the outgoing VMStore + joins the lifetime BEFORE close (unjoinable → Fatal, no close); graph-only publishes N+1 only after N's teardown boundary probe-VM + DB-job order-recorded pin; unjoinable→Fatal pin; teardown-before-publish pin (both observers see Transitioning, never N+1)
7 — admission Lease at doWork's FIRST op (factory captures nothing; never-started worker holds nothing); UI retire CAS atomic with the zero observation; snackbar queue generation-tagged (discard on commit / preserve on abort) never-started-no-lease + doWork-time-binding pins; order-sensitive unconfined acquirer; the 4000-iteration retire-CAS hammer; kit epoch pins + known-negative N3
8 — Fatal terminal Liveness rechecked inside the mutex; queued/post-Fatal ops do nothing and return Fatal; publishTransitioning cannot overwrite Fatal; deferreds complete exactly once incl. internal CE; phase-aware Fatal dispatch (a did-nothing transaction runs no compensation) A-Fatal-while-B-queued; replace/reinitialize-after-Fatal; CE-in-preflight deferred-resolves; no-op-Fatal-no-compensation pin

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 e9886497 — terminal-effects-under-the-mutex, phase-aware Fatal dispatch, the RecoveryRetryPending no-boot-loop outcome, staging debris, and five test-vacuity holes hardened into discriminating pins. One deliberate residual documented (anomalous RestartProcess close-throw journal window, §21.2a).

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; :core:data:backup:api iOS compile green. Known-negatives N1/N2/N3 executed red and reverted — XMLs committed beside the evidence README.

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.

stslex and others added 5 commits August 23, 2026 12:20
…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.
@stslex

stslex commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

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.

# Blocker Fix Pinned by
1 restore_mutation_interrupted was written only by terminal effects, so a death after close-start left the OLD valid DB with no marker → the cold-start peek published a false RestoreSuccess Attempt journal: Prepared persisted atomically pre-PONR with identity + context + reserved rollback path; Committed only after the rename succeeded AND the reservation was promoted; owner-only transitions; legacy/unparsable read as Prepared a PREPARED attempt never peeks the schema and never claims success (+18 repository tests). KN R3-A reproduces the false success on demand
2 every non-commit rollback collapsed into one retry outcome, after which chores armed and the main UI showed over an unknown DB RetrySafe (proven pre-PONR, DB intact) vs RecoveryRequired (post-PONR/closed/fatal → RouteToRecovery, arming zero DB-bound work) RecoveryRequired routes to recovery and arms ZERO db-bound work (planner, recoveryBootstrap, cleanupTempFiles each exactly = 0)
3 preserveCurrentDb() ran pre-submission, outside the mutex, onto ONE canonical path reservation taken inside the transaction, per-attempt, recorded in the journal, promoted on commit, discarded only by its owner; preserveCurrentDb deleted reserve-inside-the-transaction, rejected-attempt-keeps-the-previous-slot (sentinel bytes), promote-on-commit, swap < promote < record ordering
4 UI gate counted attachments (ABA-prone) and admitted from an EFFECT — i.e. after the region's children had already resolved token gate with atomic retire; admission during composition via RememberObserver; Store jobs parented to the generation lifetime; onCleared ends the Store ABA test a counter cannot pass; the rewritten retire-CAS hammer; a real BaseStore.launchDefault whose DB-touching finally completes before cancelAndJoin returns. KN R3-C
5 epoch advanced AFTER publish; requeue re-stamped the current epoch; resolve accounting was a non-atomic RMW with no fence advance before publish; the epoch travels with the model and requeue preserves it; one linearizable gate; fence closes admission atomically with the zero observation five snackbar tests + the snackbar epoch advances BEFORE the successor is published. KN R3-B
6 buildGeneration cancelled a partial candidate and closed its DB immediately one teardown path: clear → cancel + bounded JOIN → close only after the join → any failure stops the ladder with no later rename candidate-jobs-joined-before-close and partial-construction-joins-before-closing-the-orphan, both order-recorded with a DB-touching finally

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:

  • an interrupted-but-committed rollback was re-driven, failed looking for the file it had already consumed, and left the attempt unresolved forever — refusing every future restore and undo with no in-app remedy;
  • the recovery path erased the journal's pointer to the only copy of the true pre-attempt database, so a second interruption reverted the user past their own data;
  • the ladder recorded the caller's attempt as committed for a rollback (the false-RestoreSuccess hole from another direction), and applied the older canonical slot instead of the attempt's reservation;
  • promoteRollbackReservation deleted the undo slot before the move;
  • UiAdmissionGate.admit leaked a grant across a losing CAS retry, handing a token to a generation the gate had already reported clear;
  • a refused region was cached against the id alone, so an abort's same-id re-publish left the app blank.

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 generationJob required stops the argument being dropped, but not the value being wrongremember { null } still compiles and still un-parents every Store job. The device Smoke run surfaced it indirectly by going red for an unrelated-looking reason: :core:ui:mvi's probe deliberately builds a Store with no app graph, and the processor now legitimately needs one app-scope binding, so its plain Application failed the appDeps cast.

The tempting fix was a ?: null fallback in rememberStoreProcessor. It would have compiled, turned the gate green, and re-opened precisely the defect blocker 4 exists to prevent — the strictness of that cast is what forbids a Store from silently starting un-parented jobs. So the probe supplies the contract the way production does instead (an applicationContext implementing AppDepsHolder, overriding only LocalContext so the scope invariants under test are untouched), and a new device test ends the lifetime the holder handed out to prove the seam supplies the real parent. §22.3b carries the reasoning and the proof boundary.

Known-negatives executed red and reverted, XMLs committed: R3-A (ignore the attempt phase → false RestoreSuccess, 7 coordinator tests red), R3-B (publish before the epoch advance), R3-C (two-step retire, dies at hammer iteration 29), R3-D (remember { null } for the generation job — red on the new seam pin while the scope test beside it stays green, which is why the gap outlived the review's first pass). Six further mutations run by the test authors each died to their own new pin.

Gates, all forced (--rerun-tasks --no-build-cache --no-configuration-cache): host battery exit 0 in 8m29s, 3265/3265 executed, 2688 host tests / 0 failures; detekt + :lint-rules:test exit 0 with zero new suppressions (the LargeClass pressure from these fixes was resolved by extracting GenerationQuiescer, not silenced); Paparazzi in the graph with zero golden movers; device Regression 81/0, Smoke 44/0, and the Room same-instance characterization 2/2, unchanged in what it proves. Affected KMP iOS modules compile; link and runtime stay honestly unverified — no iOS host exists before Phase 7.

Everything else is unchanged: Android production stays RestartProcess, no Phase 7 work, and no swappable DAO/repository indirection — the only repository touched is RestoreStateRepository, the journal's own persistence surface. Six commits, each green when authored; the seam commit carries the probe fix so the device Smoke gate is green from that commit onward rather than only at the tip.

PR remains a draft.

stslex and others added 4 commits August 23, 2026 17:29
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
@stslex

stslex commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

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 936ab699 (new test files onto a worktree at that commit; raw XMLs committed as r4-red-on-base-*.xml) and is green at head.

Blocker Fix Red-on-base pin
A — promotion moved the reservation before durable Committed; explicit-source misses silently fell back to another attempt's canonical; consumeSource deleted the canonical instead of the exact applied file; keepReservation missed rejected-with-failed-compensation copy-based promotion (the journal-named file survives every crash point; deleted only after the record, with idempotent committed-cold-start cleanup); journal-named sources AUTHORITATIVE (typed rejection, never substitution); typed SourceConsumption (exact-file consumption); reservation kept on every unresolved-journal outcome promotion COPIES… (real files, sentinels, the impl suite); a MISSING journal-named rollback source is a typed rejection…; an explicit rollback applies and consumes EXACTLY its named source…; a rejection whose terminal compensation fails KEEPS the reservation…; ladder recovery whose reservation VANISHED goes Fatal…
BRejectedBeforeMutation from the recovery rollback read as "safe retry": planner armed, observer armed, Main UI over a file whose original Prepared mutation state was unproven RetrySafe REMOVED — every non-commit rollback outcome is RecoveryRequired (zero DB-bound arming, no Main UI, assets + journal preserved). The graph-only reinitialize over an unresolved Prepared journal now aborts, per locked invariant 4 every non-commit rollback outcome requires terminal recovery (rejection case) + the zero-arming processor pins
C — the legacy marker was broken both ways: the legitimate synthetic owner was REFUSED (its unchecked claim left the mutation unjournaled and the recovery failing its own bookkeeping) while ANY arbitrary attempt id could erase the marker through resolveAttempt atomic legacy→owner-scoped conversion under exactly the synthetic id; resolveAttempt requires that id; ScenarioOneRollbackEffects CHECKS its re-claim; legacy Prepared → rollback → Committed → resolved end-to-end repository: only the synthetic legacy owner claims… + the flipped arbitrary-resolver pin (real DataStore); coordinator: the recovery re-claim CHECKS ownership… + the legacy end-to-end
D — "publishing the candidate anyway" after a failed post-PONR teardown; the candidate-teardown verdict discarded on the preflight-fail path; the partial-build join verdict ignored for shared-DB candidates a failed teardown/join is TERMINAL: best-effort remaining cleanup → aggregate → Fatal — no publication, no epoch advance, gates closed; tearDownCandidate == false → Fatal, never a republished N; PartialCandidateUnwindException makes the failed unwind distinct from an ordinary construction abort mandated tests 7–10, all red on base
EhandleRestoreSuccess resolved the journal BEFORE marking undo availability (a death between hid a valid undo forever); the committed-rollback replay unconditionally revoked the previous restore's undo; retained reservations leaked replay-safe ordering everywhere: data-bearing writes first, resolve LAST; ground-truth availability (canonical file existence); idempotent retained-reservation cleanup; the at-most-once rollback-dialog residual documented with its exact boundary (§8.5b) success finalization is data-bearing-first…; a finalization failure never leaves the journal resolved with the undo hidden (mandated test 11); …keeps the previous restore's still-valid undo; …cleans up the RETAINED reservation copy idempotently

The mandated fresh-context adversarial review earned its keep (4 hunts: crash ordering, source-owner identity, teardown return values, outcome truth/vacuity). Production RestartProcess checked clean on every prong; the confirmed findings — all RebuildInProcess-scoped — are fixed in e9459025, each pin proven red against the pre-fix production (r4-review-red-prefix-*.xml):

  • 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. It now runs the same source policy as the top level, and runs onBeforeMutation before mutating, so a death inside the inline mutation replays truthful Prepared/Rollback bookkeeping instead of a false RestoreSuccess.
  • HIGH: the post-clean-commit ladder recovery rolled back while the journal still read Committed — the very next preflight published RestoreSuccess for rolled-back data, no crash required. The recovery now durably UN-commits first, applies without consuming, and grants one bounded fresh attempt after an inline re-recovery.
  • Plus: ground-truth undo availability in the settings effects, terminal OrphanCloseException handling for the generation-1 build, and a bounded ViewModelStore clear so a wedged main dispatcher cannot hang the machine inside the mutex.

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; detekt + :lint-rules:test exit 0 with zero new suppressions (verified by diff: no @Suppress added, no config/baseline touched); device API-34 Regression 81/0, Smoke 44/0, Room same-instance characterization 2/2 unchanged; affected KMP iOS compile exit 0 — link/runtime honestly UNVERIFIED (no host before Phase 7).

No Phase 7 work; no swappable DAO/repository indirection; deliberate residuals with exact boundaries in §23.4. PR remains a draft.

stslex and others added 2 commits August 23, 2026 20:02
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
@stslex

stslex commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

R4.1 final correction addressed — two commits (39e9155c + the docs commit) on the same draft PR. Spec §24 is the record; every new pin ran red at 7c82c368 (r41-red-on-base-*.xml) or against the executed-and-reverted unbounded-clear mutant, and is green at head.

Blocker Fix Red pin
1 — a committed canonical rollback stayed repeatable after a record→consume crash: the replay read the surviving canonical as "a valid previous undo" and left the same rollback offered again — replaying it after later writes would erase them the RecoveryCompleted finalization is SOURCE-AWARE from the journal's own rollbackSnapshotPath discriminator (null = the canonical was applied → finish its consumption idempotently + clear availability; non-null = consume exactly the named file, preserve a surviving canonical and its availability, clear only if the canonical is actually absent) — never inferred from file existence; resolve LAST; the branch replays idempotently on any mid-way failure a committed CANONICAL-sourced rollback consumes the canonical — the same undo is never offered again (consume → clear → resolve order + second-launch NoOp); a committed-rollback finalization failure keeps the journal — the replay is idempotent; the explicit-source pair (A consumed, B + availability survive; A-with-no-canonical clears)
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 next preflight — an unresolvable Prepared attempt with no source left — went Fatal the retry commits through the ORIGINAL effects: Committed recorded before the exact-source consumption; a failed record keeps the asset and the unresolved journal, surfaced on the outcome (never a clean Completed); same bounded ladder; outcome Completed, never RecoveredByRollback. Anonymous compensation remains only for the rollback of a failed Restore, where recording the Restore as committed would be false the composed pin with a journal-aware production-shaped preflight: two swaps and no third, onMutationCommitted before the consumption, the preflight observes Committed/Rollback, a Serving successor publishes, terminal effects exactly once, clean Completed; plus the negative bookkeeping case
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 inline invalidation routes through THE candidate teardown protocol (a suspend disposal callback backed by tearDownCandidate(candidate, close = true)): claim → invalidate → VM clear → cancel + bounded join → close → only then swap; candidateDisposed prevents a second teardown; a failed clear/join/close is Fatal with zero renames after, nothing published, journal and assets preserved the order pin (VM clear / job finally → close → swap, exact source applied and consumed); an UNJOINABLE candidate stops the inline rollback FATAL — zero renames after admission (bounded, epoch unchanged, admission retired)

The missing liveness proof: the previous immediate-throw test could not distinguish clearStoreBounded from the old unbounded withContext(mainDispatcher). The new pin uses a dispatcher that ACCEPTS but never RUNS the clear, with the transition on an advanceable scheduler: after the drain budget the submitted deferred completes, the outcome and runtime are Fatal, no successor publishes, the epoch does not advance, UI admission stays retired, and the worker acquirer wakes and fails loudly. The old implementation was executed as a mutant and hangs this test to its timeout (known-negative-r41-unbounded-clear.xml), then reverted.

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 Committed canonical rollback: record → consume death row; §23.4's null-source residual covers any Prepared attempt (legacy, interrupted canonical-sourced rollback, torn write); and the claim that a committed rollback's source identity "cannot be known" is removed — rollbackSnapshotPath is the durable discriminator.

AppRuntime re-crossed the LargeClass ceiling from these fixes; the cohesive non-publishing pieces moved to ReplacementMechanics/RuntimeGeneration.kt — no suppression anywhere.

Gates (all forced): host battery exit 0 — 2720 host tests / 0 failures, 3265/3265 executed, zero Paparazzi movers; detekt + :lint-rules:test forced, exit 0, zero new suppressions; device API-34 Regression 81/0, Smoke 44/0, Room characterization 2/2 unchanged; affected KMP iOS compile exit 0 — link/runtime honestly UNVERIFIED.

PR remains a draft.

stslex and others added 2 commits August 23, 2026 22:36
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
@stslex

stslex commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

R4.2 addressed — two commits (f2d3c81a + 85a3e48f). Spec §25 is the record; §8.4's terminal-selection and §8.5a's durable-commit-ordering paragraphs rewritten in place.

Blocker A — pre-durable commit failures were dispatched as committed terminals. commitMutation conflated "the file mutation happened" with "the mutation became durably provable": a promotion or onMutationCommitted failure returned Completed(effectsError), and runTerminalEffects dispatched onCommitted for every Completed — the production committed effects then resolved the still-Prepared attempt, cleared undo availability, published success and acknowledged the initiating action, erasing exactly the state §8.5a's conservative recovery depends on. The protocol phase is now EXPLICIT (CommitResult.Durable | NotDurable), and every one of the four call sites maps NotDurable to a non-committed continuation:

  • RestartProcessFailedAfterMutation: journal Prepared, reservation retained, next launch recovers;
  • RebuildInProcess primary → 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 durable record once;
  • recovery commit → persistent record failure ends Fatal with journal and assets preserved;
  • inline scenario-1 → FailedAfterMutation: feedback allowed, no resolve, no clear.

The only remaining origin of Completed.effectsError is a failure OF the terminal onCommitted callback after a durable commit — a different phase, never conflated. Caller mappings reconcile by construction: a pre-durable failure can no longer become Succeeded/RestoreSucceeded/clean Success anywhere.

Production-shaped proofsall eight pins red at 83793531 (r42-red-on-base-runtime.xml): the persistent-record retry whose REAL committed terminal (resolve/clear/publish/ack) is never invoked while Prepared + source + availability survive into a bounded Fatal; the one-shot requested-rollback failure (exactly ONE committed terminal — the base dispatched a stale SECOND from the record that never landed); promotion failure under both policies; the inline record failure that cannot erase the Prepared journal; the focused durable-phase-selects-terminal pin; and the two rewritten legacy pins. The one-shot RESTORE proof was the adversarial review's one confirmed finding (vacuous against a targeted divert-gating mutant) — strengthened with the swap-before-preflight and clean-effectsError axes, after which it too is red on base (§25.1). KN R4.2 (the old dispatch restored on the retry path) kills the production-shaped pin (known-negative-r42-predurable-dispatch.xml); reverted, empty diff.

Blocker B — the zero-suppression claim is true again. The one @Suppress("UNCHECKED_CAST") added by 39e9155c is removed via a typed ProbeViewModelFactory (Class.cast), the pre-existing sibling factory converted with it, and the claim verified against the FULL range: git diff -U0 936ab699..HEAD -- '*.kt' adds no suppressions.

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 (known-negative-r42-unbounded-clear-queued.xml) — and reverted.

Gates (all forced): host battery exit 0 — 2725 host tests / 0 failures, 3265/3265, zero Paparazzi movers; detekt + :lint-rules:test forced, exit 0; device API-34 Regression 81/0, Smoke 44/0, Room characterization 2/2 unchanged; affected KMP iOS compile exit 0 — link/runtime honestly UNVERIFIED. A fresh-context 4-hunt adversarial review ran over durable-phase dispatch, every commitMutation call site, caller mappings, and test vacuity/evidence truth; its outcome is in §25.4 — no invariant counterexample; the one confirmed vacuity finding is fixed above; the canonical-integrity observation is on record as a named residual.

PR remains a draft.

@stslex
stslex marked this pull request as ready for review August 23, 2026 22:01
@stslex

stslex commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown

Claude finished @stslex's task in 11m 13s —— View job


Claude finished the review

Todo list

  • Gather context (PR body, spec doc, changed files)
  • Read governing spec (documentation/feature-specs/kmp-phase-5-startup-processor.md) §8.4/§8.5a/§8.5b/§25
  • Review core runtime files: AppRuntime.kt, ReplacementMechanics.kt, StartupProcessor.kt, RuntimeGeneration.kt, GenerationQuiescer.kt
  • Review RestoreStateRepositoryImpl.kt (journal / attempt durability), DatabaseReplacement.kt, DatabaseSnapshotProviderImpl.kt
  • Review UI admission / snackbar generation code (SnackbarManager.kt, BaseStore.kt, StoreProcessor.kt)
  • Review recovery coordinator / backup domain (RestoreRecoveryCoordinator.kt, RestoreLatestBackupUseCase.kt)
  • Post final review

Summary

I checked the PR's own protocol claims against the code (not just the description), focusing on the durable-commit dispatch (CommitResult.Durable/NotDurable), PONR/teardown terminality, Fatal terminality, the attempt-journal reservation lifecycle, and generation-tagged UI/snackbar admission. Those core invariants hold up — I traced all four commitMutation call sites and confirmed NotDurable never becomes a committed terminal, and the snackbar epoch-fence/admission gate uses a real atomic CAS (updateAndGet), not a check-then-act race.

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 pathfeature/recovery/src/main/kotlin/io/github/stslex/workeeper/feature/recovery/domain/RestoreRecoveryCoordinator.kt:165. runPostRestoreLaunch()'s fallback branch — a Prepared/unknown-phase attempt, i.e. the process died mid-restore before committing, exactly the scenario the attempt journal exists to catch — calls recoverFrom(attempt, context, cause = null). Inside recoverFrom (line 187), reporter.recordRestoreTimeFailure(...) only fires if (cause != null && context != null) (line 192). Since cause is hard-coded null here, this non-fatal never reaches Crashlytics for the primary interrupted-restore case — it only fires for the narrower "attempt already Committed but the post-commit schema peek unexpectedly throws" branch (line 115). Verified against the diff and the test suite (a PREPARED attempt never peeks the schema... makes no assertion about reporter). Worth synthesizing a placeholder Throwable so the non-fatal still records with real context.

2. [Minor] Asymmetric re-entrancy guard, latent deadlock trapapp/app/src/main/java/io/github/stslex/workeeper/runtime/AppRuntime.kt:434-476. rollbackToPreRestoreBackup checks coroutineContext[ReplacementTransaction] / GraphOnlyTransition.Key to detect a call from inside its own transaction and route it inline (avoiding a deadlock on the non-reentrant transitionMutex). restoreFromSnapshot (line 434-438) has no equivalent guard and goes straight to replace(...). No current caller invokes it reentrantly, so it's not live today, but nothing stops a future preflight path from doing so and self-deadlocking on transitionMutex. Worth the same guard or a defensive check().

3. [Minor] Over-broad visibility on transition-only snackbar controlscore/ui/kit/src/main/kotlin/io/github/stslex/workeeper/core/ui/kit/snackbar/SnackbarManager.kt:118-157. fenceResolves(), unfenceResolves(), and advanceGenerationEpoch() are public (the sibling requeue/beginResolve/endResolve are correctly internal). They appear public only because the runtime orchestrator lives in a different Gradle module; any feature module can currently call fenceResolves() without a matching unfenceResolves() and deadlock every future snackbar app-wide, or call advanceGenerationEpoch() and silently discard in-flight toasts. Consider a narrower visibility boundary (e.g. a runtime-only internal interface).

4. [Minor, documented risk] generationJob capture fragilitycore/ui/mvi/src/main/kotlin/io/github/stslex/workeeper/core/ui/mvi/processor/StoreProcessor.kt:72-74. remember(context) { context.appDeps<StoreGenerationDeps>().appScopeLifetime.job } is correctly invalidated today only because every Store-resolving composable happens to sit inside App.kt's key(currentPhase.id) block; context (the Activity) itself doesn't change on a graph-only swap. A future call site composed outside that boundary would silently capture an already-cancelled parent job (jobs launched in it would silently no-op). Not exploitable today, but the invariant isn't documented at this call site the way the analogous LocalViewModelStoreOwner hazard is on AppFeature.kt.

5. [Nit] stageRestoreSource ignores a failed cleanup deleteapp/app/src/main/java/io/github/stslex/workeeper/runtime/ReplacementMechanics.kt:149-163. After the copyTo fallback (cross-filesystem rename failure), source.delete()'s return value is discarded — a disk-space leak on delete failure, not a protocol-correctness issue.

6. [Nit] Dead fieldcore/data/backup/scheduling/src/main/kotlin/io/github/stslex/workeeper/core/data/backup/scheduling/RestoreStateRepositoryImpl.kt:90,138. KEY_LEGACY_MUTATION_INTERRUPTED is written/cleared but never read by getAttempt(); harmless (legacy synthesis conservatively always reads Prepared anyway) but the stronger pre-R3 signal it once carried is now silently discarded.

7. [Nit] Stale doc commentapp/common/src/main/kotlin/io/github/stslex/workeeper/App.kt:87. Still refers to "The DisposableEffect is the runtime's Quiescing signal," but the actual signal is GenerationAdmission : RememberObserver (onForgotten/onAbandoned) — no DisposableEffect exists there anymore.

One agent flagged a "cold-start currentGeneration double-build/leak race" as a candidate Major finding; I traced it by hand and it does not hold up — synchronized(buildLock) (AppRuntime.kt:215) is genuine JVM mutual exclusion (not a check-then-act race), a second thread blocks until the first either publishes or throws, and isFatal cannot become true before generation 1 exists (nothing sets it pre-cold-start). I'm not including it as a finding.

Not re-litigated: the previously-documented residuals the PR body already discloses (the null-source Prepared recovery case, the mid-copy promoteRollbackReservation partial-write residual named in spec §25.4, the saveable-slot one-frame death window) all check out as described in code — I didn't find anything beyond what's already disclosed there.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +212 to +215
val currentGeneration: RuntimeGeneration
get() {
check(!isFatal) { "runtime is Fatal — no generation is serving; recovery required" }
return currentOrNull ?: synchronized(buildLock) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@stslex
stslex marked this pull request as draft August 24, 2026 05:43
@stslex
stslex marked this pull request as ready for review August 24, 2026 06:08
stslex and others added 2 commits August 24, 2026 13:56
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

@stslex stslex left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@stslex

stslex commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Bot review triage (automated verification pass)

Every item from the @claude review above reproduced against HEAD and classified per the merge-flow rule. Six of seven still stand; one is already closed.

# Bot finding Class State
1 Crashlytics skipped for the dominant recovery path correct — and new open
2 Asymmetric re-entrancy guard on restoreFromSnapshot correct, not live open
3 fenceResolves / unfenceResolves / advanceGenerationEpoch are public correct open
4 generationJob capture fragility correct — already decided open, documented
5 stageRestoreSource discards a failed source.delete() correct, nit open
6 KEY_LEGACY_MUTATION_INTERRUPTED is write-only correct open
7 Stale DisposableEffect KDoc in App.kt correct fixed

1 — reproduced exactly. RestoreRecoveryCoordinator.kt:97 calls recoverFrom(attempt, context, cause = null) and the reporter at :106 is gated on if (cause != null && context != null). The Prepared/unknown-phase branch is the interrupted-restore case the attempt journal exists to catch, and it is the one that never reaches Crashlytics; only the narrower "already Committed but the schema peek threw" branch at :70 passes a non-null cause. No test asserts on reporter for the Prepared path, so nothing catches it either. This is the one I would want closed before merge — it is a production-observability hole on the most likely recovery path, not a code-correctness issue.

3 — reproduced. fenceResolves() :83, unfenceResolves() :94, advanceGenerationEpoch() :117 are public while their siblings requeue() :66, beginResolve(), endResolve() are internal. The asymmetry is real and the failure mode the bot names is real: an unpaired fenceResolves() from any feature module wedges every future snackbar app-wide, because unfenceResolves() is the only thing that clears the gate.

6 — reproduced. KEY_LEGACY_MUTATION_INTERRUPTED appears only at :63 and :106, both prefs.remove(...). The read at :47 is KEY_LEGACY_RESTORE_IN_PROGRESS, a different key. Harmless today because legacy synthesis conservatively reads Prepared regardless, so the entry is dead weight rather than a defect.

7 — closed, incidentally: the comment trim in d8a44658b removed the stale KDoc. The current text describes GenerationAdmission : RememberObserver correctly.

Not re-litigated: the residuals the PR body already discloses — the null-source Prepared case, the mid-copy promoteRollbackReservation partial write in §25.4, the one-frame saveable-slot death window in §18 — all check out in code as described. The pass found nothing beyond them.

One bot-adjacent claim I could not confirm and am recording as refuted: a reviewer raised "an in-process Fatal strands AppUiPhase.Transitioning forever, so the app is a permanent blank screen." Both refutation lenses killed it and I agree: publishTransitioning() has no reachable call site on the RestartProcess production path, so the state is never entered on Android.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant