Skip to content

Typed LifecycleKit: compile-checked launch plans, engine/UI split, Where migration#116

Open
kyleve wants to merge 8 commits into
mainfrom
typed-lifecyclekit
Open

Typed LifecycleKit: compile-checked launch plans, engine/UI split, Where migration#116
kyleve wants to merge 8 commits into
mainfrom
typed-lifecyclekit

Conversation

@kyleve

@kyleve kyleve commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Problem

LifecycleKit modeled launch as a flat list of closure steps with no data flow between them: ordering was declaration-order only, "must succeed vs. best-effort" was encoded by whether a closure happened to throw, cross-step data traveled through WhereModel optionals (model.session?.syncAuthorization() silently no-ops if the ordering assumption ever breaks — and the launch still reaches .ready), and the engine imported SwiftUI and carried per-step AnyView presentation machinery. PR #99 made the store impossible to double-open by injection; this PR applies the same make-invalid-states-unrepresentable philosophy to the launch pipeline itself.

Changes

LifecycleKit is now a typed engine (Foundation + Observation only — no SwiftUI):

  • Each step is its own type: the LifecycleStep protocol (associated Input/Output) and the LifecycleGate protocol (pass-through, foreground-default, isNeeded re-evaluated on promotion).
  • LaunchPlan<Input, Output> composes a sequential trunk plus concurrent detached fan-outs, with the data flow compiler-checked: then requires the next step's Input to equal the current trunk Output; only pass-through positions (thenKeeping, gates, detached children) may be mode-gated, so a skipped node can't leave a hole; detached children are Void-output, so nothing can depend on a fire-and-forget step. Type erasure lives in exactly one internal place, with the combinators' constraints guaranteeing every cast.
  • LifecycleRunner<Launch> walks the plan and publishes a value-carrying phase: launching | running(context) | awaitingGate(handle) | failed(failure) | ready(Launch). .ready carries the trunk's output, so the app surface cannot be built without the value the launch produced. Completed nodes' outputs are memoized per attempt — retry() resumes the failed node with its original input, and promotion never repeats completed work. Detached children never block .ready and surface failures only on the off-phase detachedFailures diagnostics. Preserved from the legacy engine: single cancel-and-drain drive, idempotent enterForeground() promotion, the Fix fresh-install launch failure: canonical store, prompt promotion, honest splash #99 superseded-drive-can't-clobber-phase rule (extended to gate handles), typed teardown(plan:input:) + relaunch with teardown children drained before the relaunch and the retained input released on success.

New LifecycleKitUI target owns everything rendered: the generic LifecycleContainer (phase → splash / gate view / failure / content(Launch), preserving buildsNoViewTree, surface-identity animations, and launch-surface z-ordering), gate views registered by gate type via GateView(for:content:) — the registry recovers each gate's Value statically, so views receive (handle, typed value) — and the non-generic LifecycleProxy under @Environment(\.lifecycle).

