# RD-07 — Move the step-boundary decision into the 3 wizard machines Status: done Source: PLAN.md 1a ## Why Each wizard component decides in the UI whether the primary button means "next step" or "submit", duplicating a rule the reducer already owns: | Component | Line | Body | | ------------------------------------ | ------- | -------------------------------------------------------------------------------- | | `herregistratie-wizard.component.ts` | 256-259 | `if (s.tag !== 'Editing') return;` then `s.step < 3 ? Next : Submit` | | `intake-wizard.component.ts` | 368-371 | `if (s.tag !== 'Answering') return;` then `step() === 'review' ? Submit : Next` | | `registratie-wizard.component.ts` | 613-616 | `if (s.tag !== 'Invullen') return;` then `step() === 'controle' ? Submit : Next` | Each already-exported `next`/`submit` pair holds the real transition, so the component is re-deriving a decision the machine can make. After this ticket, `onPrimary()` in RD-08 becomes a single `dispatch({ tag: 'Primary' })`, and the wizard shell's `primary`/`back`/`retry` outputs map 1:1 onto messages — which is what `.claude/skills/form-machine/SKILL.md:74-78` already claims they do. This ticket is **domain-only**: machines and their specs. No component changes. ## Read first - `apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts` — `next` at 87, `submit` at 118, `reduce`'s `Next`/`Submit` cases at 165/171 - `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` — `currentStep` at 86, `next` at 173, `submit` at 197, cases at 226/232 - `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts` — `currentStep` at 96, `next` at 265, `submit` at 284, cases at 337/343 - The three matching `*.machine.spec.ts` files - CLAUDE.md decision 3 (state, and the naming rule for machines) ## Decisions (pre-made, don't relitigate) 1. **Add `{ tag: 'Primary' }` to each of the three `Msg` unions**, plus an exported `primary(s)` function, plus a `case 'Primary'` in each `reduce` that delegates to it. Same shape as the existing `Next`/`Submit` cases. 2. **Express "last step" in each machine's own vocabulary. Do NOT invent a shared `isLastStep` helper.** The three state shapes genuinely differ: - `herregistratie`: `Editing` carries `step: 1 | 2 | 3` and there is **no `STEPS` array** → the test is `s.step === 3`. - `intake`: `Answering` carries `cursor: number` against `STEPS = ['buitenland','werk','review']` → the test is `currentStep(s) === 'review'`. - `registratie`: `Invullen` carries `cursor: number` against `STEPS = ['adres','beroep','controle']` → the test is `currentStep(s) === 'controle'`. Two of the three could share a `cursor === STEPS.length - 1` form, but the third cannot. A helper covering two of three, plus a special case, is more to read than three plain expressions. 3. **`primary` delegates to the existing exported `next` and `submit`.** Write no new validation and duplicate no transition logic. The whole function is a branch: ```ts export function primary(s: WizardState): WizardState { if (s.tag !== 'Editing') return s; return s.step === 3 ? submit(s) : next(s); } ``` (…and the equivalent, in its own vocabulary, for the other two.) 4. **The guard moves into the machine.** `primary` returns `s` unchanged when the state is not the editing state, so RD-08 can delete the component preamble. Note the editing tag differs per machine: `Editing`, `Answering`, and `Invullen`. **`Invullen`/`Indienen`/`Ingediend`/ `Mislukt` are correct Dutch domain tags per CLAUDE.md — do not "fix" them to English.** 5. **KEEP `Next` and `Submit` in all three `Msg` unions.** Verified: they are dispatched across **9 spec files**, including `intake.acceptance.spec.ts`, which uses them as a readable behaviour narrative, and the three `*-has-progress.spec.ts` files. Removing them would rewrite dozens of spec lines for no gain. `Primary` is purely **additive**. 6. **Do not touch any component.** RD-08 migrates the three wizards. If you edit a `*.component.ts` in this ticket, it is out of scope. 7. **Keep `SCHOLING_THRESHOLD_DEFAULT` greppable.** `npm run check:seam` greps for it as a top-level `export const` in `intake.machine.ts:43`. Do not move or inline it. ## Files - `apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts` + `.spec.ts` - `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` + `.spec.ts` - `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts` + `.spec.ts` ## Steps 1. In each machine: add `{ tag: 'Primary' }` to the `Msg` union, export `primary(s)` next to `next`/`submit`, and add the `case 'Primary'` to `reduce`. 2. In each machine spec: add the three cases from Acceptance below. 3. Run `npm run gen:behaviour-spec` — new `it()` titles otherwise fail the drift check. 4. Update this ticket's `Status:` to `done` and the README's RD-07 row to `done`. 5. Commit all of it together. ## Acceptance criteria Three cases per machine spec (9 total), pure `reduce` calls, no TestBed: ``` - Primary advances to the next step from a non-final step - Primary submits from the final step - Primary is a no-op from a non-editing state ``` The third case is what lets RD-08 delete the component guard, so do not skip it. ```bash npm test # exits 0 npm run ci # exits 0 ``` Prove `Primary` produces exactly what the components produce today, so the migration in RD-08 is behaviour-preserving. For each machine, these must be equal: ``` reduce(s, { tag: 'Primary' }) === reduce(s, { tag: 'Next' }) // at a non-final step reduce(s, { tag: 'Primary' }) === reduce(s, { tag: 'Submit' }) // at the final step ``` Prove nothing was removed: ```bash grep -c "tag: 'Next'\|tag: 'Submit'" apps/ssp/src/app/herregistratie/domain/intake.machine.ts # Next and Submit must still be in the union and still handled in reduce ``` ## Verification `npm run ci`. This ticket touches no story, no `.mdx` and no component, so `--full` is not required. ## Out of scope - The three components. RD-08. - `besluit.machine.ts` and `change-request.machine.ts` — single-step forms with no step boundary, so `Primary` would mean nothing there. RD-06 handles those two. - Removing `Next`/`Submit` (decision 5). - Renaming the Dutch tags in `registratie-wizard.machine.ts` (decision 4). ## Risks - **`behaviour-spec.mdx` drift.** 9 new `it()` titles across 3 spec files. `scripts/ci-local.sh` regenerates `libs/shared/docs/behaviour-spec.mdx` from a path-sorted walk of spec titles and fails on any drift. Run `npm run gen:behaviour-spec` in the same commit. - **`check:seam`** greps `SCHOLING_THRESHOLD_DEFAULT` (decision 7) and `besluit.machine.ts`'s `BESLUIT_TAGS`. Neither should move, but if `check:seam` fails, that is why. - **`snippets.generated.ts` drift.** `intake.machine.ts:59` carries a `// #region showcase:steps` marker around `STEPS`. If your edit moves or splits that region, run `npm run gen:snippets` in the same commit. - **Do not make `primary` clever.** It is a three-line branch delegating to two existing functions. If it grows validation, error mapping, or a cursor calculation, the transition logic has been duplicated instead of reused.