Skip to main content
In this tutorial you make every screen of Foyer reachable from state alone. Tutorial 4 left the app with two gates and a locked card; here the root gains an onboarding gate with three steps and a progress row, the main level gains a sheet slot that mounts an upgrade flow from either tab, the app answers two deep links, and each level’s route sliver is gathered into one value that both apps save before the process dies and rebuild the tree from afterwards. Back is an action on the level that owns the page. The progress row learns each step’s readiness through a seam between siblings, not through the parent. This is the fifth page of the nine-tutorial series.
The manual setup below is what Modaal automates. Every tree this tutorial opens and every file it asks you to write is produced by the Duet templates in the Modaal new-project wizard, and a coding agent working in Modaal writes the feature, records it and runs the checks for you. This series walks the same ground by hand so you know what the scaffold emits and why: start a Duet project in the wizard when you would rather skip the setup.

What will you build?

Six new features and a new phase. The onboarding level owns a page and mounts one of three step leaves, welcome, name and preferences, beside a progress leaf that lives for the level’s lifetime; the session carries whether the account finished the steps, and the root mounts the gate from that fact. The upgrade flow walks plans, confirm and done as route state, buys through the purchases port and completes through its delegate; the main level mounts it in a sheet slot when either tab asks, and the card still unlocks from the entitlement stream and nothing else. Two links, foyer://upgrade and foyer://profile/account, are parsed into a value and forwarded down the tree one level at a time. A RouteSpine gathers the phase, the tab, the profile tree’s depth, the home tab’s presented screen, the flow’s step and the onboarding page; Android saves it in the instance Bundle, iOS in the scene’s restoration activity, and both rebuild the tree from it. Expect about three hours. You will have at the end:
  • Six new logic modules with 20 recordings, 33 re-recorded for the five that changed, 72 fixtures on the tree, and three new chains, one of them the closing exercise.
  • A readiness worker per platform, owned by the onboarding level’s composition, with a WorkerTester suite on each side.
  • The upgrade flow presented as a sheet on iOS and a modal bottom sheet on Android from the same sheet value.
  • Both deep links opening on both platforms, from a cold start and while the app runs.
  • The Android app resumed inside the name editor after process death, and an Android back policy pinned by a test.

Where do you start?

Open tutorial5-start from the duet-tutorials repository. It is Tutorial 4’s finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0, duet-services 0.11.1 and the KSP mock processor 0.2.1 with Tutorial 4’s toolchain. Run the checks once before you edit anything:
The Kotlin lane reports one failure, Tutorial5ExerciseUpgradeEntitlementChainTest; that is the exercise, and everything else is green. TUTORIAL_SKIP_STUBS=1 tools/duet verify leaves the stub out and ends with duet verify: PASS.

The steps

1

Draw the routes before the screens

Every screen in Foyer is the consequence of a value: the root’s phase, the main level’s activeTab and sheet, the profile tab’s child, the home tab’s presented, the flow’s step, the onboarding level’s page. This tutorial adds the values in the shaded boxes; the arrows are delegate events received as the parent’s actions, as in Tutorial 3.Nothing on the diagram is a navigation call. A level changes its own route value in its reducer, and the shell mounts and unmounts children from that value with the same reconciler Tutorial 3 introduced. Back, a deep link and a restore are three more ways a route value changes; none of them needs a second mechanism.
2

Carry the onboarding fact on the session

The root needs one fact to pick between main and the onboarding gate: whether this account finished the steps. The backend owns the fact and the session stream carries it, the same way the stream carries a saved name in Tutorial 4, so no feature seeds it and no second read can disagree with it.
src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt
The sign-in outcome and the gate’s Completed carry the same flag, so the root learns it from the gate at the moment the gate completes, without waiting for the stream’s next value. The account port gains one void call, the onboarding write; the session stream carries its result.
src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt
src-kmp/backend-local/src/commonMain/kotlin/dev/modaal/foyer/backend/LocalAccount.kt
LocalRecord gains hasOnboarded and preferences, and LocalAuth builds every session value from the record, so a relaunch reports the fact with the stream’s first value. LocalAccountTest pins the write, the stream and the next sign-in in one test. Re-record signin, whose Completed event grew a field.
3

