Skip to content

Step 5: App-Scope Collapse — DB-cascade (5a) + SnapshotExportRunner (5b) Metro-owned#176

Merged
stslex merged 177 commits into
devfrom
feature/metro-batch
Jul 26, 2026
Merged

Step 5: App-Scope Collapse — DB-cascade (5a) + SnapshotExportRunner (5b) Metro-owned#176
stslex merged 177 commits into
devfrom
feature/metro-batch

Conversation

@stslex

@stslex stslex commented Jul 14, 2026

Copy link
Copy Markdown
Owner

App-Scope Collapse — Steps 3–5 (Hilt → Metro): app scope is now fully Metro-owned

DO NOT MERGE. This PR is for review + the final on-device baseline restore/backup pass before merge. Merge into dev is the maintainer's, after that pass. Nothing here has been auto-merged.

The final DI migration: every app-scope binding now resolves from a Metro @DependencyGraph(AppScope) (AppGraph) held by BaseApplication, standing up alongside @HiltAndroidApp under a reversible dual-path (adopt-back shims). No @HiltAndroidApp / @AndroidEntryPoint removed — that is Step 6 (the single irreversible cut), not this PR.

What landed (this branch: 589777d9..bbab610f)

Step 3 (context, earlier commits): the ~40-binding bulk — dispatchers, ResourceWrapper, Navigator, the google-drive auth chain (HOME-A GMS/ktor containment), the 9 exercise repos, app-dialogs, RestoreStateRepository, ActivityHolder, etc. — flipped to @ContributesBinding(AppScope) / provides-factory @BindingContainers, each with a single-owner adopt-back shim for its still-Hilt readers.

CommonDataStore — DONE (589777d9): migrated via Metro-native assisted injection (dagger.assisted.*dev.zacsweers.metro.*, generateAssistedFactories off). Proves the assisted→Metro mechanic exists — no Metro bump, no Kotlin-2.4.0, no Detekt-rule cascade. core/data/dataStore runs Metro alongside Hilt-KSP with no collision.

5a (f23fe897) — DB-cascade collapsed to the AppDatabase root: AppGraph.create() shrinks 12 params → 3 roots (applicationContext, appDatabase, imageStorage). AppDatabase becomes the explicit create() bound-instance test-override root (Room; the seam swaps an in-memory DB) and is not @ContributesBinding-flipped. Everything derived moves into Metro: the 9 DAOs + DbTransitionRunner → a DbCascadeBindingContainer (@BindingContainer @ContributesTo(AppScope)); DatabaseSnapshotProvider / LiveDatabaseLocator (repeatable @ContributesBinding on one impl) + DatabaseJsonExporter@ContributesBinding. Deleted CoreDatabaseBindingsModule; CoreDatabaseModule keeps only the AppDatabase root.

5b (bbab610f) — SnapshotExportRunner Metro-owned (last binding flip): @ContributesBinding(AppScope) on the now-public SnapshotExportRunnerImpl; deleted AuthBindingsModule (its last binding); retired the two transient shims (databaseJsonExporter, snapshotStorage) whose sole still-Hilt reader it was.

Invariants held

  • ImageStorage + AppDatabase stay permanent create() roots (test-override roots): an androidTest fake expressed as a @ContributesBinding never merges into the main-compiled graph → a contribution flip would silently fall back to the real I/O impl (a false-green). Tests inject fake/in-memory via create().
  • No @IOappGraph back-edge (the C2 StackOverflow class): every graph-internal @Provides reads a create() root and/or the direct Dispatchers.IO; none routes through an appGraph accessor. Proven structurally, not "it compiled".
  • Restore/backup path behavior unchanged — this is a DI-ownership flip; the same instance resolves (=== seam tests: AppDatabase resolves as ONE instance across DatabaseSnapshotProvider/LiveDatabaseLocator; every Hilt adopt-back === the Metro-owned instance).

Gates (per commit, executed — not cached)

:app:dev:assembleDebug · :app:app:compileDebugAndroidTestKotlin · affected-module compile + unit tests · detekt --no-daemon --rerun-tasks (custom ContributesBindingScopeRule / ContributesToScopeRule, zero suppressions). On-device Regression + the baseline restore/backup pass is deliberately left for the maintainer's review — it is the gate for merge, not a per-commit blocker.

Residual Hilt surface = the Step-6 (big-bang cut) target — NOT in this PR

~41 adopt-back shims (AppGraphAdoptBackModule); the AppDatabase + ImageStorage root @Provides; CoreDataStoreModule / CoreUiKitModule / RecoveryBootstrapModule; 14 *HiltEntryPoint feature bridges; 2 @AndroidEntryPoint Activities; @HiltAndroidApp (Dev/StoreMobileApp); @HiltWorker BackupWorker (+ dormant MetroWorkerFactory). Step 6 removes these atomically (mapped in Phase 0). It is separate, irreversible, and gated on the device baseline-restore pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GJFepRMk2665d9Hb9bjR1D

stslex and others added 30 commits July 6, 2026 09:56
…3 catalog, KMP convention plugin, NO_ACTUAL_FOR_EXPECT guard

Phase C.0: a green toolchain foundation with ZERO migration. No module
converted, no Room rename, no DI slice — every KMP-facing addition is
declared but applied to nothing.

- catalog: KSP 2.3.6 -> 2.3.9 (2.3.6 silently skips KMP/native codegen — a
  proven false-green from Probe-2); add UNAPPLIED metro 1.1.1, room3 3.0.0,
  sqlite-bundled 2.7.0 + convention-kmpLibrary alias.
- promote KmpLibraryConventionPlugin (registered in build-logic, applied to
  zero modules; target/source-set config deferred to the first C.1 slice).
- add Detekt rule NoActualForExpectSuppressionRule: repo-wide ban on
  @Suppress("NO_ACTUAL_FOR_EXPECT"), which masks a missing `actual` and
  produces the exact false-green Probe-2 caught. Validated safe as a blanket
  ban (repo has zero expect/actual and zero such suppressions today).

Gate proven, not just green: full-repo assembleDebug + detekt green under
KSP 2.3.9; 32/32 kspDebugKotlin AND 32/32 kspDebugUnitTestKotlin executed
(0 cached) -> existing Hilt-Android codegen survives 2.3.9 on every module;
1094 unit tests pass. Guard proven end-to-end: a planted suppression fails
detekt citing the rule, green on removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LdATiNmTB5zFWUWH2ZqJeK
… catalog, KMP convention plugin, NO_ACTUAL_FOR_EXPECT guard) (#175)

## Phase C.0 — green toolchain foundation, ZERO migration

No-regret foundation for the Android→KMP migration. **No module is
converted, no Room rename is executed, no DI slice is started.** Every
KMP-facing addition is *declared but applied to nothing* — this PR only
makes the toolchain ready and adds one guardrail.

### What changed (7 files, +284/-1)
- **Version catalog** — KSP `2.3.6` → `2.3.9`; add **unapplied** `metro
1.1.1`, `room3 3.0.0`, `sqlite-bundled 2.7.0`, and the
`convention-kmpLibrary` alias + KMP/Metro plugin aliases.
- **`KmpLibraryConventionPlugin`** — promoted into `build-logic` and
registered, but **applied to zero modules**.
Target/source-set/Metro/Room3 wiring is deliberately deferred to the
first C.1 slice rather than guessed here.
- **`NoActualForExpectSuppressionRule`** (Detekt) — repo-wide ban on
`@Suppress("NO_ACTUAL_FOR_EXPECT")`, which masks a missing `actual` and
produces a false-green build (the exact issue Probe-2 caught when KSP
2.3.6 silently skipped native codegen). Validated safe as a blanket ban:
the repo has **zero** `expect`/`actual` and **zero** such suppressions
today.

### Why KSP 2.3.9 / Metro 1.1.1
- KSP **2.3.6 silently skips KMP/native codegen** — a proven
false-green; 2.3.9 is the fix.
- Locked at **Kotlin 2.3.20** (CMP caps the KMP world at 2.3.x). Metro
**1.1.1** is the last release compatible with 2.3.20 (1.2.0+ needs
Kotlin 2.4.0).

### Gate — proven, not just green
- `assembleDebug` + `detekt` green under KSP 2.3.9.
- **32/32 `kspDebugKotlin` AND 32/32 `kspDebugUnitTestKotlin` executed
with 0 cached** (the version bump invalidated every KSP cache) →
existing **Hilt-Android codegen survives KSP 2.3.9 on every module**,
including the three apps' `@HiltAndroidApp` aggregation. This was the
migration's #1 unverified assumption.
- **1094 unit tests pass.** `detekt` clean across all 32 modules with
`config.validation: true` (rule is correctly registered).
- **Guard proven end-to-end:** a planted
`@Suppress("NO_ACTUAL_FOR_EXPECT")` fails `detekt` citing
`NoActualForExpectSuppressionRule`; green on removal. 8/8 unit tests
cover 4 positive + 4 negative controls.

### Not in this PR (C.1, on macOS for iOS/K-N compile)
- Room 2→3 migration — scoped as **CHUNKY** (driver now mandatory,
`@TypeConverter`→`@ColumnTypeConverter`,
`Migration.migrate(SQLiteConnection)`, driver-aware
`MigrationTestHelper`, explicit PagingSource converter). C.1 must also
add `room3-paging`, `room3-testing`, and the **room3 Gradle plugin
coordinate (unconfirmed on Google Maven — blocker)**.
- First Hilt→Metro DI slice — recommended target **`feature/archive`**
(off the Room track; exercises the representative cross-module
`@Singleton` bridge; the shared `core:ui:mvi` `hiltViewModel()`
resolution rework is the real first-slice prerequisite).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LdATiNmTB5zFWUWH2ZqJeK
Add rememberMetroStoreProcessor to core:ui:mvi as the transition-era twin
of the hiltViewModel-backed rememberStoreProcessor overloads. A migrated
feature supplies a factory lambda that resolves its Metro graph and builds
the BaseStore subclass; because BaseStore already IS an androidx ViewModel,
the Metro-created Store is retained directly via viewModel { initializer { } }
(NavBackStackEntry-scoped — the exact lifetime hiltViewModel gives today),
then handed to the existing backend-agnostic rememberStoreProcessor(StoreCreator)
overload so both paths share identical lifecycle/analytics/render-trace wiring.

Added alongside the Hilt path, not replacing it: the 12 non-migrated features
keep resolving their Store via hiltViewModel, untouched. core:ui:mvi stays an
Android module here; the KMP split (commonMain/androidMain/iosMain) lands in
C.1.2 where feature/archive must compile for iosSimulatorArm64.

Gate: full assembleDebug green; all 12 features' unit tests pass (593 tests,
0 failures); core:ui:mvi detekt (custom MVI rules) green, zero suppressions.
Blast radius CORE_ONLY — no feature-module changes forced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make :core:core a pure-Kotlin Kotlin Multiplatform module (commonMain +
iosSimulatorArm64), the foundation the archive iOS cascade needs. All Hilt
@modules and Android-framework impls move to a new :core:core-android module.

core:core (31 commonMain / 6 androidMain / 6 iosMain):
- Logger/Log (Kermit, already KMP), FirebaseEvent/EventsFilter, model/, result/,
  utils/, DurationFormat, AppCoroutineScope(Impl), the ResourceWrapper +
  AppReinitializer + PlatformInfoProvider + ImageStorage interfaces, ImageRef —
  all pure-Kotlin commonMain.
- expect/actual seams: FirebaseAnalyticsHolder + FirebaseCrashlyticsHolder
  (androidMain = real com.google.firebase.*, iosMain = no-op) and the 4 dispatcher
  qualifiers (androidMain actual carries @javax.inject.Qualifier so Hilt still
  recognizes them; iosMain actual is a plain annotation). 44 dispatcher consumers
  and 54 ResourceWrapper consumers keep their imports unchanged.
- CoroutineExt (asyncMap/asyncForEach/asyncScope/asyncAssociate) is commonMain:
  ConcurrentHashMap in asyncAssociate replaced with awaited-pairs .toMap()
  (KMP-safe, deterministic last-write-wins).

core:core-android (Android library, runs Hilt): the 3 Hilt @modules
(Core/Platform/ImageStorage) + Android impls (AndroidResourceWrapper,
AndroidTempFileProvider, AndroidPlatformInfoProvider, ImageStorageImpl,
AndroidAppReinitializer, RelativeTimeFormat) + the java.io.File-typed
TempFileProvider. Added to :app:app so its @modules aggregate into the app's
single Dagger graph. The 4 modules that reference its concrete Android symbols
(feature/home, feature/recovery, feature/settings, core/ui/test-utils) depend on
it directly. A KMP module cannot run the Hilt plugin, so this split is required.

Gate: assembleDebug BUILD SUCCESSFUL (345 tasks executed, app Hilt graph intact);
core:core compileKotlinIosSimulatorArm64 executed, zero NO_ACTUAL_FOR_EXPECT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…th intact)

First real per-feature Hilt→Metro flip, proving the repeatable pattern on one
feature. DI-axis only: archive stays an android-library composeLibrary — NO KMP,
NO iOS target, NO expect/actual. The other 11 features + app remain on Hilt via
the dual-path Store seam (rememberStoreProcessor / MetroStoreProcessor, acc5711).

Archive DI is now 100% Metro:
- ArchiveGraph: one @DependencyGraph(scope=ArchiveScope) with a @DependencyGraph.Factory
  taking the 8 app-scoped Hilt @singletons as @provides bound instances; the two @BINDS
  (Interactor, HandlerStore) migrated here from the deleted ArchiveModule.
- ArchiveHiltEntryPoint: @entrypoint @Installin(SingletonComponent) exposing those 8 deps.
  Consumed via EntryPointAccessors.fromApplication in ArchiveFeature; aggregates into the
  app Dagger graph automatically (same pattern as feature/recovery) — ZERO app changes.