Where migrated onto the typed plan: OpenStoreStep (Void → WhereServices, still the process's one store open) → StartSessionStep (WhereServices → WhereSession, fires onServicesReady on every session (re)start) → OnboardingGateSyncAuthStep/ReconcileTrackingStep on the trunk (reconcile must see the synced authorization) → a detached fan of the five independent session-configuration steps, which no longer serialize. Every post-session step takes a non-optional WhereSession; the model.session? chains in steps and RootView's if let session guard are gone (MainTabs is built from the session .ready carries). The reset is a typed teardown plan rooted at the session Settings hands in. The trunk value doubles as the launch's dependency scope, growing by embedding (WhereServicesWhereSession); the scope convention is documented in the module README.

Review hardening (self-review findings):

  • Teardown plans that reuse launch node IDs now fail fast (precondition) — the shared run-once memo would otherwise silently skip the teardown node and corrupt the typed trunk value.
  • DetachedFailureReporter mirrors detached-step failures into WhereLog at warning (UI-independent observation, so headless drives are covered) — the runner's detachedFailures state alone satisfied "observable" but nothing logged it.
  • A parked gate with no registered view now logs (os) and fails its handle with a localized MissingGateViewError, landing on the retryable failure surface instead of an indefinite splash; identical in debug and release.

Accepted as documented decisions: the (improved) animated splash→onboarding transition, onServicesReady firing once per fresh attempt even for pre-built preview/test sessions, and the construction-time precondition (rather than a type-level rule) that value-producing steps keep modes == .all.

Testing

  • Engine suites ported and extended: drive/value-threading, gates, detached isolation, promotion, memoized retry, teardown, plus the seeded fuzz suites (200 random plans against an independent model — now also predicting detached spawn/failure sets — and 120 flaky-retry drains). New LifecycleKitUI container/proxy suites, DetachedFailureReporterTests, and the unregistered-gate fallback test.
  • Where suites updated: plan parity via nodeIDs (with the new start-session node), onboarding as a parked gate resolved through its handle, session-rooted teardown, hook re-fire on reset relaunch, and the weak-reference test that caught (and now pins) the teardown-input release.
  • Full Stuff-iOS-Tests scheme green locally (iPhone 17, iOS 26.2 simulator); ./swiftformat --lint clean.
  • Best end-to-end check: a fresh install should splash → zoom-reveal into onboarding → zoom-reveal into the app, and Settings' "Erase all data & reset" should land back on onboarding over an empty store.

kyleve added 8 commits July 19, 2026 18:31
Plan step: core-typed-model. Pure groundwork, no behavior change.

- New typed model: the LifecycleStep protocol (associated Input/Output),
  the LifecycleGate protocol (pass-through, foreground-default), the
  LaunchPlan<Input, Output> combinators (init/then/thenKeeping/gate/
  detached) whose generic constraints make out-of-order or hole-leaving
  plans unrepresentable, plus LifecycleStepContext (reporting half of
  the old bridge) and LifecycleGateHandle (gate resolution token).
- Types are erased in exactly one place (LaunchPlanNode and friends,
  package-visible for the upcoming runner/UI split); the combinators'
  constraints guarantee every internal cast.
- Value-producing trunk steps must keep modes == .all (precondition):
  only pass-through positions may gate on the launch reason.
- The existing linear API is renamed with a Legacy prefix (types, files,
  test suites) so the final names are free for the typed engine; it
  still drives the Where app unchanged and is deleted at the end of
  this plan.
- Tests: LaunchPlanTests (combinators, builder conditionals, erasure,
  teardown-rooted plans), LifecycleGateHandleTests (resolution
  machinery), shared FixtureStep/FixtureGate fixtures.
Plan step: runner-rewrite.

- LifecycleRunner is now generic over the plan's output. Its nested
  Phase is the explicit, value-carrying UI signal: launching |
  running(context) | awaitingGate(handle) | failed(failure) |
  ready(Launch) — .ready carries the trunk's output so the app surface
  cannot exist without the value the launch produced.
- The walk threads the typed trunk value through steps and gates,
  memoizes each completed node's output (run-once across promotion +
  retry; a retry resumes the failed node with the same input), and
  spawns detached children into a task group owned by the drive:
  .ready publishes as soon as the trunk finishes, children drain
  behind it, and their failures land on the off-phase detachedFailures
  diagnostics surface (recorded, logged by consumers, never fatal).
- Preserved invariants from the legacy runner: single cancel-and-drain
  drive, mode gating per LifecycleReason, idempotent enterForeground()
  promotion (skipped gates re-evaluate, completed work doesn't repeat),
  a superseded drive can't clobber the phase (PR #99 regression,
  extended to gate handles), teardown(plan:input:) runs a typed
  teardown then relaunches fresh — with teardown's detached children
  drained before the relaunch, @_spi(Testing) failure injection.
- Tests: ported drive/promotion/failure/reset suites and the 200+120
  seeded fuzz suites to the typed model; the fuzz model now also
  predicts detached spawn/failure sets and proves memoized retry never
  re-runs completed nodes. New gate, detached, and phase-surface
  coverage. The legacy runner and its suites stay green untouched.
Plan step: ui-split.

- New Shared/LifecycleKitUI module (SwiftUI + LifecycleKit only) owning
  everything rendered: the generic LifecycleContainer switches on the
  typed runner's phase — splash(context) / registered gate view /
  failure / content(Launch) — preserving the legacy container's
  hard-won behavior (buildsNoViewTree renders nothing even at .ready,
  surface-identity-keyed transitions, launch surfaces layered above
  content for the reveal).
- Gate views register by gate *type* via GateView(for:content:), which
  statically recovers the gate's Value: the view receives
  (LifecycleGateHandle, Value) instead of trusting the environment.
  One registration per gate type (construction precondition); a parked
  gate with no registration debug-asserts and falls back to the splash.
- LifecycleProxy under @Environment(\.lifecycle) forwards retry /
  enterForeground / typed teardown(_:input:) through the new
  package-level LifecycleDriving seam in core (an environment value
  must be non-generic; the plan+input are type-checked at the call
  site and erased only to cross it). Disconnected default asserts in
  debug, no-ops in release, like the legacy proxy.
- Wired in Package.swift (product + target), Project.swift
  (LifecycleKitUITests hosted bundle + scheme + Stuff-iOS-Tests build
  and test entries). Module README.md + AGENTS.md added.
- Core still exports the legacy SwiftUI surface (old container, bridge,
  splash/failure views) until the Where migration lands; the SwiftUI
  import leaves core when the legacy API is deleted in the cleanup
  step.
