Skip to main content
In this tutorial you take the checks you have been running since Tutorial 1 and make them the gate of a repository. You read the lanes the manifest derives and the list of what verify does not cover, learn what each command proves and what its red looks like, put every command into one workflow with three jobs and a scheduled fourth, and run the mutation drill in both directions: a row whose recording exists is caught, and a row that survives tells you which recording to write. The app does not change on this page; one recording joins it. This is the sixth page of the nine-tutorial series, and Tutorials 7, 8 and 9 each start from this page’s finished tree.
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?

A repository whose main branch cannot go red silently. The checks are the ones Tutorial 5 left you with, tools/duet verify and its neighbours; this page adds nothing to them and puts them where they run on every push. parity/mutations.json gains three rows, each an exact-string edit that seeds a defect into a reducer, and tools/duet mutate proves that the recordings catch every one. The third row survives on the tree you open, which is the exercise: the corpus does not pin the guard it deletes until you record the leaf that does. Expect about ninety minutes, most of it waiting for the first workflow run. You will have at the end:
  • The lanes report for the tree, and a table of the ten commands the checks are made of with what each proves.
  • .github/workflows/parity.yml in a repository of your own: checks, apple and android green on a push, mutate on a nightly schedule with its receipt on the run’s summary page.
  • parity/mutations.json with three rows, and tools/duet mutate ending with 3/3 caught.
  • One new recording, root.late-sign-in-inert, 73 fixtures on the tree, and the exercise stub gone.

Where do you start?

Open tutorial6-start from the duet-tutorials repository. It is Tutorial 5’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 5’s toolchain. Run the checks once before you edit anything:
The Kotlin lane reports one failure, Tutorial6ExerciseSurvivingMutationTest in the root module; 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

Read the lanes the manifest derives

Every check on this tree is derived from parity/manifest.yaml: one entry per feature naming its implementation file, its state, action and effect types, the scenario its recordings are compiled from, and the recordings that pin it. The root’s entry reads:
parity/manifest.yaml
tools/duet lanes prints what the duet tool derives from the file and does not guess at anything else:
Three lines decide the shape of the workflow. There is one lane, the Kotlin lane, because every feature declares a kotlin: path and none declares a swift: one; the Swift side is reached through the Apple boundary instead, which is a script and not a lane. The protocol lane is not part of verify, so it is a step of its own. And the last block is a list of modules verify makes no claim about: the two apps, the backend, the ports, the runner and the Swift packages. Each of those needs its own step, or its tests never run anywhere.
2

Know what each command proves

The checks are ten commands. You have run most of them on earlier pages; this is the list with what each one proves and what its red names.The order matters once: verify refuses to run a lane while a meta-check fails, so a manifest error is reported alone, and record --check after verify tells drift apart from a wrong reducer. You saw the shape of a verify red in Tutorial 1’s exercise, where deleting the arming guard failed one recording at one step; the drill below produces that red on purpose, many times.
3

Run every check once, locally

Run the ten in the order the table lists them, from the tree’s root. The two lines that take minutes are the boundary lane, which assembles the Kotlin core into an XCFramework before it replays anything, and the first Gradle invocation, which downloads the wrapper’s distribution. Every later run is warm.
Each command ends with its own PASS line or with nothing, which for Gradle is the same. The tutorials repository wraps exactly this sequence in scripts/run-tree.sh, and its CI runs that script; the workflow you write next is the same sequence with a runner around each part.
4

Put the checks in a workflow

tutorial6-complete carries .github/workflows/parity.yml, a workflow for a repository shaped like this tree. GitHub reads workflows at a repository’s root, so the file does nothing where it sits inside the tutorials repository; it is the file you copy. Its header states the split:
.github/workflows/parity.yml
The checks job is the first eight commands with a comment above each. Two steps carry the shape you would otherwise get wrong. The recording check is scoped per feature and per chain, never unscoped, because an unscoped check refuses on a tree whose feature declares both lanes, and the scoped form checks the same fixtures on any tree:
.github/workflows/parity.yml
The android job runs on ubuntu, because the hosted ubuntu image ships the Android SDK and the macOS image does not; it is the whole reason the workflow has two runners:
.github/workflows/parity.yml
The apple job is the boundary lane and the iOS build behind a cache of the Kotlin/Native toolchain, keyed on the version catalog and saved even when the lane is red so the next run is warm. The mutate job is the subject of the last two steps.To run it, make the tree a repository of its own and push it:
The Actions tab shows one run with three jobs. For scale, the tutorials repository’s own runs on this tree, which run the same commands as one macOS job and one ubuntu job, take about eight minutes on the macOS runner with the toolchain cache warm and under two on ubuntu; the first run downloads the Kotlin/Native toolchain and takes longer. Every step’s name is the command it ran, so a red job reads the same as the same command red on your machine.
5

Write the first mutation rows

The recordings are tests; the drill tests them. parity/mutations.json lists edits that each seed one defect into a reducer, and tools/duet mutate applies them one at a time, runs verify, and requires it to go red. A row that leaves verify green has found a behavior no recording pins. The first two rows target guards you know: the splash’s arming guard from Tutorial 1, and the root’s hold on the phase while the session is unknown, from Tutorial 3:
parity/mutations.json
old must match the named file exactly once; new replaces it; seeds is the defect in one sentence, and the drill prints it beside the verdict. The first row turns the guard into a condition that is never true, so every Appeared arms a new net:
src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashFeature.kt
The second drops next.awaitingAuth from the root’s AuthChanged arm, so the session’s first report would move the phase with the splash still on screen:
src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt
Run one row by name while you write it:
The drill verifies the clean tree first, because on a red tree every row would count as caught. It names the seeded file before the run, restores the exact prior bytes after it, and reports which lane went red. splash.repeat-appear-inert is the recording that caught this one; you deleted the same guard by hand in Tutorial 1 and read the same red.
6