- Handlers/Interactor/HandlerStore: @singlein(ArchiveScope) (== @ViewModelScoped); Store
  is unscoped, retained by the Android ViewModelStore via rememberMetroStoreProcessor.
- @DefaultDispatcher resolved on the Hilt side of the bridge; Metro receives one
  unqualified CoroutineDispatcher (Metro ignores javax.inject qualifiers).

Toolchain made Metro-aware (additive, dual-path-preserving — Hilt path unchanged):
- HiltScopeRule accepts @singlein as the @ViewModelScoped analogue (+ unit tests).
- detekt LongParameterList ignoreAnnotated += @DependencyGraph.Factory (the bridge's
  bound-instance surface is intrinsically one param per dep — no code @Suppress).

Gates (all EXECUTED, not cached):
- :feature:archive:compileDebugKotlin green; Metro codegen real (ArchiveGraph$Impl,
  $Factory$Impl, $BindsMirror, *$MetroFactory generated).
- clean :app:dev:assembleDebug green — Metro + Hilt coexist through @HiltAndroidApp merge.
- Full testDebugUnitTest green (11 Hilt features + app intact); archive tests 13/0/0.
- detekt green incl. custom MVI rules, zero suppressions.
- Bridge === proven at RUN on archive's real graph (ArchiveGraphBridgeTest, JUnit5, no
  Hilt-JUnit4 harness): AnalyticsHolder/LoggerHolder reach the Store as the same instances.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…cludeJavax)

The M0 bridge STRIPPED qualifiers — app-scoped deps flowed into the Metro graph by
type only. Fine for archive (one dispatcher), but a collision audit found 4 of the
remaining features (exercise, settings, single-training, recovery) bridge TWO same-typed
CoroutineDispatchers distinguished only by @DefaultDispatcher / @iodispatcher /
@MainImmediateDispatcher → under strip they'd arrive bare = duplicate binding / silent
wrong-dispatcher. This fix lands before any feature flip so the batch inherits it.

Fix: `metro { interop { includeJavax() } }` in the feature build. Metro then reads
javax.inject qualifiers directly, so the existing core:core @DefaultDispatcher /
@iodispatcher (expect/actual, @javax.inject.Qualifier) become real Metro binding keys —
Metro's key is (type + qualifier), so two same-typed dispatchers coexist and each resolves
by its qualifier. No core:core change, no new qualifier annotations, no lint change: the
same @DefaultDispatcher a feature already imports works on both the Hilt EntryPoint side and
the Metro graph side. (Chosen over dual-annotating core:core: strictly smaller blast radius,
same batch-scalability — every feature's metro block gets the one interop line.)

Applied to archive: @DefaultDispatcher restored (qualified) on the graph factory param and
the interactor consumer — no longer stripped.

Proof (QualifiedBridgeCollisionTest, focused two-dispatcher graph mirroring a colliding
feature): two @provides CoroutineDispatchers qualified @DefaultDispatcher + @iodispatcher,
consumed by two @Inject consumers — each resolves to its correct instance (=== identity, no
cross-wire) at both accessor and consumer level. The test compiling AT ALL proves includeJavax
registered the javax qualifiers (else duplicate-binding compile error). The strip-bug cannot recur.

Gates (all EXECUTED, not cached):
- :feature:archive:compileDebugKotlin green; Metro codegen real (ArchiveGraph$Impl/$Factory$Impl).
- clean :app:dev:assembleDebug green (383 tasks executed) — qualified bridge + includeJavax
  coexist through @HiltAndroidApp merge; dev-debug.apk produced.
- full testDebugUnitTest green (11 Hilt features + app intact); archive 14 tests incl. 2 bridge proofs.
- detekt green incl. custom MVI rules, zero suppressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…st AppScope on Handlers)

The M0 Metro-awareness (batch#... M0 5ec40cb) matched @singlein by NAME only
(annotationNames.contains("SingleIn")) — it never read the scope argument, so
@singlein(AppScope) on a *Handler would green-light. A Handler is per-screen,
feature-scoped, NEVER app-scoped; pinning one to the app singleton graph is a real
mis-scope the rule should catch.

Fix: on the Metro path, read the @singlein(<Scope>::class) argument and reject a
`*Handler` scoped to AppScope. Minimal + sound — a Handler-only AppScope blacklist,
NOT full per-feature scope-matching (deliberately out of scope: higher false-positive
risk mid-batch). referencesScope() strips "::class" and any package qualifier and
matches the scope simple name, so both `AppScope::class` and `pkg.AppScope::class`
are caught while a feature scope (`ArchiveScope::class`) passes.

VERIFICATION-FIRST: archive's 3 Handlers carry @singlein(ArchiveScope::class) (the
feature scope, not AppScope) → :feature:archive:detekt stays green. The fix is not
over-strict.

Tests (7, all green): Handler @singlein(<FeatureScope>) passes; Handler
@singlein(AppScope) fails; non-Handler @singlein(AppScope) NOT flagged (guard is
Handler-scoped); the Hilt-path guard (no @singlein → require @ViewModelScoped) still fires.

Bisect-green: :lint-rules:compileKotlin + full detekt both pass independently. No
feature touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…ualified dispatchers + Context bridge)

Lands the stopped commit #3 on the hardest collider (18 app-scoped bound instances —
most in the app), proving BOTH bridge mechanisms in situ on a real feature.

Flip (DI-axis only, exactly as archive M0):
- SettingsGraph: @DependencyGraph(scope=SettingsScope) + @DependencyGraph.Factory taking
  the 18 app-scoped @singletons as @provides bound instances; the 3 @BINDS (SettingsInteractor,
  BackupInteractor, SettingsHandlerStore) migrated from the deleted SettingsModule.
- SettingsHiltEntryPoint (@Installin SingletonComponent) exposes them; consumed via
  EntryPointAccessors in the rememberMetroStoreProcessor lambda. App-module untouched.
- Handlers @singlein(SettingsScope) (pass the commit#2 scope-arg rule); Store unscoped.

Two same-typed dispatchers (@DefaultDispatcher + @iodispatcher, both CoroutineDispatcher):
stay QUALIFIED into Metro via includeJavax (metro block) → (type + qualifier) keys → resolve
distinctly, never merge.

@ApplicationContext Context — the 3-point mechanic (Metro never sees the Hilt qualifier):
  1. EntryPoint accessor KEEPS @ApplicationContext (Hilt resolves the app Context).
  2. Graph factory param is PLAIN Context (no qualifier).
  3. BackupClickHandler consumer DROPS @ApplicationContext → plain Context.
Licensed by M2 Part A: every feature has ≤1 distinct app-scoped @ApplicationContext Context,
none activity-scoped — so binding one bare Context per graph is unambiguous (NOT the blind strip).

INTEGRATION PROOF (SettingsGraphBridgeTest, settings' REAL graph, 4/4 pass):
- the two qualified dispatchers resolve to their distinct === instances, no cross-wire;
- the bare Context reaches the graph as === the app Context;
- settingsStore constructs — transitively wiring the Context-consuming BackupClickHandler
  and both dispatcher-consuming interactors (whole 18-dep graph resolves live);
- bridged singletons reach the Store by identity.

Toolchain (additive, dual-path-preserving): detekt TooManyFunctions ignoreAnnotated +=
dagger.hilt.EntryPoint (a bridge EntryPoint has one accessor per bridged dep — intrinsic,
not a smell; no Hilt code carries @entrypoint). No code @Suppress.

Gates (all EXECUTED, not cached):
- :feature:settings:compileDebugKotlin green; Metro codegen real (SettingsGraph$Impl/$Factory$Impl/$BindsMirror).
- clean :app:dev:assembleDebug green — settings Metro + Hilt coexist through @HiltAndroidApp; dev-debug.apk produced.
- full testDebugUnitTest green (the other 11 Hilt features + app intact); settings tests incl. the 4 integration proofs.
- detekt green incl. custom MVI rules, zero suppressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…+ assisted Store)

Wave 1, flip 1 of 2. First ASSISTED-Store flip in the batch (archive/settings were
plain Feature). 14 app-scoped bound instances.

Assisted Store: ExerciseStoreImpl takes the Screen.Exercise route arg via @assisted, so
the graph exposes the assisted ExerciseStoreImpl.Factory as its root — NEVER the Store
(exposing an assisted type = [Metro/InvalidBinding]). dagger.assisted.* → dev.zacsweers.metro.*
(@AssistedInject/@Assisted/@AssistedFactory); the @AssistedFactory keeps
`Factory : StoreFactory<Screen.Exercise, ExerciseStoreImpl>` (SAM create(screen), Metro-compatible).
ExerciseFeature calls storeFactory.create(screen) inside the rememberMetroStoreProcessor lambda.

Dispatcher collision — @DefaultDispatcher + @MainImmediateDispatcher (both CoroutineDispatcher):
stay QUALIFIED via includeJavax. THIRD dispatcher qualifier proven — @MainImmediateDispatcher
is a javax @qualifier, rides the existing bridge, resolves to a distinct key.

@ApplicationContext Context (ClickHandler) — the 3-point mechanic (Metro never sees the Hilt
qualifier): EntryPoint accessor KEEPS @ApplicationContext → factory param PLAIN Context →
ClickHandler consumer DROPS @ApplicationContext. One bare Context per graph.

Standard flip: ExerciseGraph @DependencyGraph(scope=ExerciseScope) + Factory (14 bound
instances) + 2 @BINDS (ExerciseInteractor, ExerciseHandlerStore); ExerciseHiltEntryPoint via
EntryPointAccessors; 4 handlers @singlein(ExerciseScope); interactor + 4 usecases + HandlerStore
class-@Inject @singlein; Store assisted, unscoped; delete ExerciseModule. App-module untouched.

INTEGRATION PROOF (ExerciseGraphBridgeTest, exercise's REAL graph, 3/3):
- @default and @MainImmediate resolve to distinct === instances, no cross-wire (third-qualifier);
- bare Context reaches the graph as === the app Context;
- the assisted ExerciseStoreImpl.Factory is resolvable (whole 14-dep graph wired).

NO new config exemption needed — the 3 prior exemptions (LongParameterList/@DependencyGraph.Factory,
HiltScopeRule scope-arg, TooManyFunctions/@entrypoint) cover exercise. Toolchain patterns held.

Gates (all EXECUTED, not cached):
- :feature:exercise:compileDebugKotlin green; Metro codegen real (ExerciseGraph$Impl/$Factory$Impl/
  $BindsMirror + ExerciseStoreImpl$Factory$Impl — the assisted factory impl).
- clean :app:dev:assembleDebug green — exercise Metro + Hilt coexist through @HiltAndroidApp.
- full testDebugUnitTest green (other Hilt features + app intact).
- detekt green incl. custom MVI rules, zero suppressions. Bisect-green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
… Context, assisted Store)

Wave 1, flip 2 of 2. Pure dispatcher-collision flip — no Context (M2 Part A verified 0
sites). Assisted Store (Screen.Training route arg). 13 app-scoped bound instances.

Dispatcher collision — @DefaultDispatcher + @MainImmediateDispatcher (both CoroutineDispatcher):
stay QUALIFIED via includeJavax → two distinct (type + qualifier) binding keys, resolve
distinctly, never merge. Same third-qualifier path proven live in wave1a (exercise).

Assisted Store: dagger.assisted.* → dev.zacsweers.metro.* (@AssistedInject/@Assisted/
@AssistedFactory); graph exposes the assisted SingleTrainingStoreImpl.Factory (never the Store);
SingleTrainingFeature calls storeFactory.create(screen) in the rememberMetroStoreProcessor lambda.

Standard flip: SingleTrainingGraph @DependencyGraph(scope=SingleTrainingScope) + Factory (13
bound instances) + 2 @BINDS; SingleTrainingHiltEntryPoint via EntryPointAccessors; 4 handlers
@singlein(SingleTrainingScope); interactor + HandlerStore class-@Inject @singlein; Store assisted,
unscoped; delete SingleTrainingModule. App-module untouched; no Context mechanic (none needed).

INTEGRATION PROOF (SingleTrainingGraphBridgeTest, real graph, 2/2):
- @default and @MainImmediate resolve to distinct === instances, no cross-wire;
- the assisted SingleTrainingStoreImpl.Factory is resolvable (whole 13-dep graph wired).

NO new config exemption needed — the 3 prior exemptions cover it. Toolchain patterns held.

Gates (all EXECUTED, not cached):
- :feature:single-training:compileDebugKotlin green; Metro codegen real (SingleTrainingGraph$Impl/
  $Factory$Impl/$BindsMirror + SingleTrainingStoreImpl$Factory$Impl).
- clean :app:dev:assembleDebug green — 4 features now on Metro coexist with Hilt through @HiltAndroidApp.
- full testDebugUnitTest green (other Hilt features + app intact).
- detekt green incl. custom MVI rules, zero suppressions. Bisect-green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…sisted, simplest)

Wave 2 checkpoint, flip 1 of 3. Non-collider, ASSISTED Store (Screen.ExerciseImage),
the SIMPLEST graph in the batch — 4 bound instances (Navigator, StoreDispatchers,
AnalyticsHolder, LoggerHolder), no dispatcher, no Context, 1 @BINDS. Proves the
assisted-bulk flip is mechanical on a minimal live feature.

Mechanic = exercise template (04fba62): ImageViewerGraph @DependencyGraph(scope=
ImageViewerScope) exposing the assisted ImageViewerStoreImpl.Factory (never the Store);
ImageViewerHiltEntryPoint via EntryPointAccessors; 3 handlers @singlein; HandlerStore
class-@Inject @singlein; Store assisted, unscoped; delete ImageViewerModule. App untouched.

INTEGRATION PROOF (ImageViewerGraphBridgeTest, real graph): the assisted Factory is
resolvable from the 4 bridged singletons.