- Tests: container rendering per phase (including the gate registry
  receiving the typed trunk value and the context-fed splash), proxy
  connected/disconnected + typed teardown forwarding.
Plan step: where-migration.

- WhereLaunch.plan(for:) replaces the closure sequence: OpenStoreStep
  (Void → WhereServices, the one store open), StartSessionStep
  (WhereServices → WhereSession; fires onServicesReady on every session
  (re)start, as before), the OnboardingGate (pass-through, foreground-
  only, re-evaluated on promotion), SyncAuthStep + ReconcileTrackingStep
  kept sequential on the trunk (reconcile must see the synced
  authorization), then the detached fan (captureToday [foreground],
  reminders, summary, issueAlerts, widgetSnapshot) — independent
  session-configuration steps that no longer serialize or block .ready.
  New LaunchStepID.startSession names the scope promotion.
- Every post-session step takes a NON-OPTIONAL WhereSession as its
  typed input: all the model.session? optional chaining in launch steps
  is gone, and WhereModel.startSession(services:) now takes the
  services and returns the session (no stashed-optional handshake).
  WhereModel.session remains only as the UI mirror for surfaces the
  container doesn't feed; eraseAllData() (optional-chained) is deleted
  in favor of EraseDataStep taking the session directly.
- resetPlan(for:) is the typed teardown, rooted at the session being
  torn down; SettingsView hands its (non-optional) environment session
  in through the new \.lifecycle proxy. The runner now releases the
  retained teardown input after a successful teardown so the dead
  session can't be retained for the process's life (regression-tested
  by the existing weak-reference reset test).
- RootView renders the new LifecycleContainer: content receives the
  session from .ready (the 'if let session' guard is gone), and
  OnboardingView is registered for OnboardingGate — it now takes
  (gate handle, session) instead of a bridge + environment session.
  Scene activation keeps its single role: enterForeground() promotion.
- AppDelegate/WhereApp carry LifecycleRunner<WhereSession>; LaunchPlan
  gains public nodeIDs so the parity tests can inspect order from
  outside the package.
- Tests updated: parity on plan(for:).nodeIDs (with start-session),
  onboarding as a parked gate resolved via its handle, teardown calls
  rooted at the session, and a typed failing-erase plan for the
  teardown-failure path.
Plan step: cleanup-docs.

- Removed the Legacy* linear API and its suites: the closure step
  struct + LifecycleSteps builder, the non-generic runner, the old
  phase enum (LifecycleFailure survives in its own file), the
  LifecycleStepUIBridge (its reporting half lives on in
  LifecycleStepContext; its resolution half in LifecycleGateHandle),
  the old container/proxy, and the per-step presentation machinery
  (minVisible/deferred triggers) — gates render immediately via the
  registry and slow-launch captions are the splash's own concern.
- LifecycleKit is now pure Foundation + Observation: LifecycleSplash,
  LifecycleFailureView, and the localized strings moved to
  LifecycleKitUI (whose bundle now owns the string catalog).
- Docs: LifecycleKit README/AGENTS rewritten for the typed model (step
  protocol, LaunchPlan combinators, value-carrying phase, scope
  convention, memoized retry, cancel-and-drain invariants); Where
  AGENTS + WhereUI README updated for the plan/gate wiring; root
  AGENTS + Project.swift double-link notes now name LifecycleKitUI.
  ./sync-agents run.
- Full Stuff-iOS-Tests scheme green (197 suites) and
  ./swiftformat --lint clean.
Review finding: the run-once memo is keyed by node ID across both the
launch and teardown walks, so a teardown node that collides with a
completed launch node was silently skipped — an erase that never runs —
and, worse than the legacy engine, adopted the launch node's memoized
output as the teardown trunk value, which a later teardown node's typed
input cast would trap on.

teardownErased() now preconditions that teardown node IDs are disjoint
from the launch plan's before anything is torn down, matching the
programmer-error convention (LaunchPlan already preconditions
uniqueness within each plan; this closes the cross-plan gap). Where is
unaffected: LaunchStepID keeps both sets disjoint by construction.
Untestable as a death test on iOS (no exit tests); documented in the
module README + AGENTS instead.
Review finding: the runner surfaces detached-step failures only on its
observable detachedFailures array, which nothing in Where renders or
logs — so the first *throwing* detached step would fail with no trace
in logs, violating the never-silently-swallow rule's 'logs, state, or
both'. (Today's detached steps all wrap non-throwing WhereSession
methods; this is the seam that keeps a future one honest.)

- New DetachedFailureReporter (WhereUI/Launch): observes a runner's
  detachedFailures via withObservationTracking — UI-independent, so
  headless background drives are covered — and logs each new entry
  exactly once at warning level (degraded-but-handled), tolerating the
  runner's per-attempt resets. Kept alive by its own observation chain;
  holds the runner weakly.