Run the table, and read the row that survives

The third row targets the root’s guard on a late sign-in completion: the SignIn arm ignores a Completed that arrives when the gate is not the mounted child. The row compares the wrong phase, so a completion under main would be accepted and would replace the session:
parity/mutations.json
src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt
Run the whole table:
The third row survives on the tree you opened: every root recording sends the gate’s Completed while the gate is up, so a reducer that accepts it under main passes them all. The receipt table is the drill’s output and the reason the mutate job in the workflow writes it to the run’s summary page:
.github/workflows/parity.yml
The job runs on the schedule and on demand, never on a push: each row is a full verify, and the table grows with the app. Trigger it once from the Actions tab and read the table there; the third row is red until the exercise below is done.
A GitHub Actions run summary for a workflow named nightly.yml, showing a green plan job, a green mutate job that took 3 minutes 23 seconds, and two green matrices of 12 Android and 15 macOS jobs, above a Gradle job summary table with green build outcomes.

The tutorials repository's nightly run on the branch that added tutorial6-complete: the tree matrix and the mutate job green, the drill's first run on a hosted runner; Duet 0.7.0, duet-tools 0.24.0.

What you now have

  • The lanes report read, and the list of modules verify makes no claim about, each with its own step in the workflow.
  • Ten commands, each ending in its own pass line, run in one order locally and in the same order by the workflow.
  • A repository of your own whose checks, apple and android jobs are green on a push, with the Kotlin/Native toolchain cached between runs.
  • A mutation table with three rows and the drill’s receipt on a nightly run’s summary page; two rows caught, one surviving until the recording below exists.

Exercise: record the leaf that catches the surviving row

tutorial6-start carries Tutorial6ExerciseSurvivingMutationTest, a failing placeholder in the root module. The surviving row deletes the guard that keeps a late sign-in completion inert, and no recording sends one. Add a branch to the root scenario that brings main up and then delivers a Completed from a gate that is gone:
src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RootScenarioTest.kt
List root.late-sign-in-inert in the manifest under the root’s fixtures and in the root’s feature spec, add its replay to the golden test, and delete the stub:
src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RootGoldenTest.kt
Run tools/duet record --feature root, then tools/duet verify, which replays 73 fixtures, then the drill:
tutorial6-complete carries the recording, the table and the workflow. A mutation that survives has told you exactly which recording is missing, and the recording you add is a test the drill will keep checking on every nightly run.

Common questions

Each row is a full verify run plus a baseline: seconds per row on a laptop with a warm Gradle daemon, as the output above shows, and under three minutes for the baseline and three rows on a hosted macOS runner that starts cold, with the table growing as the app does. The drill also edits source files while it runs, which a per-push gate must not do. The workflow gives it a schedule and a manual trigger, and the summary page carries its table, so a surviving row is read the next morning rather than blocking a push.
Then the guard the row deletes is unobservable, and the row does not belong in the table. The root has one such guard: the arm that ignores a splash Completed after the splash phase. Without it, a late completion re-enters the phase the session already implies, which is the phase the root is in, with no effect, so every recording passes with the guard gone and no recording can be written that fails. The guard stays as a statement of intent; the table holds only rows whose defect a recording can see.
GitHub reads workflow files from a repository’s root .github/workflows/ directory only. The tutorials repository is many trees in one repository, so the file in tutorial6-complete/.github/workflows/ is inert there; the repository’s own workflows run scripts/run-tree.sh over every tree, which runs the same ten commands. In a repository of your own the tree is the root, and the file runs as written.
The macOS runner carries Xcode, which the Apple boundary lane and the iOS build need, and the hosted macOS image ships no Android SDK. The hosted ubuntu image ships the SDK and no Xcode. One job per runner, each with the steps that runner can carry, is the whole arrangement; the alternative is installing an SDK on every macOS run.
A row targets source text, and source text moves. A row whose old no longer matches, or matches twice, would either seed nothing or seed the wrong site, and either way the verdict would be meaningless; the drill fails on it as a configuration error, so the table sweeps itself when a reducer is refactored. Recordings do not have this problem, which is why a row is a negative control for the recordings and not a replacement for them.
Because you run it on a tree with uncommitted edits, while writing the next row. The drill reads the file, applies the substitution, runs the suite, and writes the bytes it read back, so your edits to that file survive. If the drill is interrupted mid-row the file is left mutated, which is why it prints the git checkout line before every run.

Sources and further reading

Tutorial 5: Navigation as State

The tree this page puts under CI: the onboarding gate, the upgrade flow, the deep links and the route spine.

Tutorial 7: Theming with Design Tokens

The cards move onto a token vocabulary generated for both platforms, and a high-contrast theme ships as one mapping with no feature-code change.

Tutorial 8: Localizing the App

Every string moves into string catalogs on iOS and resources on Android, German joins as a second language, and no recording changes.

Tutorial 9: Adding Analytics

Seven events emitted by the reducers as effect data, a console sink worker on each platform, and the recordings checking every event.

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