NO new config exemption. Gates all EXECUTED: compile (codegen real incl
ImageViewerStoreImpl$Factory$Impl), clean :app:dev:assembleDebug (5 features Metro coexist),
full testDebugUnitTest, detekt green. Bisect-green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…LAIN, bulk)

Wave 2 checkpoint, flip 2 of 3. Non-collider, PLAIN Store (@hiltviewmodel, no assisted —
a BottomBar destination with no route args) — the archive template on a live BULK feature.
Proves the plain-bulk flip is mechanical, not just the hand-built M0 archive. 8 bound
instances (ExerciseRepository, TagRepository, ResourceWrapper, Navigator, StoreDispatchers,
Analytics/Logger, @DefaultDispatcher), 2 @BINDS.

Mechanic = archive template (5ec40cb): AllExercisesGraph @DependencyGraph(scope=
AllExercisesScope) exposing the Store directly as val allExercisesStore (PLAIN — not a
factory); AllExercisesHiltEntryPoint via EntryPointAccessors; 3 handlers @singlein; interactor
+ HandlerStore class-@Inject @singlein; Store class-@Inject NO @singlein; delete
AllExercisesModule. Single @DefaultDispatcher qualified via includeJavax (no collision). App untouched.

INTEGRATION PROOF (AllExercisesGraphBridgeTest, real graph, 2/2): plain graph constructs
the Store from the 8 bridged singletons; bridged singletons reach the Store by === identity.

NO new config exemption (only import-ordering auto-fixed). Gates all EXECUTED: compile
(codegen real AllExercisesGraph$Impl/$Factory$Impl/$BindsMirror), clean :app:dev:assembleDebug
(6 features Metro coexist), full testDebugUnitTest, detekt green. Bisect-green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…sisted, @io)

Wave 2 checkpoint, flip 3 of 3. Non-collider, ASSISTED Store (Screen.PastSession), single
@iodispatcher (no collision). 9 bound instances (SessionRepository, SetRepository,
PersonalRecordRepository, ResourceWrapper, Navigator, @iodispatcher, StoreDispatchers,
Analytics/Logger), 2 @BINDS. Proves the assisted-bulk flip with a lone non-Default qualified
dispatcher (@io alone rides includeJavax and resolves under its qualifier — no collision needed).

Mechanic = exercise template (04fba62): PastSessionGraph @DependencyGraph(scope=PastSessionScope)
exposing the assisted PastSessionStoreImpl.Factory (never the Store); PastSessionHiltEntryPoint via
EntryPointAccessors; 4 handlers @singlein; interactor + HandlerStore class-@Inject @singlein; Store
assisted, unscoped; delete PastSessionModule. App untouched.

INTEGRATION PROOF (PastSessionGraphBridgeTest, real graph): the assisted Factory is resolvable —
proving the @IO-qualified graph wired all 9 bridged singletons.

NO new config exemption (only import-ordering + one unused-import auto-fixed — confirming
NoUnusedImports backstops the dupe-import gate). Gates all EXECUTED: compile (codegen real incl
PastSessionStoreImpl$Factory$Impl), clean :app:dev:assembleDebug (7 features Metro coexist),
full testDebugUnitTest, detekt green. Bisect-green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
Wave 3, flip 1 of 5. Non-collider, PLAIN Store (archive template, BottomBar destination),
single @DefaultDispatcher, 0 Context — matches the shape-map. 8 bound instances
(TrainingRepository, TagRepository, ResourceWrapper, Navigator, StoreDispatchers,
Analytics/Logger, @DefaultDispatcher), 2 @BINDS. Structurally identical to all-exercises (wave2b).

AllTrainingsGraph @DependencyGraph(scope=AllTrainingsScope) exposing the Store directly;
AllTrainingsHiltEntryPoint via EntryPointAccessors; 3 handlers @singlein; interactor +
HandlerStore class-@Inject @singlein; Store class-@Inject no @singlein; delete AllTrainingsModule.

INTEGRATION PROOF (real graph, 2/2): plain graph constructs the Store; bridged singletons
reach it by === identity. Map-match confirmed: 0 Context, @default only.

Gates all EXECUTED: compile (codegen real), clean :app:dev:assembleDebug (8 features Metro),
full testDebugUnitTest, detekt green. Bisect-green. NO new config exemption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
Wave 3, flip 2 of 5. Non-collider, PLAIN Store (archive template, BottomBar), single
@DefaultDispatcher, 0 Context — matches the shape-map. 9 bound instances (TrainingRepository,
SessionRepository, SessionConflictResolver, ResourceWrapper, Navigator, StoreDispatchers,
Analytics/Logger, @DefaultDispatcher), 2 @BINDS.

HomeGraph @DependencyGraph(scope=HomeScope) exposing the Store directly; HomeHiltEntryPoint via
EntryPointAccessors; 3 handlers @singlein; interactor + HandlerStore class-@Inject @singlein;
Store class-@Inject no @singlein; delete HomeModule. App untouched.

INTEGRATION PROOF (real graph, 2/2): plain graph constructs the Store; bridged singletons reach
it by === identity. Map-match confirmed: 0 Context, @default only.

Gates all EXECUTED: compile (codegen real), clean :app:dev:assembleDebug (9 features Metro),
full testDebugUnitTest, detekt green. Bisect-green. NO new config exemption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…fault)

Wave 3, flip 3 of 5. Non-collider, ASSISTED Store (Screen.ExerciseChart), single
@DefaultDispatcher, 0 Context — matches the shape-map. 8 bound instances (ExerciseRepository,
SessionRepository, ResourceWrapper, Navigator, StoreDispatchers, Analytics/Logger,
@DefaultDispatcher), 2 @BINDS.

ExerciseChartGraph @DependencyGraph(scope=ExerciseChartScope) exposing the assisted
ExerciseChartStoreImpl.Factory (never the Store); ExerciseChartHiltEntryPoint via
EntryPointAccessors; 3 handlers @singlein; interactor + HandlerStore class-@Inject @singlein;
Store assisted, unscoped; delete ExerciseChartModule. App untouched.

INTEGRATION PROOF (real graph): the assisted Factory is resolvable — 8 bridged singletons wired.
Map-match confirmed: 0 Context, @default only.

Gates all EXECUTED: compile (codegen real incl ExerciseChartStoreImpl$Factory$Impl), clean
:app:dev:assembleDebug (10 features Metro), full testDebugUnitTest, detekt green. Bisect-green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
Wave 3, flip 4 of 5. Non-collider, ASSISTED Store (Screen.PlanEditor), single
@DefaultDispatcher, 0 Context — matches the shape-map. 5 handlers. 8 bound instances
(ExerciseRepository, TrainingExerciseRepository, ResourceWrapper, Navigator, StoreDispatchers,
Analytics/Logger, @DefaultDispatcher), 2 @BINDS.

PlanEditorGraph @DependencyGraph(scope=PlanEditorScope) exposing the assisted
PlanEditorStoreImpl.Factory (never the Store); PlanEditorHiltEntryPoint via EntryPointAccessors;
5 handlers @singlein; interactor + HandlerStore class-@Inject @singlein; Store assisted, unscoped;
delete PlanEditorModule. App untouched.

INTEGRATION PROOF (real graph): the assisted Factory is resolvable — 8 bridged singletons wired.
Map-match confirmed: 0 Context, @default only.

Gates all EXECUTED: compile (codegen real incl PlanEditorStoreImpl$Factory$Impl), clean
:app:dev:assembleDebug (11 features Metro), full testDebugUnitTest, detekt green. Bisect-green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
, largest feature)

Wave 3, flip 5 of 5. The LARGEST feature — non-collider, ASSISTED Store (Screen.LiveWorkout),
single @DefaultDispatcher, 0 Context — matches the shape-map. 6 handlers + 2 feature-scoped
mappers (LiveSetMutator, StateStatusMapper) + ExercisePickerHandler, all @singlein(LiveWorkoutScope)
(feature-internal, Metro-constructed — NOT bridged). 13 app-scoped bound instances (7 repos:
Exercise/PerformedExercise/PersonalRecord/Session/Set/TrainingExercise/Training + ResourceWrapper +
Navigator + StoreDispatchers + Analytics/Logger + @DefaultDispatcher), 2 @BINDS.

LiveWorkoutGraph @DependencyGraph(scope=LiveWorkoutScope) exposing the assisted
LiveWorkoutStoreImpl.Factory (never the Store); LiveWorkoutHiltEntryPoint via EntryPointAccessors;
Store assisted, unscoped; delete LiveWorkoutModule. App untouched.

INTEGRATION PROOF (real graph): the assisted Factory is resolvable — 13 bridged singletons +
feature-scoped nodes wired. Map-match confirmed: 0 Context, @default only.

FULL :feature:live-workout:testDebugUnitTest (per the largest-feature requirement): 158 tests,
0 failures, 0 errors — ALL green.

Gates all EXECUTED: compile (codegen real incl LiveWorkoutStoreImpl$Factory$Impl), clean
:app:dev:assembleDebug (12 features Metro), full testDebugUnitTest, detekt green. Bisect-green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
…rd topology, Context Hilt-side)

Wave 4 dedicated slot — the third mount topology (AppFeature: root-mounted, Activity-scoped
ViewModelStore) with a secondary-constructor Context. DISCOVERY-FIRST resolved both unknowns
to a KNOWN mechanic before flipping (decision gate → PROCEED):

UNKNOWN 1 (AppFeature mount): the Metro seam applies AS-IS. AppFeature.createProcessor delegates
to the SAME no-Screen rememberStoreProcessor overload plain Feature uses; only the MOUNT SITE
differs (Activity ViewModelStore vs NavBackStackEntry), which is a caller concern (AppDialogHost),
unchanged by the DI flip. AppDialogStoreImpl is a PLAIN @hiltviewmodel → graph exposes the Store
directly (archive/all-exercises template). rememberMetroStoreProcessor retains it in whatever
LocalViewModelStoreOwner is current — the Activity's, at the root mount.

UNKNOWN 2 (secondary-ctor Context): DISSOLVES. AppDialogRepository is an app-scoped @singleton
that HILT constructs (via its secondary @Inject ctor with @ApplicationContext Context, on the Hilt
side, already working). It is BRIDGED into the Metro graph as a bound instance like any @singleton.
Metro NEVER constructs it → never sees the secondary ctor → no ctor-selection question, no 3-point
Context mechanic in the Metro graph. The Context stays 100% Hilt-side.

Flip = ViewModel tier only. AppDialogGraph @DependencyGraph(scope=AppDialogsScope) exposing the
Store directly; AppDialogsHiltEntryPoint bridges 5 @singletons (AppDialogRepository +
AppDialogObserverImpl [the concrete — ChooseHandler injects the impl, not the interface] +
StoreDispatchers + Analytics + Logger); 1 @BINDS (AppDialogHandlerStore); NO dispatcher, NO Context
in the graph. 2 handlers @singlein; HandlerStore + Store as usual. Removed only the ViewModel half
of the module; the SingletonComponent @BINDS (AppDialogPublisher / AppDialogObserver) stay in Hilt.

API CONTRACT PRESERVED: app-dialogs/api untouched; the SingletonComponent Publisher/Observer binds
untouched. Consumers (settings BackupClickHandler → AppDialogPublisher; recovery
RestoreDialogChoiceObserver → AppDialogObserver; app BaseApplication reactor) still wire — proven by
the clean assembleDebug.

INTEGRATION PROOF (AppDialogGraphBridgeTest, real graph, 2/2): plain graph constructs the Store;
bridged singletons reach it by === identity.

Gates all EXECUTED: compile (codegen real AppDialogGraph$Impl/$Factory$Impl/$BindsMirror), clean
:app:dev:assembleDebug (13 features Metro; api consumers intact through @HiltAndroidApp merge),
full testDebugUnitTest, detekt green. NO new config exemption. Bisect-green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gy6NNPqh1oeCZsVmpmyCG
… (leaf)

Stand up factory-shaped Metro AppGraph alongside Hilt (app-scope dual-path).
Migrate AnalyticsHolder to Metro single-owner (strip @Inject/@singleton);
Hilt-side readers resolve via delegating @provides reading AppGraph from a
Hilt binding via AppGraphOwner (never casting Application — HiltTestApplication
does not extend BaseApplication). @HasMemberInjections on BaseApplication for
Metro coexistence. Prod + test AppGraph via @TestInstallIn.

E proven: === across Metro-direct and Hilt-inject-via-adopt-back paths, on
emulator under @HiltAndroidTest; negative control (BaseApplication cast)
confirmed throwing ClassCastException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…2, additive)

Design B (plain bridge factory, not Metro assisted-injection): MetroWorkerFactory
implements androidx WorkerFactory, bridge-reads BackupWorker's 6 deps from Hilt via
a new BackupWorkerHiltEntryPoint (SettingsHiltEntryPoint bound-instance pattern) and
constructs BackupWorker directly. Metro never processes BackupWorker's constructor;
@HiltWorker + Dagger @AssistedInject stay untouched. Reuses the batch-bridge pattern
(Metro-reads-Hilt), not M3 adopt-back — BackupWorker is framework-constructed, not
graph-constructed.

Design A (Metro @AssistedInject on BackupWorker) rejected: double-processor collision
with Dagger @AssistedInject/@HiltWorker (would force @HiltWorker drop into Step 2), and
Metro assisted types cannot be scoped (wrong for a framework-constructed worker).

BackupWorkerHiltEntryPoint is transient scaffolding — retired at Step 6 when the
Configuration.Provider flip to MetroWorkerFactory removes all bridges. Factory is
DORMANT: zero main references, Configuration.Provider still returns Hilt's
HiltWorkerFactory, no runtime behavior change. Both in core/data/backup/worker
(BackupWorker is internal). Plain-instantiate at Step 6 (Context-only ctor) — no
AppGraph binding, no TestAppGraphModule mirror.