- WhereLaunchLog gains the detachedStepFailed(stepID:description:)
  event; WhereLaunch.makeLauncher installs the reporter on every runner
  it builds.
- Tests: exactly-once and reset semantics on report(_:), plus an
  end-to-end observation test driving a real runner whose detached fan
  throws (no view tree involved).
Review finding 5: a parked gate whose type had no GateView registration
degraded to the splash in release — an indefinite spinner that reads as
progress, with no retry and no trace. The realistic exposure is a
conditionally-needed future gate (e.g. migration consent) that never
parks during development and ships unregistered.

The container now logs the misconfiguration (os, subsystem
com.stuff.lifecyclekitui — the kit deliberately has no app logging
facade) and fails the gate's handle with the new MissingGateViewError
(localized description naming the gate), so the drive parks in .failed
at the gate and the normal failure surface shows: visible, retryable,
diagnosable from a screenshot. The debug assertionFailure is dropped so
debug matches production — which also makes the path testable.

The handle is failed from the fallback view's onAppear, not during
body: resolving it resumes the drive's parked continuation, whose phase
write must land after the render commits.

Tests: new container test drives a parked, unregistered gate through
the hosted fallback and asserts the .failed(at: gate) phase carries
MissingGateViewError. Full Stuff-iOS-Tests scheme green (one rerun
after a simulator 'failed preflight checks' launch flake, unrelated to
the change); ./swiftformat --lint clean.
@kyleve

kyleve commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on kve's behalf.

Recommendation, now that the full ladder exists

We explored five points in the design space, each a PR stacked so its diff isolates one decision:

PR What it is Base Δ vs. base (Swift)
#116 (this PR) Typed LaunchPlan — declarative combinator engine main
#117 Prototype A — vanilla async launch functions #116 −412
#120 Prototype C — A minus retry #117 −250
#118 Prototype B — raw async/await, no kit at all #117 −3,000
#121 Prototype D — #116 minus retry, declarative API kept #116 −243

My pick, assuming two more apps land in the repo over the next couple months: ship #121 — this engine with retry removed. Second choice #120. I'd rule out #118 outright.

Why not #118 (raw async, no kit)

It's the leanest by far and the park-based promotion is genuinely elegant for one app — but it deletes the reusable engine. App #2 re-hand-rolls the phase machine, the scene-activation park, the onboarding handle, and the cancelled-attempt-can't-write-phase guard from scratch; app #3 makes it three copies or a from-scratch re-extraction. Its 3,000-line saving is a Where saving that inverts into duplication across a multi-app repo. Right answer only for a single-app repo, which isn't where we're headed.

Why #121 over #116-as-is

Retry is no longer carrying its weight. Its one real historical customer was the fresh-install store-open race — and we fixed that structurally, by injection (the whole point of this PR), not by retry. Prototypes C and D both confirmed the same thing empirically: removing retry deletes ~250 lines and roughly a quarter of the runner's bookkeeping (FailedResume, the resume-index plumbing, RetainedTeardown), and in #121 it retires the finding-1 cross-plan disjointness precondition entirely — while leaving the engine's real conceptual weight (the memo, the walk, promotion) untouched, because that weight is promotion's, not retry's. Failure becomes an honest terminal surface ("relaunch the app"); teardown-failure safety is preserved (a thrown erase leaves state intact). Retry is cheaply re-addable per-app later if one genuinely needs it.

Why #121 over #120 (the function-style twin)

Both remove retry and land within ~10 lines of each other. The real axis is the API surface, and for onboarding new app authors I favor the declarative combinators of #116/#121:

  • Static introspectionnodeIDs, the launch/reset parity tests — survives; the function style has none.
  • The producing-step "can't be skipped" rule is compile-time here vs. a runtime precondition.
  • No "effects must live inside step" footgun: the function style re-runs bare glue on every promotion re-drive, which is a real (if documented) trap the combinator plan doesn't have.

#120's counter-argument is legitimate — a plain async function reads top-to-bottom and is the smaller conceptual surface — so if the team's taste runs to "just write async code," #120 is the close runner-up. But across three apps I'd rather teach one declarative vocabulary with the compiler holding the invariants.

Before app #2 adopts it

  1. App-agnostic API pass. A couple of doc comments still read Where-flavored (e.g. gate examples lean on onboarding); make sure the public surface reads as generic before a second consumer depends on it.
  2. Retire the duplicate simulators. Four devices are named "iPhone 17" (OS 26.1/26.2/26.5/27.0), which caused escalating preflight Busy / server died flakes on the full-scheme runs; pin CI + local runs to a UDID or prune the extras.

Net: merge #116#121 (this PR with retry stripped), keep #118 and #120 as reference for what each axis costs, and do the small API-hygiene pass before wiring app #2.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant