Skip to content

Prototype A: vanilla async launch functions over the typed engine#117

Open
kyleve wants to merge 2 commits into
typed-lifecyclekitfrom
typed-lifecycle-vanilla
Open

Prototype A: vanilla async launch functions over the typed engine#117
kyleve wants to merge 2 commits into
typed-lifecyclekitfrom
typed-lifecycle-vanilla

Conversation

@kyleve

@kyleve kyleve commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Problem

This is a prototype for comparison, not a default-merge candidate. The question it answers: how much of the combinator engine's (#116) safety survives if setup and teardown become ordinary async functions — and what does the simplification actually buy?

The combinator LaunchPlan bought compile-checked data flow at the cost of a whole concept layer: step protocol conformances, plan combinators/builders, erased node internals, and one extra hop between "what the launch does" and where you read it.

Changes

Setup and teardown are now vanilla async functions driven through a LifecycleContext:

{ context in
    let services = try await context.step(.openStore) {  }     // Void → WhereServices
    let session  = try await context.step(.startSession) {  }  // uses `services`
    if !model.hasOnboarded {
        try await context.gate(OnboardingGate(), value: session)
    }
    try await context.step(.syncAuth) {  }
    context.detached(.reminders) {  }                          // never blocks .ready
    return session                                              // → .ready(session)
}
  • Deleted: LaunchPlan + combinators/builders/erased nodes and the LifecycleStep protocol; LifecycleGate slims to id + modes + Value (conditionality is the caller's if; the type survives because the UI registry keys on it). Where's step types fold back into WhereLaunch.launch(for:)/reset(for:) with their doc comments.
  • Unchanged: the value-carrying Phase (.ready(Launch), gate handles), the entire LifecycleKitUI layer (container, gate registry — only the proxy's teardown(input:_:) signature changed), cancel-and-drain, superseded-drive rules, promotion semantics, detached drain-before-relaunch, teardown-input release, DetachedFailureReporter.
  • Retry and promotion collapse into one code path: re-run the function with the run-once memo. Deliberate semantic change, pinned by a test: plain ifs before a failed step re-evaluate on retry.

What's kept vs. lost (the actual trade)

Guarantee Combinators (#116) This prototype
Ordering (use-before-produce) then constraints plain data flow through lets — equivalent
Producers can't be skipped runtime precondition compile-time (no modes: on the producing overload; if forces an else)
Detached can't be depended on Output == Void constraint Void bodies — equivalent
Skipped gate re-evaluates on promotion engine rule unmemoized skip + re-run — equivalent, tested
Memoized retry types combinator-guaranteed casts ID discipline: one ID = one call site; type-checked memo hits + duplicate-ID traps fire deterministically on first full run
Static structure (nodeIDs, parity tests) yes lost — replaced by @_spi(Testing) executed-step recording
Effects between steps impossible (everything is a node) new footgun: bare glue re-runs on every re-drive; documented convention + pinning test
Launch/teardown ID collisions fail-fast precondition non-issue: per-site memo namespaces

Net: −412 lines of Swift (−443 overall including docs; 1,365(+) / 1,808(−)), one whole concept layer removed, and Where's launch reads top-to-bottom in one function.

Testing

Same coverage shape as the base branch, ported: runner drive/gate/detached/promotion/retry/teardown suites, both seeded fuzz suites (200 randomized functions vs. an independent model, 120 flaky-retry drains — the fuzz now builds its launch closures dynamically), plus new pins for the changed retry semantics, the bare-glue-re-runs rule, and executed-ID recording. LifecycleKitUI suites pass with only construction-site updates; Where suites port mechanically (parity via executed IDs). Full Stuff-iOS-Tests scheme green; ./swiftformat --lint clean.

kyleve added 2 commits July 22, 2026 13:31
Plan steps: vanilla-core, vanilla-ui-seam, vanilla-where — landed as one
commit because the change is compile-coupled across the three layers
(deleting the plan API breaks every consumer in the same build), unlike
the incremental combinator migration this branch is based on.

Core (LifecycleKit):
- Setup and teardown are now ordinary async functions run through a
  LifecycleContext: data flows through lets (ordering is plain Swift),
  conditionality through ifs, and the context wrappers add exactly what
  a bare function can't — phase publication, run-once memoization, and
  failure attribution. Only the Void step overload accepts modes: (a
  value-producing step can't be skipped — now compile-time, replacing
  the combinator engine's runtime precondition), and detached bodies
  return Void so nothing can depend on fire-and-forget work.
- LaunchPlan, its combinators/builders/erased nodes, and the
  LifecycleStep protocol are deleted; LifecycleGate slims to
  id + modes + Value (conditionality is the caller's if; the type
  survives because the UI registry keys on it). Phase, gate handles,
  cancel-and-drain, superseded-drive rules, promotion, detached
  drain-before-relaunch, and teardown-input release all carry over.
- Memo integrity replaces construction-time validation: per-site
  launch/teardown memo stores (teardown IDs may now legally reuse
  launch IDs — the disjointness precondition is gone), a type-checked
  memo hit that traps on ID reuse across call sites, and a per-walk
  seen-IDs precondition that traps duplicates on any complete run.
- Retry and promotion collapse into one code path: re-run the function
  with the memo. Deliberate semantic change, pinned by a new test:
  plain ifs before a failed step re-evaluate on retry. A throw outside
  any step is attributed to LifecycleFunctionID.launch/.teardown.
- @_spi(Testing) executedStepIDs recording replaces nodeIDs for
  order-style assertions (the function has no inspectable structure).
- Known footgun, documented loudly and pinned by a test: bare glue
  between steps re-runs on every re-drive; effects belong inside steps.

UI (LifecycleKitUI): only the LifecycleDriving seam and
LifecycleProxy.teardown change shape — the typed input + body are
captured into a closure at the call site to cross the non-generic
environment seam. Container, gate registry, and views untouched.

Where: the step types fold back into WhereLaunch.launch(for:)/
reset(for:) as one readable function each (doc comments moved inline);
LaunchStepID and OnboardingGate survive; SettingsView passes the reset
function + session through the proxy. RootView/AppDelegate/WhereModel
unchanged.

Tests: kit runner/reset/fuzz suites ported to the closure API (the
200+120 seeded fuzz suites now build their launch functions dynamically
from the same element lists); UI and Where suites updated; the parity
test asserts on executed-step recording. Full Stuff-iOS-Tests scheme
green; ./swiftformat --lint clean.
… model

Plan step: vanilla-docs-pr (docs half).

- LifecycleKit README/AGENTS now describe the async-function engine: the
  context wrappers, the effects-inside-steps discipline (bare glue
  re-runs on every re-drive), the memo/one-ID-one-call-site rules and
  their deterministic runtime traps, retry/promotion as one re-run code
  path, and the executed-ID SPI that replaces static structure
  inspection.
- LifecycleKitUI docs + Where AGENTS/README updated for the
  teardown(input:_:) proxy signature and the launch-function shape.
  ./sync-agents run.
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