Verified vs Metro 1.1.1 + androidx.work 2.10.0: null-fallthrough sufficient (WorkManager's
createWorkerWithDefaultFallback handles reflection); FQN class-match mirrors HiltWorkerFactory.
Known-positive (constructs BackupWorker, 6 accessors invoked) + known-negative (null,
bridge untouched via != short-circuit) tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…s (Step 3 SB1 slice)

App-Scope Collapse Step 3 (bulk mechanical batch), first proven slice. Establishes the
mechanic for migrating app-scoped Hilt @singletons to Metro-owned, and resolves a blocker
the spec's 'app/app @provides' model hit: ~13 of the bulk impls are internal to their core
module, and the internal AppGraph in app/app cannot reference them.

Mechanic (visibility-respecting): @ContributesBinding(AppScope) + @singlein(AppScope) +
@Inject ON THE IMPL in its own module. Metro's @DependencyGraph(AppScope::class) auto-
aggregates all AppScope contributions, so app/app never references the internal impl.
Verified against Metro 1.1.1 (DependencyGraph auto-aggregation).

This slice migrates NumUiUtils (a clean binding — no app-scope Hilt consumer, so no adopt-
back @provides needed) and stands up the shared prerequisites:
- AppScope declared in core:core-android (an Android-only com.android.library) and public.
  NOT in core:core: that is a live KMP module whose commonMain compiles to iosSimulatorArm64,
  so an app-scope DI marker there would leak Android-DI scaffolding into the iOS binary. core:
  core-android is Android-only and low in the graph (depends only on core:core), so every bulk
  contributor can depend on it without a cycle; it shares the  package with the dispatcher
  qualifiers, so consumer imports are unchanged.
- Metro plugin + interop { includeJavax() } applied to core:ui:kit (composeLibrary+metro is
  the proven combo used by the flipped features); core:ui:kit gains a core:core-android dep.
- NumUiUtilsImpl: Hilt @Inject/@singleton stripped, its @BINDS removed from CoreUiKitModule,
  @ContributesBinding/@SingleIn/@Inject added.
- AppGraph: no per-binding @provides — the graph auto-merges the contribution; a numUiUtils
  accessor is kept for identity tests.

DORMANT/dual-path intact: @HiltAndroidApp untouched; Hilt still owns every not-yet-migrated
binding. Reversible via git revert. :app:dev:assembleDebug + detekt green; core:core still
compiles for iosSimulatorArm64 (AppScope no longer in the iOS surface).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
… AppScope (Step 3 B.1)

App-Scope Collapse Step 3 false-green guard. Metro's @ContributesBinding(scope = KClass<*>)
accepts any class, so a wrong scope compiles green but the binding silently fails to
aggregate into the app-scope AppGraph (@DependencyGraph(scope = AppScope::class)) — absent
at runtime with no compile signal. Same soundness class as the pre-d1cb7965 HiltScopeRule
gap (which validates @singlein scope on Handlers only and has zero @ContributesBinding coverage).

ContributesBindingScopeRule flags any @ContributesBinding whose scope arg is not the project
AppScope (io.github.stslex.workeeper.core.core.di.AppScope). Two failure modes caught:
- scope simple name != AppScope (a feature scope, typo, other marker); and
- AppScope imported from dev.zacsweers.metro — Metro's BUILT-IN app scope, same simple name
  but a different class from the project token the AppGraph is scoped to, so a contribution
  to it would not aggregate. PSI has no type resolution, so the import origin discriminates.

Proven on known-NEGATIVE anchors (wrong scope, Metro's built-in AppScope, missing arg — all
compile green today, all now flagged) AND known-POSITIVE (project AppScope passes). The
existing NumUiUtilsImpl (SB1, correct AppScope) is not flagged; :app:dev:assembleDebug +
full lint-rules suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…p 3 D2)

Repairs a pre-existing latent defect: AppGraphAdoptBackModule.provideAppGraph did
(context as AppGraphOwner).appGraph. Prod BaseApplication implements AppGraphOwner, but
the Hilt instrumented harness swaps in HiltTestApplication, which does not — so the cast
threw ClassCastException in every flavor @HiltAndroidTest resolving a migrated binding at
startup. app:dev / app:store run their own HiltTestApplication + Regression suites and
(being separate modules that can't see app:app internals) could not @TestInstallIn-replace
the module. The leaf/SB1 === was only ever validated in app:app's own androidTest — where a
whole-module @TestInstallIn replaced AppGraphAdoptBackModule with a HAND-COPIED double, so
the === proved the copy, not the production shim. The flavor Regression suites were RED.

Fix (Option 3 — isolate the cast + fallback-build):
- New AppGraphSourceModule: the SINGLE provider of the AppGraph binding. It returns the
  BaseApplication-held graph when the Application is an AppGraphOwner (prod), else builds the
  REAL AppGraph from applicationContext (test) — the same createGraphFactory(...).create(...)
  BaseApplication performs, so zero test-double drift and no cast crash. No per-flavor test
  wiring: all three flavors' HiltTestApplication tests work unchanged.
- AppGraphAdoptBackModule now holds ONLY the adopt-back shims (unchanged bodies), consuming
  the AppGraph binding. It is NEVER @TestInstallIn-replaced, so tests exercise the REAL shims.
- app:app's TestAppGraphModule now @TestInstallIn-replaces ONLY AppGraphSourceModule (not the
  whole adopt-back module), so its own seam test also exercises the real shim now.

GATE (upgraded): app:dev:assembleDebug green AND the flavor instrumented Regression suite runs
GREEN on-device — 6/6 (ApplicationBottomBarTest.appStartInitial, which crashed at baseline,
now passes). app-assemble is green-blind to the runtime cast; the on-device flavor Regression
gate is mandatory for every adopt-back commit from here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…1 mvi slice) + seam proof

App-Scope Collapse Step 3, first ADOPT-BACK SHIM + COLLIDER slice (NumUiUtils was clean —
no shim, no collider). core:ui:mvi gains the Metro plugin + includeJavax.

- LoggerHolder + StoreDispatchers: Hilt @Inject/@singleton stripped; now @singlein(AppScope)
  @Inject. NOT @ContributesBinding — both are concrete self-bound classes with no supertype,
  and @ContributesBinding binds to a supertype (verified against Metro 1.1.1). Instead the
  AppGraph exposes val loggerHolder / val storeDispatchers accessors, pulling the scoped
  @Inject classes into the graph as retained singletons.
- Adopt-back shims (provideLoggerHolder / provideStoreDispatchers) in AppGraphAdoptBackModule
  delegate to the graph accessors — single-owner, the 13 *HiltEntryPoint readers + BaseStore
  ctor param resolve through them. 13 readers = 1 @provides each.
- COLLIDER: StoreDispatchers ctor-needs @DefaultDispatcher + @MainImmediateDispatcher (still
  Hilt-owned this layer), bridged into AppGraph.create() as qualified bound instances
  (includeJavax carries the qualifiers). AppGraphSourceModule (prod) + BaseApplication pull
  them from Hilt via a DispatcherBridgeEntryPoint; retired when core:core-android migrates.

SEAM PROOF (on-device, the point of this slice — NumUiUtils skipped it):
- POSITIVE: LoggerHolder resolved via a Hilt EntryPoint AND the Metro graph accessor → SAME
  instance (=== not ==) through the REAL shim (not a test double, post-D2-decouple).
- NEGATIVE CONTROL: casting Application→BaseApplication still throws ClassCastException under
  HiltTestApplication — preserved, shown failing (guards against false-green).
- COLLIDER: both dispatchers resolve with correct qualifier, === the Metro-owned instance.

GATE: :app:dev:assembleDebug + detekt green; app:app seam suite 6/6 on-device; the flavor
(app:dev) instrumented Regression 6/6 on-device (ApplicationBottomBarTest resolves the
migrated bindings at Home startup through the real shims).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
… (Step 3 B3 hygiene)

Two folded cleanups (from the B2-verify pass), before the ImageStorage migration multiplies
the create() param surface.

V.2 — single construction site: extract buildAppGraph(applicationContext, dispatchers...) that
BOTH BaseApplication.appGraph and AppGraphSourceModule's test fallback call. Previously each
threaded the create() argument list independently — a two-place sync obligation that would grow
with every bridged input (dispatchers now, DAOs later). Now a new create() param is added in one
place. No behavioral change: same factory call, same graph.

V.1 — retarget the negative control: the decouple replaced the Application cast with an
 check, so the old  assertion no longer mirrored
production. Retargeted to assert HiltTestApplication !is AppGraphOwner — the actual invariant the
fallback branch depends on (if the test app WERE an owner, the real-graph-build branch would never
run: a false-green). Still a real tripwire.

GATE: :app:dev:assembleDebug + detekt green; app:app seam suite 6/6 on-device (incl. the retargeted
negative control); app:dev flavor Regression 6/6 on-device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…ributesBinding + shim)

App-Scope Collapse Step 3. Closes the previously-unproven mechanic cell: @ContributesBinding
(interface-bound) WITH an adopt-back shim, end-to-end on-device.

Resolves D1 per the B4 spike: cross-Gradle-module @ContributesBinding requires a PUBLIC impl.
Metro 1.1.1 rejects an internal contribution the app graph would extend from another module
(MergedContributionChecker.isInternalInFriendModule); @PublishedApi and both @ContributesTo-
container shapes also fail — only public aggregates. So BackupPreferencesRepositoryImpl is
widened internal->public. The widening is narrow: its override methods were already public via
the interface; only the class + constructor visibility change, no behavior change.

