What will you build?
Six new features and a new phase. Theonboarding 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
WorkerTestersuite on each side. - The upgrade flow presented as a sheet on iOS and a modal bottom sheet on Android from the same
sheetvalue. - 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?
Opentutorial5-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:
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
Draw the routes before the screens
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.Carry the onboarding fact on the session
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.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.Write the onboarding level and its steps
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.validateDisplayName, added to the editname module, so the two screens that accept a display name never disagree: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.Write the upgrade flow
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.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: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.Mount the flow in each platform's idiom
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.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 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.
Grow the root: the second gate and the deep link
Onboarding, and one function decides where a signed-in session goes. The gate’s Completed and the stream’s AuthChanged both feed it; the onboarding gate’s Completed writes through the account port and mounts main.forwardLink and completeOnboarding. The main level handles OpenLink by setting its sheet or its tab and forwarding the account link on down to the profile tab, whose OpenLink mounts the account screen exactly as the row does. Each shell registers the scheme and hands the URL over: Android through an intent filter and onNewIntent under singleTask, iOS through CFBundleURLTypes and the scene’s openURLContexts.forwardLink effect can run before the shell has applied the phase change that mounts main, so each root mount holds a forwarded link until its main child is published. Re-record root; add the link forwards under main and link waits for main branches first, and the gate picks onboarding and onboarding completes into main branches. The chain chain-root-onboarding in RootChainsTest pins the second gate’s seam.Gather the route spine and restore from it
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.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:
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.
Make back an action on Android
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.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.Run both apps

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.
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
WorkerTesteron both sides and no recording. - The flow presented in each platform’s idiom from one
sheetvalue, 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:
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
Why not restore the root's phase directly and skip the splash?
Why not restore the root's phase directly and skip the splash?
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.Why is the progress row's readiness an effect and not a slice?
Why is the progress row's readiness an effect and not a slice?
Why is the sheet the one surface that differs between the platforms?
Why is the sheet the one surface that differs between the platforms?
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.Why does the worker on iOS split into a worker and a seam?
Why does the worker on iOS split into a worker and a seam?
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.Why is the hasOnboarded flag on the session and not in root state alone?
Why is the hasOnboarded flag on the session and not in root state alone?
Sources and further reading
- The Duet framework repository —
docs/composition.md, the child slot and the state-down projection the level uses;docs/workers.md, the lateral-state shape, one ancestor-owned worker with a void update and a sticky observation;ChildSlot,StateTransitionsandRelayin the shells packages. - The duet-tools repository — the chain dialect the closing exercise records, and
duet doctor’s worker rule. - Android: Save UI states — the saved-instance
Bundlethe Activity writes the spine to, and when the system restores it. - Android: Add support for the predictive back gesture — the
BackHandlerdispatch order the back policy relies on. - Apple: Restoring your app’s state — the scene’s restoration activity the spine rides on iOS.
- Apple: Defining a custom URL scheme for your app —
CFBundleURLTypesandopenURLContexts. - The duet-tutorials repository —
tutorial5-startandtutorial5-complete, and the checks CI runs on them. - The Duet glossary — delegate, worker, mount and golden recording.