Write the onboarding level and its steps

The level’s state is its page and the two answers the steps hand up. Each step’s Continued arrives as the level’s action and moves the page; the last one climbs Completed; Back moves the page back and is inert on the first page.
src-kmp/subtrees/onboarding/logic/src/commonMain/kotlin/dev/modaal/foyer/onboarding/OnboardingFeature.kt
src-kmp/subtrees/onboarding/logic/src/commonMain/kotlin/dev/modaal/foyer/onboarding/OnboardingFeature.kt
The page value itself lives in the ports module, because the steps and the progress row name it too:
src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Onboarding.kt
The three step leaves are the smallest features on the tree. The name step reuses the editor’s validation through one shared function, validateDisplayName, added to the editname module, so the two screens that accept a display name never disagree:
src-kmp/subtrees/name/logic/src/commonMain/kotlin/dev/modaal/foyer/name/NameFeature.kt
Each edit re-validates the draft and publishes the readiness when it changed. That publish is the next step’s subject. Write the welcome and preferences leaves the same way (the finished tree has both), add the five modules to the settings file, the umbrella’s exports and both replay registries, write their scenarios, and record them: tools/duet record --feature onboarding, and the same for welcome, name, preferences.
4

Share readiness laterally

The progress row shows “step n of 3” and a tick per step that is ready to continue. The page reaches it the way the entitlement reached the tabs in Tutorial 4: projected down from the level that owns it. The readiness cannot reach it that way, because the level does not have it; each step does, and a step never holds a sibling’s store. The shape for a value that must cross between siblings is one worker, owned by their lowest common ancestor, with a void update on one side and a sticky observation on the other. Neither side names the other; each sees one narrow port.
src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Onboarding.kt
A step’s reducer emits PublishReadiness(step, ready) and its handler makes the one call; nothing re-enters. The progress row’s reducer starts the observation once, on its first appearance, and its handler keeps the flow open for the store’s lifetime, turning every delivery into one action:
src-kmp/subtrees/progress/logic/src/commonMain/kotlin/dev/modaal/foyer/progress/ProgressRuntime.kt
The store’s teardown cancels the flow, which cancels the subscription; ProgressTestStoreTest pins that with the generated environment double and a hand-written sticky source behind its handler. The worker itself is native per platform. On Android it is a MutableStateFlow behind both ports, adopted by the level’s host; on iOS the worker is main-bound and holds a CurrentValueSubject behind the two ports:
The level’s Component owns the worker lazy and answers both Dependencies with it; the steps’ Dependency has readinessUpdates, the row’s has readinessObservation. The level’s composition adopts the worker last, after the row and the step slot, as the root adopts its two workers in Tutorial 4:
The worker has no recording and no scenario; its one contract is behavioral, that a late subscriber sees the current value first, and WorkerTester pins it on both sides:
Run tools/duet record --feature progress and tools/duet verify. The two shapes now sit on one screen: the page comes down from the parent as a projection, the readiness comes across from siblings through the seam, and the row’s reducer treats both as plain actions.
5

Write the upgrade flow

The flow’s step is route state with three cases, and Back is an action the reducer reads against the step: from confirm it returns to the plans, on the plans it climbs Dismissed, on done it climbs Completed. The purchase goes through the purchases port and its answer moves the step. No entitlement appears anywhere in this module.
src-kmp/subtrees/upgrade/logic/src/commonMain/kotlin/dev/modaal/foyer/upgrade/UpgradeFeature.kt
src-kmp/subtrees/upgrade/logic/src/commonMain/kotlin/dev/modaal/foyer/upgrade/UpgradeFeature.kt
The scenario’s select confirm purchase done branch pins the walk and the back walks the steps branch pins Back on two steps. The home tab’s promo loses its one-tap purchase: UpgradeTapped closes the promo and climbs UpgradeRequested, and the profile tab’s plan row climbs the same event from PlanTapped. The main level gains the slot both reach:
src-kmp/subtrees/main/logic/src/commonMain/kotlin/dev/modaal/foyer/main/MainFeature.kt
src-kmp/subtrees/main/logic/src/commonMain/kotlin/dev/modaal/foyer/main/MainFeature.kt
The flow’s Completed clears the sheet and climbs nothing, which is the point the closing exercise pins: the card unlocks when the entitlement stream emits, through the root’s slice, as in Tutorial 4. Record upgrade, re-record home, profile and main, and delete the two home fixtures the promo’s purchase owned; tools/duet lint names them.
6