- BackupPreferencesRepositoryImpl: Hilt @Inject/@singleton stripped -> @ContributesBinding(AppScope)
  + @singlein(AppScope) + @Inject; @ApplicationContext dropped -> plain Context (Metro constructs
  it, resolving Context from the graph's create(applicationContext) bound instance). Its @BINDS
  removed from BackupSchedulingModule (RestoreStateRepository's @BINDS stays).
- core:data:backup:scheduling gains the Metro plugin + includeJavax + core:core-android dep (AppScope).
- AppGraph: backupPreferencesRepository accessor. AppGraphAdoptBackModule: one adopt-back @provides
  delegating to it (single-owner). SettingsHiltEntryPoint + BackupWorkerHiltEntryPoint resolve through it.

ContributesBindingScopeRule (e177a19) fired live for the first time on real code and passed
(correct project AppScope). Seam proof on-device (app:app 7/7): backupPreferencesRepository ===
positive via BOTH Hilt EntryPoint readers (Settings + Worker) resolving the Metro-owned instance;
negative control holds. NOTE the Worker leg is a plain Hilt EntryPoint (Step-2 MetroWorkerFactory
dormant) — NOT a live Metro-bridge cross-check. app:dev flavor Regression 6/6 on-device (appStartInitial
resolves the migrated binding through the real shim). detekt + :app:dev:assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…atable @ContributesBinding)

App-Scope Collapse Step 3, FLIP-A batch L0 — first multi-supertype binding.

ActivityHolderImpl implements TWO interfaces (ActivityHolder + ActivityHolderProducer). Metro's
@ContributesBinding is @repeatable → applied twice with explicit binding<ActivityHolder>() /
binding<ActivityHolderProducer>() (the ambiguity-resolving form for a multi-supertype impl,
verified in the Metro 1.1.1 clone). One @singlein(AppScope) ActivityHolderImpl backs both types.

- Hilt @Inject/@singleton stripped (impl already public — no widening). Both @BINDS removed from
  CoreUiKitModule (ResourceManager @BINDS kept — it's L1, migrates later).
- AppGraph: activityHolder + activityHolderProducer accessors. AppGraphAdoptBackModule: one adopt-back
  @provides per type, both delegating to the same graph-owned instance.

MANIFEST CORRECTION caught at migration time: the manifest listed ActivityHolder shim=0, but
ResourceManagerImpl (still-Hilt, L1) injects ActivityHolder — so it DOES need a shim. Both types shimmed
(ActivityHolder<-ResourceManagerImpl, ActivityHolderProducer<-MainActivity). Shim consumers are re-verified
per-binding, not batch-trusted.

ContributesBindingScopeRule passed on BOTH repeatable @ContributesBinding (first repeatable firing). Seam
proof app:app 8/8 on-device (activityHolder test: both bound types + both Hilt shims === the ONE impl).
app:dev flavor Regression 6/6 on-device. detekt + :app:dev:assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
…zer to Metro (Step 3 L0)

App-Scope Collapse Step 3, FLIP-A L0 — core:core-android platform providers (3 interface-bound impls).
core:core-android gains the Metro plugin + includeJavax (it already DECLARES AppScope, so no extra dep;
Metro coexists with its Hilt CoreModule dispatchers). Each impl: Hilt @Inject/@singleton stripped,
widened internal->public, @ContributesBinding(AppScope)+@singlein+@Inject, @ApplicationContext dropped to
plain Context. PlatformModule (all 3 @BINDS migrated) deleted. 3 AppGraph accessors + 3 adopt-back @provides.

Shim consumers (re-verified per-binding, not manifest-trusted): PlatformInfoProvider<-RestoreRecoveryCoordinator
+SettingsHiltEntryPoint; TempFileProvider<-SettingsHiltEntryPoint (manifest said 0 — corrected);
AppReinitializer<-NavigatorEventBus+RestoreRecoveryCoordinator.

detekt + assembleDebug green. Seam 9/9 on-device (platformBindings test: all 3 Hilt adopt-back === Metro).
app:dev flavor Regression 6/6 on-device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
App-Scope Collapse Step 3, FLIP-A L0 — core:data:backup:scheduling RestoreStateRepository. Interface-bound:
Hilt @Inject/@singleton stripped, widened internal->public, @ContributesBinding(AppScope)+@singlein+@Inject,
@ApplicationContext dropped to plain Context. BackupSchedulingModule deleted (empty — both its bindings,
BackupPreferences + RestoreState, now Metro). AppGraph accessor + adopt-back @provides.

Shim consumers (re-verified): RestoreDialogChoiceObserver + RestoreRecoveryCoordinator (recovery Hilt
@singletons) + SettingsHiltEntryPoint. detekt + assembleDebug green (fixed a MaxLineLength on the shim
KDoc). Seam 10/10 on-device. app:dev flavor Regression 6/6 on-device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MM6EcW9qSEjvUqMaZBSAPa
stslex and others added 14 commits July 25, 2026 21:17
The port in 8f2fb43 left SingleTrainingDeps present-but-unused so that SHA stayed
independently valid. Nothing reads it now, so it goes with its entry in AppGraph's
supertype list.

THREE accessors orphaned — the biggest batch so far, and the first deletion where a
count would have been badly wrong:

  * @MainImmediateDispatcher — SingleTrainingDeps was its last declarer. Live root:
    both SingleTrainingExtensionIdentityTest and ExerciseExtensionIdentityTest read it
    to assert the qualified pair stays distinct.
  * tagRepository — last declarer, no remaining reader, genuinely inert for now.
  * sessionConflictResolver — last declarer. Live root: the single-training identity
    test reads it to assert the extension inherits the parent's instance rather than
    building its own double.

All three become plain `val`s with the reason recorded; deletion stays deferred to the
final cleanup commit. Running ledger: past-session orphaned 1, exercise-chart 0,
exercise 1, single-training 3. That is 5 orphans from 4 deletions with no pattern
predicting the count — which is exactly why the cleanup commit must ENUMERATE what
remains rather than assume, and why "orphaned" must not be read as "dead": 4 of the 5
are still live roots.

6 XxxDeps supertypes -> 5, counted by reading the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…y its own test case

FORCED-PUBLIC: predicted 23, measured 23 (prediction committed cf21dbd before round
1). Widest port of the arc. All five side-claims held. 23 forced, 0 over-widened, 0
stale, control green first.

One identity assertion had to be rewritten before it meant anything, and it was GREEN
while it was meaningless. The session-subsystem test first read

    assertSame(appGraph.sessionConflictResolver, appGraph.sessionConflictResolver)

comparing the parent's accessor to ITSELF. It never touched the extension and would
pass regardless of what the extension resolved. A self-comparing assertion is the
adjacent-answer class in test form: it answers "is this accessor stable" instead of
"did the extension inherit it", and both readouts are a green tick. Fixed by adding a
sessionConflictResolver observability accessor to the extension, and proven RED against
a DIFFERENT AppGraph's resolver.

BINDING MECHANISM: REFUTED.

The prediction logged in cf21dbd said ~+0.10 confirms and ~+0.03 kills. N=11
(single-training, 13 bindings) came in at +0.018s. Killed — by the port chosen in
advance to test it, against a number written before the port existed.

  exercise-chart    N=9    8 bindings   +0.027
  past-session      N=8    9 bindings   +0.060
  single-training   N=11  13 bindings   +0.018
  exercise          N=10  14 bindings   +0.115

Monotonic in bindings: no. Monotonic in N: no. single-training and exercise are
ADJACENT binding counts whose deltas differ 6x, which is exactly what the hypothesis
forbade. The n=3 monotonicity was coincidence; three points can be ordered by many
variables and the fourth killed it.

WHAT SURVIVES, and a flaw in how it was measured. All four deltas are positive (sign
test p = 1/16), mean +0.055s, cumulative N=7->N=11 +0.220s. But in every one of the
four pairs the NEW row was measured FIRST and the control SECOND — a fixed order, not
an alternating one. Any systematic within-session drift therefore biases every delta
the SAME way: if the machine speeds up as a session proceeds, the first-measured row is
systematically slower and every delta is biased positive. Four positive deltas is
exactly that artefact's signature. Reversed order protects the N axis from cross-row
drift; it does nothing about within-pair order bias when the order never changes.

So "all four positive" is NOT evidence of a per-extension cost — it is equally well
explained by the order that produced it. Fix for future pairs: alternate the order
between ports, or bracket (control, new, control).

The binding-axis re-run is moot, the axis being refuted. A full single-session
re-baseline is now MORE valuable, as the only way to separate a small genuine
per-extension cost from a fixed-order artefact — and it must use alternating or
bracketed order, not the fixed new-first order used so far.

Status: 11 of 13 ported, 5 XxxDeps supertypes, StoreFactory down to 1 user.
live-workout is the last feature graph — port 13 — so the ENDPOINT build-time reading
is taken there, per the rule logged in cf21dbd.

8f2fb43 wired into SERIES; N-column check now 1,2,3,4,5,6,7,7,8,9,10,11,11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…live-workout's prediction

Timestamped ahead of the port it judges. No source file touched.

WITNESSES 11-13 recorded. 12 and 13 are the two worst so far, because each was created
by a fix for an earlier one:

  11. Stale case regex in the falsifier — the mutation never applied because the real
      declaration carried its supertype inline. Caught only because the harness verifies
      the edit landed and refuses to score un-applied cases.
  12. Fixed within-pair measurement order. Reversed order was introduced to stop
      cross-row drift faking an N-slope, and it does that — but the order INSIDE each
      pair was then identical every time, so any within-session speedup biases every
      delta positive. Four positive deltas were that artefact's fingerprint, not a
      per-extension cost. A fix for a confound is itself a design decision that can
      introduce one: re-run the recognition test on the FIX, not just on what it fixed.
  13. Self-comparing identity assertion — assertSame(parent.x, parent.x) inside the very
      instrument that proves extension correctness.

IDENTITY-TEST AUDIT: no other instance. Every other assertSame across the eleven
identity tests has one operand from the extension or the store. Two look similar and
are correct — ImageViewer's assertNotSame(a, b) compares two extension-built stores, and
AppGraphIdentityTest's assertSame(first, second) compares two reads of
graph.analyticsHolder, parent-side on both sides but making a genuinely parent-side
stability claim.

The lesson generalises: assertSame(parent.x, parent.x) is VALID for a stability claim
and VACUOUS for an inheritance claim. Identical code, different claim — so this defect
cannot be found by pattern matching, only by reading each assertion against the sentence
it is meant to prove.

BRACKETING replaces alternating as the fix for witness 12. Alternating between ports
merely averages the bias out and never shows it; bracketing (control, new, control)
measures within-pair drift directly as the gap between the two controls, so it can be
reported and subtracted. After two hidden confounds, a design that SHOWS drift beats one
that blurs it.

LIVE-WORKOUT PREDICTION: 24 = 6 plumbing + 2 interactor + 15 domain + 1 UI. Would be the
widest port of the arc. Domain 15 is every declaration; the closure runs four levels down
the SessionSnapshotDomain spine, the deepest chain in the arc.

Six falsifiable side-claims, the sharpest being that BottomSheetState costs ZERO while
DialogState costs 1 — two sibling state types in the same directory, both reachable from
State, differing only in declared visibility. Also: ErrorType and LiveSessionStateUiModel
stay internal, nested DraftKey/PrSnapshotItem need no edits, no sealed subtype needs one,
and all 7 handlers plus 5 mappers stay internal (the largest handler set in the arc).

This port carries the ENDPOINT build-time reading per the port-13 rule, measured with
BRACKETED order. Prediction for the bracket: the two control readings will differ and the
drift-corrected delta will be smaller than the raw one. Controls differing in the
inflating direction confirms witness 12; controls agreeing closely while the delta
persists puts a small real per-extension cost back on the table.

Measuring BELOW 24 is a STOP, not a win.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…sion (shape B, 13/13)

THE LAST FEATURE GRAPH. LiveWorkoutGraph becomes a @GraphExtension(LiveWorkoutScope)
whose Factory carries @ContributesTo(AppScope). The 13 hand-threaded bound instances
are gone. With this port StoreFactory has NO users left and dies with the closing
commits.

FORCED-PUBLIC: predicted 24, measured 24 — the widest port of the arc. Prediction
committed in 92230fd BEFORE the first widening round.

  6 plumbing + 2 interactor + 15 domain + 1 UI

Domain 15 is every domain declaration. Signatures gave nine; closure added six, almost
all down the SessionSnapshotDomain spine — .session -> SessionDomain -> .state ->
SessionStateDomain, and .exercises -> LiveExerciseDomain -> .performed ->
PerformedExerciseDomain / .performedSets -> SetDomain, plus PersonalRecordDomain.type
-> SetTypeDomain. Four levels from one signature type, the deepest closure chain in the
arc, and the compiler walked it in exactly the predicted order.

All six side-claims held. The sharpest is the first: BottomSheetState cost ZERO while
DialogState cost 1. Two sibling sealed state types, same directory, both reachable from
State, differing only in declared visibility — the clearest demonstration available that
the UI term is about how a type happens to be declared, not about what kind of thing it
is. Fifth port, fifth UI cost (0, 6, 1, 1, 1). Also held: ErrorType and
LiveSessionStateUiModel stayed internal, nested DraftKey/PrSnapshotItem needed no edits,
no sealed subtype needed one, and all 7 handlers plus 5 mappers stayed internal — the
largest handler set in the arc and the strongest test yet of the internal-constructor
mechanism.

All 24 falsified individually: 24 forced, 0 over-widened, 0 stale. Known-negative
control (LiveWorkoutScope) validated for this feature and green before any positive.

STANDING RULE 4: this was the final boundary candidate — it owns the session WRITE path
(start, add-exercise, finish, cancel, discard-adhoc) and inherits the deepest
transactional stack of any extension. Construction succeeds off-device, so the direct
claim is made. NO feature in the arc turned out to need the boundary form; the rule
stays on the books for MetroWorkerFactory, which is not a feature graph.

IDENTITY TEST: six claims, all green, and every assertSame has one operand from the
EXTENSION — deliberately, after witness 13. Two anchors proven RED, each breaking only
its own test: comparing the session repository against a DIFFERENT AppGraph fails the
write-path test, and swapping the graph's dispatcher qualifier fails the dispatcher
test.

The route arg is the widest in the arc — TWO nullable uuids of which at least one is
non-null — so both legal arms are asserted separately. A bound instance of a nullable
type is where a graph could substitute a default, and here that would silently pick the
wrong entry mode.

ScreenInjectionRule: Screen.LiveWorkout is a flat 2-level data class, the already-proven
shape. detekt executed (not FROM-CACHE), zero suppressions.

LiveWorkoutDeps is left present-but-unused so this SHA is independently valid; deleted
in the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
… feature bridge gone

The port in ee95937 left LiveWorkoutDeps present-but-unused so that SHA stayed
independently valid. Nothing reads it now, so it goes with its supertype entry.

This is the LAST feature-graph bridge interface. AppGraph's supertype list is now
StoreCoreDeps, NavigatorDeps, RecoveryDeps, BackupWorkerDeps — and of those only the
first two are the load-bearing gamma-spine the arc always said it bottoms out at.
RecoveryDeps and BackupWorkerDeps are not feature-graph bridges.

NINE accessors orphaned, by far the biggest batch: defaultDispatcher, resourceWrapper,
exerciseRepository, sessionRepository, setRepository, personalRecordRepository,
performedExerciseRepository, trainingExerciseRepository, trainingRepository. All become
plain `val`s with `override` dropped; none is deleted, because the bulk accessor cleanup
is the second closing commit.

That completes the orphan ledger and settles the enumeration rule empirically:

  past-session 1, exercise-chart 0, exercise 1, single-training 3, live-workout 9

Fourteen orphans from five deletions, with no pattern predicting the next — the last
one alone is nine, after three deletions that produced two between them. Any cleanup
driven by an assumed count would have been badly wrong here. And "orphaned" still does
not mean "dead": several are live roots — imageStorage via
BaseApplication.cleanupOrphanedImageTempFiles, and sessionConflictResolver, the
dispatchers and the repositories via the :app identity tests.

The ledger and that conclusion are recorded as a comment on AppGraph itself, where the
cleanup commit will be written, rather than only in the arc HANDOFF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…nd a count correction

FORCED-PUBLIC: predicted 24, measured 24 (prediction committed 92230fd before round 1).
Widest port of the arc. All six side-claims held, including the sharp one:
BottomSheetState cost ZERO while its sibling DialogState cost 1 — two sealed state
types in the same directory, both reachable from State, differing only in declared
visibility. Fifth port, fifth UI cost (0, 6, 1, 1, 1). 24 forced, 0 over-widened, 0
stale.

BRACKETED BUILD-TIME READING — the first taken as control, NEW, control:

  control A (1st)  N=11   1.004  [0.965, 1.143]
  NEW      (2nd)   N=12   0.989  [0.967, 1.133]
  control B (3rd)  N=11   0.981  [0.915, 1.130]

  within-pair drift (ctlB - ctlA) : -0.023s   MEASURED, not assumed
  raw delta (new - ctlA)          : -0.015s
  drift-corrected                 : -0.003s
  three-way ranges                : OVERLAP

Two findings, both produced by the bracket.

1. Adding the twelfth extension costs nothing measurable: -0.003s corrected, ranges
   overlapping. The earlier positive deltas are not a compounding per-extension cost.

2. WITNESS 12 IS CONFIRMED, quantitatively. The bracket measured the machine getting
   FASTER within the pair (-0.023s). That is exactly the direction that inflates a
   new-measured-first delta: the new row ran on the slower early machine and its control
   on the faster later one, biasing new-minus-control positive by roughly the drift
   magnitude. The four earlier deltas (+0.018, +0.027, +0.060, +0.115, mean +0.055)
   minus a ~0.023 order bias puts three of four at or below the 0.045 yardstick. The
   prediction in 92230fd — controls differ, corrected < raw, drift in the inflating
   direction — landed on all three counts.

CORRECTION, and it is mine. live-workout is port 12 of 13, NOT 13 of 13. Commit
ee95937 is mislabelled "13/13" and "THE LAST FEATURE GRAPH" in both subject and body.
It is the last ASSISTED feature and the last feature-graph bridge interface, but
app-dialogs is still a @DependencyGraph and is genuinely the thirteenth. Measured:
12 @GraphExtension, 1 @DependencyGraph remaining.

I inherited "port 13" from the brief instead of grepping — the count class again,
committed inside the arc that carries a "grep, not memory" inventory section, and after
the same class had already been recorded twice. Not rewritten, per no-rebase; the
correction lives in Status and in the endpoint section.

CONSEQUENCE: the ENDPOINT reading has not been taken. The bracketed reading above is
N=11 -> N=12. The true endpoint is N=13 and, per the port-13 rule, must be measured as
part of the app-dialogs port, before the closing commits.

Status: 12 of 13 ported, 4 supertypes left on AppGraph (only StoreCoreDeps +
NavigatorDeps are the arc's gamma-spine; RecoveryDeps and BackupWorkerDeps are not
feature bridges), StoreFactory has NO users left. All seven FeatureAssisted users are
done. app-dialogs is the only remaining feature graph.

ee95937 wired into SERIES; N-column check now 1,2,3,4,5,6,7,7,8,9,10,11,12,12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
Surveyed before touching source, per the instruction to verify shape B applies rather
than assume the assisted pattern transfers. It applies, with one real divergence that
is a deletion opportunity rather than an obstacle.

TRANSFERS UNCHANGED. No route arg means the NO-ARG factory form,
createAppDialogGraph() with zero @provides — not new, it is exactly what the five plain
Feature<P,S> ports used, so shape B's no-arg variant is already proven 5x. And the
Store is ALREADY plain @Inject: AppDialogStoreImpl was never assisted, so there is no
assisted machinery to strip. Smallest graph in the arc — 5 factory params, 1 @BINDS,
2 handlers.

THE DIVERGENCE: this port kills a SECOND seam. app-dialogs/impl carries
AppDialogInternalsHolder + Context.appDialogInternals(), implemented by
BaseApplication, which exists because AppDialogRepository and AppDialogObserverImpl are
impl-owned concrete types no other module can name — so they reach the feature graph
through an Application-implements-holder trick instead of appDeps<T>().

Once app-dialogs is a contributed @GraphExtension that seam is dead: the extension
inherits every app-scoped binding from AppGraph, which already exposes both types as
accessors, and the seam's only consumer is AppDialogFeature. So this port also removes
the holder interface, the accessor function, and BaseApplication's supertype plus two
get() overrides — the analogue of an XxxDeps deletion, but of a different seam type.

WHAT IS NOT AFFECTED, so the closing commits do not over-delete: AppDialogPublisherHolder
+ Context.appDialogPublisher() in the API module STAYS. It serves cross-module consumers
(settings / recovery / archive), unrelated to this feature's own graph. Two
similarly-named seams, only one dead. Also unaffected: app-dialogs is the only feature
that CONTRIBUTES app-scoped bindings (AppDialogRepository is @singlein(AppScope), the
observer and publisher are @ContributesBinding) — the port touches only its
feature-scoped graph.

PREDICTION: 6 = 6 plumbing + 0 interactor + 0 models. The smallest port of the arc,
tying image-viewer, and a bookend — the widest (live-workout, 24) immediately before the
narrowest.

Six falsifiable side-claims: zero interactor pairs (no interactor exists; the domain
layer is one internal object); zero models (AppDialog / AppDialogUserAction /
AppDialogUserChoice live in the api module and are already public cross-module);
AppDialogResolver and AppDialogKeys stay internal; both handlers stay internal;
AppDialogsScope stays internal; and AppDialogRepository / AppDialogObserverImpl need no
visibility change at all, being inherited rather than exposed.

Measuring BELOW 6 is a STOP, not a win.

This port carries the ENDPOINT reading (N=13), bracketed, cold-calibration logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…ion (13/13 — ALL PORTED)

THE THIRTEENTH AND LAST FEATURE GRAPH. Every feature graph in the repo is now a
contributed @GraphExtension; no @DependencyGraph remains under feature/.

AppDialogGraph becomes a @GraphExtension(AppDialogsScope) whose Factory carries
@ContributesTo(AppScope). The 5 hand-threaded bound instances are gone.

SHAPE, surveyed before porting (4efb90d) rather than assumed. app-dialogs is
screen-less (AppFeature<P>, not FeatureAssisted), so the creator takes NO arguments:
createAppDialogGraph(). That is not a new shape — it is exactly the no-arg form the five
plain Feature<P,S> ports used. And its Store was already plain @Inject, so there was no
assisted machinery to strip. The pattern transferred with nothing invented.

THE REAL DIVERGENCE, and it is a deletion: this port retires the
AppDialogInternalsHolder seam. AppDialogRepository and AppDialogObserverImpl are
impl-owned concrete types no other module can name, so they used to reach the feature
graph through an Application-implements-holder trick (Context.appDialogInternals())
instead of appDeps<T>(). As a contributed extension the graph inherits both straight
from AppGraph, so the seam has no consumer left. The holder, its accessor and
BaseApplication's two get() overrides go in the next commit, per the
port-then-delete split.

NOT affected, so the closing commits do not over-delete: AppDialogPublisherHolder +
Context.appDialogPublisher() in the API module STAYS — it serves cross-module consumers
(settings / recovery / archive) and has nothing to do with this feature's own graph.
Two similarly-named seams; only one is dead.

FORCED-PUBLIC: predicted 6, measured 6 — the NARROWEST port of the arc, tying
image-viewer, immediately after the widest (live-workout, 24). Prediction committed in
4efb90d before the first widening round.

  6 plumbing + 0 interactor + 0 models

All six side-claims held: no interactor exists (second port ever with 0 pairs); zero
models, because AppDialog / AppDialogUserAction / AppDialogUserChoice live in the api
module and are already public cross-module; AppDialogResolver and AppDialogKeys stayed
internal; both handlers stayed internal; AppDialogsScope stayed internal; and
AppDialogRepository / AppDialogObserverImpl needed no visibility change at all, being
inherited rather than exposed.

All 6 falsified individually: 6 forced, 0 over-widened, 0 stale. Control
(AppDialogsScope) validated for this feature and green first.

IDENTITY TEST: four claims, all green. Two are specific to this port. The
seam-retirement claim asserts both app-scoped singletons are the PARENT's instances —
that is what proves the internals holder is genuinely redundant rather than merely
unused, since a double would fork app-root dialog state from what the rest of the app
publishes. Proven RED against a different AppGraph.

The second replaces the per-route-arg claim the other ports make: there is no route arg,
so the property pinned is that the no-arg creator still builds a DISTINCT extension and
Store per call (the extension is not itself a singleton) while the INHERITED app-scoped
repository stays one instance across both.

detekt executed (not FROM-CACHE) on :feature:app-dialogs:impl and :app:app, zero
suppressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…e app-dialogs port

The port in 5fafe04 left the seam present-but-unused so that SHA stayed independently
valid. Nothing reads it now, so it goes: the holder interface, the
Context.appDialogInternals() accessor, BaseApplication's supertype and its two get()
overrides.

This is the app-dialogs analogue of an XxxDeps deletion, but of a DIFFERENT seam type.
AppDialogInternalsHolder existed because AppDialogRepository and AppDialogObserverImpl
are impl-owned concrete types that no dep interface can name, so they reached the
feature graph through an Application-implements-holder trick rather than appDeps<T>().
As a contributed extension the graph inherits both straight from AppGraph, which already
exposed them — so the trick has nothing left to do.

That inheritance is not assumed: AppDialogExtensionIdentityTest asserts both singletons
are the PARENT's instances, and that assertion is proven RED against a different
AppGraph. A double would have forked app-root dialog state from what the rest of the app
publishes, and the seam could not have been deleted.

AppDialogPublisherHolder in the API module is deliberately UNTOUCHED. It is a
similarly-named but different seam serving cross-module consumers (settings / recovery /
archive reading the publisher from a Context), and nothing about this port makes it
redundant. Recorded here because the closing commits must not over-delete it.

Also drops a now-false doc reference in TestApplication, which still listed
AppDialogInternalsHolder among the interfaces BaseApplication implements.

No accessor was orphaned: AppGraph's appDialogRepository / appDialogObserverImpl are
plain vals already (this feature contributes app-scoped bindings rather than bridging
them), so the orphan ledger stays at 14 from five XxxDeps deletions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…sts nothing

FORCED-PUBLIC: predicted 6, measured 6 (prediction committed 4efb90d before round 1).
Narrowest port of the arc, tying image-viewer, landing immediately after the widest
(live-workout, 24). All six side-claims held — no interactor exists, zero models because
the api module already owns them publicly, resolver/keys/handlers/scope all stayed
internal, and the two app-scoped singletons needed no visibility change at all. 6
forced, 0 over-widened, 0 stale.

ENDPOINT, taken at port 13 per the rule, bracketed. Control ee95937 verified at 12
extensions against the working tree's 13. Cold calibration 28.976s.

  control A (1st)  N=12   1.075  [1.003, 1.195]
  NEW      (2nd)   N=13   0.995  [0.965, 1.210]
  control B (3rd)  N=12   0.988  [0.962, 1.119]

  within-pair drift (ctlB - ctlA) : -0.087s
  raw delta (new - ctlA)          : -0.080s
  drift-corrected                 : -0.036s
  three-way ranges                : OVERLAP [1.003, 1.119]

THE ENDPOINT COSTS NOTHING. The thirteenth extension is -0.036s corrected — negative —
and the ranges overlap, so it does not resolve. Across the entire arc no build-time
reading has ever resolved outside overlapping ranges.

And this bracket is the strongest evidence yet for witness 12. The within-pair drift
measured here is -0.087s, nearly 2x the 0.045s yardstick, and it swamps the effect it
surrounds. Under the old fixed order (new first, control second) a speed-up that size
pushes new-minus-control POSITIVE; under bracketing the same comparison reads -0.036s.
The SIGN of a delta in this environment is decided by measurement order whenever drift
exceeds the effect — which here it does by 2x. That retroactively covers all four
earlier positive deltas (+0.018 .. +0.115): every one is below the drift magnitude this
bracket measured directly.

Status: 13 OF 13 PORTED. No @DependencyGraph survives under feature/. StoreFactory has
zero users. AppFeature has one user (app-dialogs, now ported) and may itself be
deletable in the closing commits.

What remains is the close-out, not ports:
  1. three closing commits — delete remaining bridges + the ENUMERATED 14 orphaned
     accessors; AppGraph override->plain down to the StoreCoreDeps+NavigatorDeps spine;
     delete FeatureAssisted/StoreFactory and check AppFeature;
  2. the on-device restore-cycle known-positive anchor on the base;
  3. then #176.

The live-workout "13/13" mislabel stays recorded as a standing correction; it was port
12, app-dialogs is 13.

5fafe04 wired into SERIES; N-column check now 1,2,3,4,5,6,7,7,8,9,10,11,12,13,13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
… are actually dead

CLOSING COMMIT 1 of the arc, now legal: the arc was indivisible until port 13, and all
13 are ported.

The instruction was to delete "the ENUMERATED 14 orphans". Enumerating them is what
stopped this from being wrong: of the 14 accessors orphaned across the five XxxDeps
deletions, only EIGHT are dead. Six are live roots and stay.

  DEAD, deleted here (8):
    tagRepository, resourceWrapper, exerciseRepository, setRepository,
    personalRecordRepository, performedExerciseRepository,
    trainingExerciseRepository, trainingRepository

  LIVE, retained (6) — with their readers:
    ioDispatcher              PastSession/Settings/ExerciseChart/LiveWorkout identity tests
    defaultDispatcher         six identity tests
    mainImmediateDispatcher   Exercise + SingleTraining identity tests
    sessionConflictResolver   SingleTraining identity test
    sessionRepository         LiveWorkout identity test
    imageStorage              BaseApplication.cleanupOrphanedImageTempFiles

A count-driven deletion of "the 14 orphans" would have broken six things. That is the
enumeration rule paying off on its last and largest use, exactly as the ledger
(1,0,1,3,9) predicted it would need to.

The enumeration was PROVEN to discriminate before it was acted on: deleting one LIVE
orphan (sessionRepository) fails compilation with 1 error; deleting the 8 dead ones is
green. Not "these look unused" — measured both ways.

detekt then caught 7 imports that the deletions stranded; removed. ResourceWrapper's
import stays, still referenced elsewhere in the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…ture is assisted

CLOSING COMMIT 2. StoreFactory was the assisted-store seam: every route-arg feature's
nested @AssistedFactory implemented it so the graph could expose a factory instead of
the Store. Shape B removed the assisted machinery from all seven route-arg features —
the Screen is now a @provides bound instance on the contributed extension factory and
each Store is a plain @Inject class — so the interface has no implementors left.

Verified by grep across the repo before deleting: the only remaining occurrences were
its own declaration and an import + two KDoc references in FeatureAssisted. Everything
else matching "StoreFactory" is androidx PreferenceDataStoreFactory, unrelated.

FeatureAssisted's KDoc said its processor was "backed by a Hilt assisted-factory
StoreFactory<TScreen, TStoreImpl>" — false on two counts now (not Hilt, not assisted).
Rewritten to describe what actually builds the Store: shape B binding the Screen on the
feature's contributed @GraphExtension.Factory.

Kept deliberately separate from closing commit 1 (the orphan deletions) so a later
bisect can tell "the accessor deletions broke it" from "the StoreFactory deletion broke
it" — the same distinguishability the arc ran on throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
…efuted, anchor blocked

Two closing commits landed (1f62da9, 4b569f9). Three more planned items do not
survive measurement, and one is blocked on credentials.

ORPHAN DELETION, enumerated not counted: of the 14 orphans only EIGHT are dead. Six are
live roots — ioDispatcher, defaultDispatcher, mainImmediateDispatcher,
sessionConflictResolver and sessionRepository are read by the :app identity tests, and
imageStorage by BaseApplication.cleanupOrphanedImageTempFiles. Deleting "the 14" would
have broken six things. Proven both ways before acting.

REFUTED, each by grep rather than assumption:

  * FeatureAssisted CANNOT be deleted — 7 live users. Every ported route-arg feature
    still extends it; NavComponentScreen and ScreenInjectionRule reference it. Its
    difference from Feature is processor(screen) vs processor(), a COMPOSITION-time
    concern the DI port never touched. The plan conflated "delete the assisted DI
    machinery" with "delete the assisted composition base class"; only the first was
    ever true.
  * AppFeature CANNOT be deleted — app-dialogs still extends it, plus AppFeatureScopeTest
    and AppFeatureProbe in core:ui:mvi androidTest.
  * The AppGraph override->plain collapse is ALREADY DONE, distributed across the five
    XxxDeps deletion commits — the 14 orphan conversions WERE that collapse. There is no
    separate commit to write, and the 11 accessors still carrying `override` all override
    a live supertype.
  * The supertype list bottoms out at FOUR, not two. StoreCoreDeps + NavigatorDeps are
    the feature-facing spine, but RecoveryDeps and BackupWorkerDeps are live FRAMEWORK
    seams: RecoveryActivity reads RecoveryDeps via RecoveryDepsHolder, MetroWorkerFactory
    reads BackupWorkerDeps via BackupWorkerDepsHolder — entry points that must not depend
    on core:ui:mvi. Collapsing "to the spine" as written would break both.

BLOCKED: the on-device restore-cycle anchor cannot be run by an agent. tech-debt.md:219
records it as the manual/device restore-gate baseline, explicitly NOT an automated test,
and BackupStorage is DriveBackupStorage — restore requires a signed-in Google account,
live Drive OAuth, and an existing backup. An emulator is attached and the base cf328bf
is reachable, so it can be run on that fixed SHA whenever; the ordering is not lost by
the two closing commits, since nothing is merged. It must be run before merge. The
existing metro-batch-anchor tag (64f875d) belongs to the App-Scope Collapse arc, not
this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
The plan and the reality disagreed in six places. This records what `git diff
cf328bf..HEAD` and grep actually show, and marks the plan wrong where it was.

THE ARC'S OWN HEADLINE was wrong in three of five numbers:

  @provides bound instances   125 -> 0        EXACT
  *BridgeTest.kt               13 -> 0        EXACT
  XxxDeps interfaces           11 of 15       4 are load-bearing
  override val accessors       30 -> 11       11 override a live supertype
  the `as T` cast seam         STILL PRESENT  every port depends on it

The `as T` line was self-contradictory from the start: shape B's flip point IS
context.appDeps<XxxGraph.Factory>(), which goes through that cast. The seam was
GENERALIZED, not deleted — which the body of this document said all along under
"load-bearing, stays". Headline and body disagreed for the whole arc; the body was
right.

THE SUPERTYPE FLOOR was wrong twice, and climbed both times: assumed 0, measured 2
(gamma-spine is not transient), measured 4 at close-out — RecoveryDeps and
BackupWorkerDeps are live framework seams read by RecoveryActivity and
MetroWorkerFactory via typed holders, neither of which may depend on core:ui:mvi.
Recorded against STANDING RULE 5 witness 3, which is now a two-time miss on the same
number.

THE APPGRAPH COLLAPSE never could have been one commit. The override->plain conversion
was distributed across the five XxxDeps deletions — the 14 orphan conversions WERE the
collapse. AppGraph went 43 accessors / 30 override at the base to 35 / 11 now,
incrementally. The "one big final collapse" model was wrong for the entire arc; only
measurement showed the file had been changing a little at a time.

FEATUREASSISTED AND APPFEATURE STAY. FeatureAssisted has 7 live users and its
processor(screen) vs processor() difference is a COMPOSITION-time concern the DI port
never touched; AppFeature is still app-dialogs' base plus its own androidTest probe.
The plan conflated deleting the assisted DI machinery (StoreFactory — done) with
deleting the assisted composition base class (never possible).

ENUMERATION, final tally: 8 of 14 orphans dead, 6 live roots retained with their readers
named.

STILL OUTSTANDING, and it is the only thing: the on-device restore-cycle known-positive
anchor on cf328bf. Manual, credential-requiring, not runnable by an agent. The doc now
carries an explicit do-not-substitute warning — something-green named "known-positive"
would answer "something passed" instead of "restore works", and both read as a tick —
plus the trap that metro-batch-anchor (64f875d) is the App-Scope Collapse arc's anchor,
not this one's. Nothing is merged, so the ordering is intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125msHipfByxZkTPs72UQTD
@stslex

stslex commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@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: c6977fbcb7

ℹ️ 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".

@stslex
stslex requested a review from Copilot July 25, 2026 21:44

Copilot AI 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.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

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

Reviewed the full merge diff at c6977fb. I found two additional false-green paths in the new DI guardrails and called them out inline. I did not duplicate the three existing unresolved findings.

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

Review — Hilt → Metro migration

Reviewed the full origin/dev...HEAD range (169 commits, 621 files) across ten areas: app-scope graph and Application lifecycle, the 13 feature @GraphExtension ports, core:ui:mvi store resolution, Room/DB, backup + WorkManager + recovery, the custom Detekt rules, build/toolchain config, the core modules, test integrity, and cross-cutting concurrency. Findings below were filed by area reviewers and then independently re-checked against the code before posting; several proposed findings were dropped as refuted or pre-existing.

The DI port itself holds up. The things most likely to be wrong here are not: binding parity is exact (every removed @Binds @ViewModelScoped has a counterpart, nothing dropped, re-scoped, or re-qualified); no feature-scoped node was silently promoted to app scope and no app singleton demoted; viewModel<TStoreImpl>() cannot key-collide (no two features share a Store type) and the initializer runs at most once per retained Store; no Activity leaks through LocalContext into an extension; MetroWorkerFactory's null-return fallback is correct against the pinned work-runtime 2.11.2; there is no @IOappGraph back-edge; and the 14 *ExtensionIdentityTest files build the real compiled AppGraph and assert genuine extension-vs-parent inheritance, not fakes. The concurrency surface is unchanged in shape — no new GlobalScope, no new runBlocking on a startup path.

What I'd fix before merge

Two of these mean a guard you believe is running is not:

  1. ContributesBindingScopeRule never runs — registered in the rule set, 138 lines of green tests, no active: key in detekt.yml. Detekt treats an unlisted rule as inactive, and the test suite can't catch it because Config.empty returns true for active. (lint-rules/detekt.yml:663)
  2. core:core's 17 unit tests are dark — the KMP move puts them under androidHostTest, which testDebugUnitTest (CI's only unit-test command) does not reach. asyncAssociate was rewritten in this PR with its suite never executed. (core/core/build.gradle.kts:19)
  3. MetroScopeRule exempts 31 classeshasInject reads only the primary constructor, and this migration moved nearly everything to class-level @Inject. HiltScopeRule did see those classes on origin/dev. (MetroScopeRule.kt:59)
  4. The Room 3 device tests can never be selectedRoom3RoundTripDeviceTest, AtomicRollbackDeviceTest, InvalidationDeviceTest and RecoveryActivityDbFreeTest carry no @Smoke/@Regression, and both ui_tests.yml jobs filter by annotation. The two riskiest parts of the PR have zero reachable device coverage.
  5. AtomicRollbackDeviceTest passes if the transaction never runsrunCatching swallows everything and the post-condition can't distinguish "rolled back" from "never written". Assert on RollbackTrigger.
  6. RecoveryActivityDbFreeTest lost its teeth in the move — Hilt field injection used to force both collaborators at onCreate; they are now by lazy + get() and nothing in CREATED→RESUMED reads them.
  7. The @SingleIn(XScope) sharing invariant is untested across all 13 features — it is the only thing making the Store's emitter and the handlers' emitter one object, and dropping it compiles, passes detekt, and passes the identity tests while crashing the screen on open.

Two things that aren't inline-anchorable

  • app/app/src/main/AndroidManifest.xml:53-61 (unchanged by this PR, so no diff line to attach to). The comment justifying tools:node="remove" on WorkManagerInitializer still says the initializer must go because on-demand init installs HiltWorkerFactory, and that restoring it would break @AssistedInject construction of BackupWorker. BaseApplication.kt:94-97 now installs MetroWorkerFactory(this) and BackupWorker is a plain CoroutineWorker with an 8-arg constructor. The directive is still load-bearing and nothing guards it — RemoveWorkManagerInitializer is disabled in LintConventionPlugin.kt:50-62. A post-migration Hilt sweep that reads this comment and deletes the block gives you a WorkManager initialized by androidx.startup with a default Configuration, no MetroWorkerFactory, and a reflection fallback that cannot construct BackupWorker — every scheduled backup fails, with no foreground signal.
  • AsyncAssociateTest.kt:38-55 (pure rename, no content diff). You rewrote asyncAssociate to asyncMap { … }.toMap() and upgraded its KDoc to promise deterministic last-write-wins, but the test still documents the deleted ConcurrentHashMap behaviour ("order is not guaranteed across runs") and asserts only result["a"] in setOf(1, 2, 4). The answer is now always 4. Tighten to assertEquals(4, result["a"]) so the guarantee you just documented is actually pinned.

Process

  • The PR description is describing a different PR. Title and body still say "Step 5", Metro standing up "alongside @HiltAndroidApp under a reversible dual-path", "No @HiltAndroidApp / @AndroidEntryPoint removed — that is Step 6", and enumerate a residual Hilt surface (~41 adopt-back shims, 14 *HiltEntryPoint bridges, 2 @AndroidEntryPoint Activities, @HiltWorker BackupWorker) as not in this PR. None of that is true at c6977fbc: Hilt and Dagger are gone from every classpath, all 13 features are on contributed @GraphExtensions, StoreFactory is deleted, and MetroWorkerFactory is the live WorkerFactory. The body also still says DO NOT MERGE. A reviewer who reads it will believe this change is reversible and that the irreversible cut is a future PR — and may approve without the on-device backup/restore baseline, which is now the only gate on an irreversible Hilt-free startup path.
  • The branch bundles four independent changes. Beyond the DI cut: Room 2.8.4 → room3 3.0.0 (new setDriver(AndroidSQLiteDriver()), new AppDatabaseFactory), the core:corecore:core + core:core-android KMP split, and a full toolchain bump (Kotlin 2.3.20→2.4.10, KSP 2.3.6→2.3.9, AGP 9.1.0→9.3.0, compileSdk 36→37, Gradle wrapper 9.5.0→9.6.1 with the jar replaced, JUnit 5→6, vkompose removed). Each has its own failure mode, and none can be bisected independently because the same commits that touch Room also delete Hilt. The KMP split already caused finding #2 above, and it is invisible when the diff is read as a DI change. If splitting isn't practical now, please at least state the bundle in the description and say which gate covers each risk.
  • :lint-rules tests never run in CI (pre-existing, not introduced here — flagging because this PR adds five new rule test classes into that module). It's a kotlin("jvm") module, so its task is test, not testDebugUnitTest; Gradle matches by name and silently skips it. detektPlugins(project(":lint-rules")) builds the jar without compiling the test source set. Adding :lint-rules:test to the CI step would have caught finding #1 and #3 as red builds.

Nothing in this review was applied to the code — comments only, as requested.

Comment thread lint-rules/detekt.yml
Comment thread lint-rules/detekt.yml Outdated
Comment thread core/core/build.gradle.kts
Comment thread app/app/src/main/java/io/github/stslex/workeeper/di/AppGraph.kt Outdated
Comment thread feature/archive/build.gradle.kts Outdated
stslex and others added 8 commits July 26, 2026 05:54
PR #176 review — threads on MetroScopeRule, ScreenInjectionRule,
ContributesBindingScopeRule, ScopeClassType and detekt.yml.

- MetroScopeRule only read `klass.primaryConstructor?.annotationEntries`,
  so every class carrying a CLASS-level `@Inject` — the shape this arc moved
  almost everything to — returned before the scope check. 31 name-matched
  classes were invisible to the gate (13 *HandlerStoreImpl, 12 *InteractorImpl,
  StateStatusMapper, CommonDataStoreImpl, AccountDataStoreImpl,
  BackupPreferencesRepositoryImpl, RestoreStateRepositoryImpl, StoreDispatchers).
  Injection detection is now `isMetroInjected()` — class OR primary ctor — and
  the intentionally-unscoped Store carve-out is explicit by name instead of
  riding on that short-circuit. All 31 already declare `@SingleIn`, so the
  widened gate is clean on this tree.
- ScreenInjectionRule exempted `*StoreImpl` by suffix, which also matched every
  `*HandlerStoreImpl` adapter — those are event emitters, not BaseStore impls,
  so a `Screen.X` ctor dep on one bypassed the rule. Exemption narrowed via the
  shared `ScopedClassNames.isStoreImpl`.
- ContributesBindingScopeRule validated only the FIRST `@ContributesBinding`;
  the annotation is repeatable and two live classes carry two entries each
  (ActivityHolderImpl, DatabaseSnapshotProviderImpl). It now checks every entry
  and anchors each finding at its own annotation.
- ContributesBindingScopeRule was registered in the provider and shipped with
  tests, but detekt.yml had no key for it — an absent entry means the rule does
  not run, so the gate was dead while its `Config.empty` unit tests stayed green
  by construction. Activated.
- ScopeClassType's SINGLETON/FEATURE_SCOPED split was write-only (the sole
  consumer discarded the value). Collapsed into `ScopedClassNames`.
