Each wizard component re-derives the step-boundary decision the reducer already owns: advance on a middle step, submit on the last step. This ticket moves that decision into the machine, so RD-08 can replace the component's guard with one dispatch. Add a `Primary` message to each Msg union, and export a `primary(s)` function next to the existing `next`/`submit` pair. `primary` is a three-line branch that delegates to `next`/`submit` and writes no new validation. Each machine tests "last step" in its own vocabulary, per the ticket's Decisions block: `herregistratie` checks `step === 3`, `intake` checks `currentStep(s) === 'review'`, `registratie` checks `currentStep(s) === 'controle'`. `Next` and `Submit` stay in every union and every reducer — `Primary` is purely additive. Add 3 spec cases per machine (9 total): Primary advances from a non-final step, Primary submits from the final step, and Primary is a no-op outside the editing state. Each case also asserts the equivalence the ticket requires for RD-08's migration: `reduce(s, Primary)` equals `reduce(s, Next)` at a non-final step, and equals `reduce(s, Submit)` at the final step. Regenerate `behaviour-spec.mdx` for the 9 new `it()` titles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7.3 KiB
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—nextat 87,submitat 118,reduce'sNext/Submitcases at 165/171apps/ssp/src/app/herregistratie/domain/intake.machine.ts—currentStepat 86,nextat 173,submitat 197, cases at 226/232apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts—currentStepat 96,nextat 265,submitat 284, cases at 337/343- The three matching
*.machine.spec.tsfiles - CLAUDE.md decision 3 (state, and the naming rule for machines)
Decisions (pre-made, don't relitigate)
-
Add
{ tag: 'Primary' }to each of the threeMsgunions, plus an exportedprimary(s)function, plus acase 'Primary'in eachreducethat delegates to it. Same shape as the existingNext/Submitcases. -
Express "last step" in each machine's own vocabulary. Do NOT invent a shared
isLastStephelper. The three state shapes genuinely differ:herregistratie:Editingcarriesstep: 1 | 2 | 3and there is noSTEPSarray → the test iss.step === 3.intake:Answeringcarriescursor: numberagainstSTEPS = ['buitenland','werk','review']→ the test iscurrentStep(s) === 'review'.registratie:Invullencarriescursor: numberagainstSTEPS = ['adres','beroep','controle']→ the test iscurrentStep(s) === 'controle'.
Two of the three could share a
cursor === STEPS.length - 1form, but the third cannot. A helper covering two of three, plus a special case, is more to read than three plain expressions. -
primarydelegates to the existing exportednextandsubmit. Write no new validation and duplicate no transition logic. The whole function is a branch: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.)
-
The guard moves into the machine.
primaryreturnssunchanged 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, andInvullen.Invullen/Indienen/Ingediend/Misluktare correct Dutch domain tags per CLAUDE.md — do not "fix" them to English. -
KEEP
NextandSubmitin all threeMsgunions. Verified: they are dispatched across 9 spec files, includingintake.acceptance.spec.ts, which uses them as a readable behaviour narrative, and the three*-has-progress.spec.tsfiles. Removing them would rewrite dozens of spec lines for no gain.Primaryis purely additive. -
Do not touch any component. RD-08 migrates the three wizards. If you edit a
*.component.tsin this ticket, it is out of scope. -
Keep
SCHOLING_THRESHOLD_DEFAULTgreppable.npm run check:seamgreps for it as a top-levelexport constinintake.machine.ts:43. Do not move or inline it.
Files
apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts+.spec.tsapps/ssp/src/app/herregistratie/domain/intake.machine.ts+.spec.tsapps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts+.spec.ts
Steps
- In each machine: add
{ tag: 'Primary' }to theMsgunion, exportprimary(s)next tonext/submit, and add thecase 'Primary'toreduce. - In each machine spec: add the three cases from Acceptance below.
- Run
npm run gen:behaviour-spec— newit()titles otherwise fail the drift check. - Update this ticket's
Status:todoneand the README's RD-07 row todone. - 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.
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:
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.tsandchange-request.machine.ts— single-step forms with no step boundary, soPrimarywould 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.mdxdrift. 9 newit()titles across 3 spec files.scripts/ci-local.shregenerateslibs/shared/docs/behaviour-spec.mdxfrom a path-sorted walk of spec titles and fails on any drift. Runnpm run gen:behaviour-specin the same commit.check:seamgrepsSCHOLING_THRESHOLD_DEFAULT(decision 7) andbesluit.machine.ts'sBESLUIT_TAGS. Neither should move, but ifcheck:seamfails, that is why.snippets.generated.tsdrift.intake.machine.ts:59carries a// #region showcase:stepsmarker aroundSTEPS. If your edit moves or splits that region, runnpm run gen:snippetsin the same commit.- Do not make
primaryclever. 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.