Mount the flow in each platform's idiom

The main level’s composition mounts the flow from state.sheet through a single-slot reconciler, exactly as the profile tab mounts the account screen. The one difference from every other screen in the series is the presentation: the slot takes each platform’s idiom with no extra code, a .sheet on iOS and a ModalBottomSheet on Android. The recordings do not change; the difference is manner, not behavior, and the presentation ledger has no entry for it.
Each idiom’s own dismissal reaches the flow as DismissTapped: the sheet’s swipe on iOS through the presentation binding, the bottom sheet’s swipe and the system back press on Android through onDismissRequest. The flow decides what a dismissal means on each step: Dismissed before a purchase, Completed after one. The Back button inside the flow walks the steps.
The plans step with a Monthly card at $4.99 and a Yearly card at $39.99 over the home tab, presented as a full sheet on an iPhone simulator on the left and as a modal bottom sheet on an Android emulator on the right.

The upgrade flow's plans step over the home tab, the one surface shown in each platform's idiom: a sheet on iOS, a modal bottom sheet on Android, both mounted from the same main.sheet value; tutorial5-complete at Duet 0.7.0, duet-tools 0.24.0.

8

Gather the route spine and restore from it

Process death loses every store. What must survive is each level’s route sliver and nothing else: the items reload, the session and the entitlement arrive through the streams, and the answers already persisted. One value in the root module gathers the slivers, encoded by one function and decoded tolerantly, so a stale payload restores nothing and never fails a launch.
src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RouteSpine.kt
RouteSpineTest pins the encoding as a golden string and the tolerant decode. Each composition reads the spine from its live stores when the platform asks, and applies it as each store’s initial state when it builds: every make*Store gained a parameter for its sliver, and the reconcilers mount from those initial values without any imperative unwind. The splash replays on a restore, and the spine’s phase says whether the slivers below it apply.
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt
The host test RootFlowTest walks it: sign in, onboard, open the profile tab, the account screen and the editor, encode the spine, tear the tree down, rebuild it from the decoded spine over the same memory file, and find the editor mounted after the splash; a payload from another version restores the default tree. RootCompositionSpec does the same across the boundary. To watch it on a device, open the editor, background the app and kill the process:
Reopen the app from the launcher. After the splash, the editor is on screen with the name in its field.
The Edit name screen of the Android app with the display name field filled in, reached without tapping anything after the process was killed and the app reopened.

The Android app after process death, restored from the saved route spine inside Account with the name editor mounted; tutorial5-complete at Duet 0.7.0, duet-tools 0.24.0.

9

Make back an action on Android

A back press on Android reaches the last composed enabled BackHandler. The tree composes its handlers shallow to deep with the sheet last, and every handler is enabled by one predicate in a pure table, so the winner per state is a function of the spine and a test replays it.
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/BackPolicy.kt
Each handler sends one action into one store: OnboardingAction.Back on the level, HomeAction.Dismissed, AccountAction.CloseTapped, EditNameAction.CancelTapped; the sheet’s own back handling sends UpgradeAction.DismissTapped. The splash and the sign-in gate install no handler and the onboarding gate’s first page installs none, so back on a gate leaves the app. BackPolicyTest pins that a hidden tab’s handlers are dead and that the sheet wins over everything below it. iOS has no system back; its screens offer their own Back buttons, which send the same actions.
10

Run both apps

Build and run each app as in Tutorial 2. Sign in with a new address and the onboarding gate comes up: the progress row shows step 1 of 3 with the first tick already filled, because the welcome step published its readiness when it appeared. Type a name and the second tick fills while you type; clear it and the tick empties.
The onboarding name step with a display name typed into the field, a progress row reading step 2 of 3 with two of three ticks filled and a linear bar at two thirds, on an iPhone simulator on the left and an Android emulator on the right.

The name step with a valid name typed, the progress row showing step 2 of 3 and the tick from the lateral value; the same layout on both platforms; tutorial5-complete at Duet 0.7.0, duet-tools 0.24.0.