- Dropped the `dagger.hilt.EntryPoint` TooManyFunctions exemption and its
  "Hilt code is unaffected" clause; no Hilt remains on any classpath.

Every behaviour change carries a positive/negative test pair, and all three
rules were proven to fire against the real tree under the production config
(mis-scoped a live @ContributesBinding, dropped a @singlein, and gave a
*HandlerStoreImpl a Screen dep — each turned `./gradlew detekt` red).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
PR #176 review — AppGraph accessor / StoreCoreDeps / AppDialogPublisherHolder
threads.

- Seven AppGraph accessors had zero readers repo-wide (numUiUtils,
  activityHolder, platformInfoProvider, tempFileProvider, restoreStateRepository,
  accountDataStore, statsRepository) while their KDoc named readers that do not
  exist ("the seam test uses it...", "exposes the binding for identity tests").
  Deleted with their imports. Re-measured per accessor, including app/app's test
  and androidTest source sets, rather than inherited from the review.
- StoreCoreDeps and NavigatorDeps were ADDED by this PR and ended it with zero
  readers: nothing calls `appDeps<StoreCoreDeps>()` or `appDeps<NavigatorDeps>()`,
  because every feature now inherits analytics/logger/dispatchers/navigator from
  the parent graph through its @GraphExtension. This PR deleted all 13 analogous
  XxxDeps bridges for exactly that reason. Deleted both, dropped the supertypes
  and the `override`s they forced.
- AppDialogPublisherHolder + Context.appDialogPublisher() were likewise added
  dead. The consumer their KDoc named (feature/settings) takes AppDialogPublisher
  as a constructor dep from the graph instead. The sibling AppDialogInternalsHolder
  was deleted for the same reason in a750d71. Leaving it would invite the next
  dialog producer to reintroduce the Application-cast seam this arc spent 13
  ports removing.
- ArchiveGraph gains two accessors so the @singlein(FeatureScope) sharing
  invariant is testable — see the test commit.

The four accessors that survive with no reader are now documented as
compile-time binding-resolution assertions rather than attributed to readers
that do not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
PR #176 review — the "passes vacuously" / "no CI job can select this" threads.

Reachability:
- The five device tests under core/data/database/src/androidTest carried no
  suite annotation, and BOTH instrumented CI jobs filter by annotation, so no
  job could ever select them — the Room 2->3 evidence was unreachable. Annotated
  @regression and added the androidTestImplementation edge on :core:ui:test-utils
  that supplies the annotation (it genuinely was not on that classpath; the
  same androidTest-out/main-back shape already exists in core/ui/mvi).
- RecoveryActivityDbFreeTest was likewise unannotated.

Vacuity:
- AtomicRollbackDeviceTest: all four tests were `runCatching { ...; throw }`
  followed by "table is empty". runCatching swallows everything, and an empty
  table is satisfied both by rollback-after-write AND by the writes never
  happening. The file's whole value is being a trustworthy oracle (its own KDoc
  records two false negatives under Robolectric). Now asserts the caught
  throwable is the named RollbackTrigger, and reads the rows back INSIDE the
  transaction before the throw — turning "empty after" into "written, then
  rolled back".