Finish the steps and main comes up with the name you entered. Open the promo from the locked card and buy through the flow; the card unlocks when the stream emits, and the plan row shows the plan. Then try the links:
Each opens the screen it names on the app that receives it, with the app running or from a cold start; on a cold start the link waits through the splash. scripts/run-tree.sh tutorial5-complete runs every check on the tree; the Android job’s unit tests include the restore, the back policy and the worker.

What you now have

  • Six new logic modules, 72 fixtures on the tree and three new chains; the onboarding gate and the upgrade flow as route state, with Back as an action on the level that owns the page.
  • A readiness worker per platform, owned once by the onboarding level’s composition, with the sticky delivery pinned by WorkerTester on both sides and no recording.
  • The flow presented in each platform’s idiom from one sheet value, and the card unlocking from the stream alone.
  • Two deep links parsed by one function, forwarded down the tree as effects and held until main mounts.
  • A route spine encoded once, saved by each platform’s own mechanism, and applied as initial state on rebuild; a back policy as pure predicates with a test.
  • Every check green: tools/duet verify, tools/duet mocks --check, the backend’s tests, the Android unit tests and the Apple lane.

Exercise: record the entitlement chain

tutorial5-start carries Tutorial5ExerciseUpgradeEntitlementChainTest, a failing placeholder in the home module. Delete it and write the chain in the main module, whose module sees all three nodes: the flow on its done step sends DoneTapped and emits exactly the Completed delegate; the hop carries it into the main level as Upgrade(event), which clears the sheet and emits nothing; the home node, still on its initial Free state, receives EntitlementChanged(Premium(Monthly)) as the root’s slice and unlocks. List chain-upgrade-entitlement in the manifest and in the three participants’ feature specs. The finished hop reads:
src-kmp/subtrees/main/logic/src/jvmTest/kotlin/dev/modaal/foyer/main/MainUpgradeEntitlementChainTest.kt
Run tools/duet record --chain chain-upgrade-entitlement, then tools/duet verify; the Kotlin lane replays 72 fixtures and the run ends with duet verify: PASS. tutorial5-complete carries the finished test.

Common questions

Because the phase is the consequence of the session, and the session arrives through the stream after the workers start. Restoring main before the auth snapshot is known would mount the profile tree with no name to show. The splash replays, the stream’s first value decides the phase as it always does, and the spine’s slivers apply when the child they belong to mounts. The spine’s phase field is a guard: slivers under a phase the app does not enter are dropped.
A slice carries a parent’s state down to a child, and the readiness is not the parent’s: each step has its own. Neither step can send the row an action or hold its store, so the value crosses through a seam the level owns, and the row reads it as any other environment stream, one action per delivery. The page, which is the level’s own value, takes the slice route. The two arrive at the same reducer as two plain actions.
The main level mounts the flow from state.sheet through the same reconciler every other child uses; what the two shells do with the mounted child is presentation. On iOS a .sheet and on Android a ModalBottomSheet are each the platform’s own surface for a screen over the tabs, and each takes the child with no extra code. The recordings are identical, the chain is identical, and the presentation ledger records behavior-visible divergence only.
The framework’s checks require a worker to be isolated, and this app’s workers are main-bound. The Kotlin port interfaces carry no isolation, so a main-actor class cannot satisfy them directly; the seam is the worker’s one stored value, an object that conforms to both ports, and the worker’s run() parks until the level’s host cancels it. On Android the worker conforms to both ports itself, because a Kotlin interface has no isolation to reconcile.
The root does keep it, inside the auth snapshot, but it never derives it. The backend is the one writer: a relaunch, a second device or a support reset would each leave a root-owned flag wrong. The session stream already carries every account fact the app reads, the display name since Tutorial 4, so the onboarding fact joins it and the root reads one value from one source.

Sources and further reading

Tutorial 6: The Checks in CI

The lanes the manifest derives, every check as one workflow in your own repository, and the mutation drill run both directions.

Tutorial 4: Workers

The streams, the workers and the slice this page’s onboarding gate and readiness seam build on.

The Duet tutorial series

The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
Last modified on September 8, 2026