- RecoveryActivityDbFreeTest: post-migration RecoveryActivity resolves its
  collaborators lazily and onCreate only binds callable references, so walking
  to RESUMED no longer forced either one out of the graph — the tripwire could
  not fire through them. Added @VisibleForTesting warmDeps() and call it inside
  the tripwire window; KDoc corrected.
- AppGraphIdentityTest: `separate graphs own separate instances` asserted
  assertNotNull on two non-nullable reads, so it could only fail if buildGraph()
  threw, which test 1 already covers. Now assertNotSame.
- AppFeatureScopeTest: the assertSame identity claim had been downgraded to
  assertNotNull on a platform-non-null API. Restored by capturing the Store via
  viewModel() in the same setContent block.
- ExerciseFormBasicsTest: the method name promised three checks and the body did
  one and a half. State is now hoisted so the empty->non-empty transition really
  drives the button, and Save is tapped.

New coverage:
- Nothing tested the @singlein(FeatureScope) invariant that replaced ~60
  @ViewModelScoped annotations, even though it is the only thing making the
  Store's emitter and the handlers' emitter one object. Two tests added with
  BOTH assertSame operands inside one extension. Verified they fail for the
  right reason: deleting @singlein from ArchiveHandlerStoreImpl fails exactly
  these two and leaves the pre-existing two passing.
- Deleted QualifiedBridgeCollisionTest: it declared its own @DependencyGraph and
  asserted Metro's own behaviour with no production type participating, and
  still passed with includeJavax() removed from the modules it claimed to guard.
  ExerciseExtensionIdentityTest / PastSessionExtensionIdentityTest already
  assert the qualified pair against the real AppGraph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
…dges

PR #176 review — core/core/build.gradle.kts and core/ui/kit/build.gradle.kts
threads.

- core:core moved to convention.kmpLibrary and its tests to src/androidHostTest,
  but the AGP KMP android target has no build types: it names the host-test task
  after the component identity (testAndroidHostTest), so `testDebugUnitTest` —
  the ONE unit-test command CI runs — did not exist here, and Gradle silently
  skips projects lacking a named task. 17 tests (AsyncAssociateTest 8,
  NumUiUtilsTest 9) never ran, including the suite for the asyncAssociate
  rewrite in this same PR. Added a name-independent alias that depends on the
  live tasks.withType<Test>() collection. Verified by artifact, not by exit
  code: :core:core:testDebugUnitTest now drives testAndroidHostTest and emits
  test-results XML with 9 and 8 tests, matching CI's publish globs.
- Added src/androidHostTest/kotlin to core:core's detekt source set — it was
  outside the gate. That required teaching lint-rules/detekt.yml about the glob
  (previous commit): androidHostTest matches none of detekt's default test
  excludes, so MagicNumber and FunctionNaming would have gone red on entry.
- 11 modules carried an `implementation(project(":core:core-android"))` edge
  justified by a comment claiming the AppScope token lives there. It does not —
  2f9c89d (this branch) moved it to core:core commonMain precisely so a module
  could see it without an Android-only edge. Every module was checked against
  the actual public surface of core:core-android; all used only symbols declared
  in core:core, and all keep a direct :core:core edge. Edges and comments removed.
- Swept the build scripts and convention plugins of comments describing a
  Hilt/dual-path world that no longer exists ("the convention still force-applies
  the Hilt plugin", "@modules aggregate into the app's single Dagger graph",
  "coexists with the module's Hilt @module", names like TestAppGraphModule that
  never existed). grep -ri hilt was the fastest way to get a wrong mental model
  of this codebase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
PR #176 review — the doc-drift threads (AppDatabaseFactory, ImageStorageFactory,
ArchiveScope, CommonExt, AppScope spec, AppGraph header).

The dangerous ones first:

- AppDatabaseFactory and ImageStorageFactory both said "STAGED, not the live
  feed" and named a Hilt module as "still the prod construction". Both Hilt
  modules are deleted by this PR and BaseApplication calls both factories
  directly — these ARE the production construction sites. AppDatabaseFactory is
  the one place addMigrations is applied, so someone who believes "not yet wired"
  and adds fallbackToDestructiveMigration to make a schema change compile wipes
  every user's workout database instead of routing to Scenario 2 recovery. The
  deliberate absence of that call is now stated as load-bearing.
- .claude/skills/add-database-migration.md still told a migration author to
  register their Migration in CoreDatabaseModule, a file this PR deletes. That is
  the literal instruction a future author follows; an unregistered migration
  ships a crash-on-upgrade. Repointed at the MIGRATIONS array in
  MigrationsRegistry.kt, with the Room 3 suspend migrate(SQLiteConnection) shape.
  Same fix in MigrationsRegistry.kt's own KDoc, CLAUDE.md, CONTRIBUTING.md,
  documentation/architecture.md and documentation/testing.md.
- ArchiveScope's KDoc claimed "every Metro-CONSTRUCTED archive node is
  @singlein(ArchiveScope::class), so one ArchiveGraph == one retained
  ArchiveStoreImpl". ArchiveStoreImpl says the opposite and is right — it is
  deliberately unscoped and retained by the ViewModelStore. Trusting the KDoc and
  reading graph.archiveStore twice yields two Stores sharing one handler store,
  and the second setStore silently rebinds the emitter. Reworded, and the stale
  "proven on Metro 1.1.1" pin corrected to the shipped version.
- CommonExt.isTraceExecutionEnabled documented a bootstrap contract that did not
  exist ("set from BuildConfig.DEBUG in BaseApplication.onCreate"), leaving
  traceExecutionTime an unconditional pass-through in debug where origin/dev
  genuinely traced. Wired the assignment and corrected the KDoc.
- The AppScope execution spec asserted AppScope lives in core:core-android and
  called commonMain a platform-axis leak, contradicting 2f9c89d on this branch.
  Corrected to code truth, with re-measured snapshot figures. The invariant that
  actually matters — every @ContributesBinding/@ContributesTo(AppScope) site
  lives in an Android source set — is restated and still holds (0 in
  commonMain/iosMain).
- AppGraph's header still described a cleanup as pending that had already landed,
  its resourceWrapper KDoc dangled after the accessor was deleted (keeping a dead
  import alive, invisible to UnusedImports because KDoc links count as usage), and
  an ORPHANED comment sat above the wrong accessor.

Also swept: the four dispatcher qualifiers said Hilt reads their
@javax.inject.Qualifier (the reader is Metro's includeJavax interop), and
KmpLibraryConventionPlugin explained the whole KMP/Android module split by a
Hilt constraint that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
Follow-up to df0a893, which put core:core's src/androidHostTest under the
detekt gate. Rules with an explicit `excludes` list were updated then; rules
with NO local `excludes` silently inherit detekt's defaults, and those defaults
predate AGP's KMP host-test source set — so such a rule judges host tests as
production code.

Audited all 16 `excludes:` keys in detekt 1.23.8's bundled
default-detekt-config.yml (extracted from the jar, not recalled) against this
config. Exactly one rule was in the gap: TooGenericExceptionCaught — active,
no local excludes, and detekt's own default excludes test code. Its list is now
spelled out locally, reproducing the default 8 globs verbatim plus
'**/androidHostTest/**'.

The other 15 are accounted for and unchanged: 8 are active:false here, 5 were
already fixed in df0a893, 1 is absent and off by default, and
MissingPackageDeclaration's default excludes are '**/*.kts' only. The
`formatting:` block is deliberately untouched — detekt-formatting ships no
excludes at all, because ktlint rules are meant to apply to test sources.

Proven two-sided, since an exclude can only remove findings and green alone
proves nothing: added a `catch (e: Exception)` under androidHostTest and ran
`:core:core:detekt` — green with the glob, RED on that exact line with the glob
removed. So the detector does reach these files and the exclude is load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
7f48093 removed seven AppGraph accessors that had zero readers. Three of them
were the last reference to their type, so those types are now unreachable.

Deleted, each verified by grepping every source set (main/test/androidTest/
commonMain/androidHostTest) plus '*.xml' and the manifest, with a positive
control run first so an empty result is evidence rather than a broken command:
- core:ui:kit's `NumUiUtils` interface and `NumUiUtilsImpl`. Note this is NOT
  the live `NumUiUtils` object in core:core commonMain, which stays — it has 9
  passing tests and was one of the 17 CI could not reach before df0a893. The
  kit-level pair only delegated to it and had no consumer of its own.
- The `ActivityHolder` interface, plus its `binding = binding<ActivityHolder>()`
  contribution on ActivityHolderImpl. The sibling `ActivityHolderProducer`
  supertype is live (MainActivity reads it) and its contribution is untouched,
  so ActivityHolderImpl keeps exactly one binding.

Deliberately NOT swept: StatsRepository / StatsRepositoryImpl in
core:data:exercise. It also has zero production consumers, but unlike the above
it has a live passing DB suite (StatsRepositoryImplDbTest) and deleting it would
orphan a SessionDao query chain in a third module — that reads as staged work,
not rot, so it is a maintainer's call rather than a cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
…aims

Three groups, all found while working the review round rather than raised in it.

1. .claude/skills/add-feature.md was entirely Hilt-era — ~20 hits for
   @hiltviewmodel / @Installin / @ViewModelScoped / @module / javax.inject.
   Following it produced a module that neither compiled nor wired into the app
   graph. Rewritten against the real current shape, derived by reading
   feature/archive end to end (scope token, contributed @GraphExtension.Factory,
   @BINDS, the Feature composition seam) plus feature/settings and
   feature/exercise for a route arg. It now states the scoping invariant
   explicitly, because getting it wrong is silent: the Store carries class-level
   @Inject and NEVER @singlein (retained by the ViewModelStore), while every
   *HandlerStoreImpl MUST carry @singlein(<X>Scope::class) — otherwise Metro
   builds one emitter for the concrete key and another for the @BINDS interface
   key, it compiles green, and the screen crashes on first action. The skill also
   now warns about the custom detekt rules a new feature will trip.

2. documentation/lint-rules.md: dropped the claim that the Kotlin version is
   pinned "for Hilt compatibility" and the bullet about Hilt's own lint rules
   shipping with the Hilt Gradle Plugin. Both false with Hilt off every
   classpath. app-dialogs.md spelled the scope token @singlein(AppDialogScope)
   where the code says AppDialogsScope.

3. The graph-extension-arc and appgraphcontract-split documents still described
   seams this branch deleted — most actionably HANDOFF.md's "AppDialogPublisherHolder
   + Context.appDialogPublisher() in the api module STAYS", an instruction a
   future reader would follow. These are ARC HISTORY, so the narrative is NOT
   rewritten: each stale statement is preserved and superseded by a dated
   correction note in the house style those files already use, recording what
   was deleted, by which commit, and what replaced it.

Also fixed documentation/design-system.md's `LocalAppDimension.current
// already exists` — it never existed; AppDimension is a plain object read
directly. The neighbouring AppSwipeAction entry is NOT stale and was left
alone: that document is a specification and implementation plan, so a component
it lists as unbuilt is planned work, not drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYHoyzzWCWrnTL15zBgv64
@stslex
stslex merged commit c6c743e into dev Jul 26, 2026
3 checks passed
@stslex
stslex deleted the feature/metro-batch branch July 26, 2026 08:14
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.

2 participants