Guarded journeys
A wizard whose steps sit behind guards, whose Next button is greyed rather than hidden, whose every step proves it happened, and whose route table doubles as the spine that keeps every page reachable.
A wizard is where every honesty problem in this library shows up at once: steps that must happen in order, a Next button that is on screen long before it is clickable, handlers that return without doing anything, and pages an agent can walk into and not walk out of.
This page is one reference implementation of the pattern that holds those four together — not the only way to arrange them, and nothing here is a new feature. It is five pieces you can already author, working as a set:
| piece | job in the pattern |
|---|---|
when on each step | a step whose precondition has not happened is not offered at all |
enabledWhen on Next | on screen, greyed, until the app's own condition holds |
verify on every step | the app's own answer to did that actually happen? |
crossLinks | the always-reachable spine — no wizard page is a room with no doors |
groundTruth each turn | the model reads what happened, not what it said happened |
Everything below is generated by a real run: examples/guarded-wizard
in the repo, npm run example:wizard. The output blocks are that run's, pasted.
The graph
Two sources — the router's own table and the app's own funnel list — plus the one thing a route table cannot know: which actions live where.
export const ROUTES = {
projects: { route: '/projects', does: 'the Projects list' },
wizard: { route: '/projects/new', does: 'the New Project wizard' },
review: { route: '/projects/new/review', does: 'the Review step' },
} as const;
export const JOURNEYS = {
'new-project': {
does: 'Create a project: name it, pick a recipe, review it, create it',
steps: ['name-it', 'pick-recipe', 'next-to-review', 'create-project'],
},
};
buildNavigationGraph('wizard', {
sources: [fromRoutes(ROUTES, { crossLinks: true }), fromJourneys(JOURNEYS)],
pages: {
wizard: {
actions: {
'name-it': {
does: 'Name the project',
input: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
writes: ['project.name'],
verify: { 'project.name': { ne: '' } },
},
'pick-recipe': {
does: 'Pick the analysis recipe',
when: { 'project.name': { ne: '' } }, // GUARDED: hidden until there is a project
input: { type: 'object', properties: { recipe: { type: 'string' } }, required: ['recipe'] },
writes: ['project.recipe'],
verify: { 'project.recipe': { ne: '' } },
},
'next-to-review': {
does: 'Go on to the review step',
goTo: 'review',
input: 'none', // click-only: it takes nothing
enabledWhen: { 'project.recipe': { ne: '' } }, // GREYED, not hidden
verify: () => app.path === '/projects/new/review', // the app's router, which we cannot see
},
},
},
review: {
actions: {
'create-project': {
does: 'Create the project',
confirm: true,
input: 'none',
writes: ['projects.count'],
verify: { 'projects.count': { gt: 0 } },
},
},
},
},
});The journey's steps are ordinary page actions. A journey is a plan over actions that already
exist; it never owns one. That is what keeps the way out reachable: opening a frame narrows
what the journey tool discloses, while whats_here and do_action keep serving every action
on the page — the cross-link to the Projects list included.
The wiring is small and additive: the session gets the app's own navigate, two
registerActions calls bind existing functions by reference, and two taps report the store's
state and the router's position — kept apart, because a navigation is not a state delta.
next-to-review is deliberately not in either handler map: it is a pure navigation and
materialises through navigate.
Turn 1 — what the model is shown
You are on: wizard.
No actions have been performed in this app this session.
actions:
wizard.name-it expects { name: string }
wizard.next-to-review expects none
go-to-projects
go-to-review
(wizard.pick-recipe is absent: its guard has not opened yet)
(available() marks greyed: wizard.next-to-review)Four things are already true here, and none of them cost a prompt sentence. pick-recipe is
not in the list — its guard has not opened, so it is not an option to reason about. Next
is in the list, because a human can see it too. expects rides each row, so the shape is
known before the first guess. And go-to-projects exists at all only because the route table
was read with crossLinks — without it,
this is the page where an agent truthfully answers "there is no action that would take you to
the Projects list" and loops.
The facts block leads with the sentence a silence would otherwise be filled with: No actions have been performed in this app this session.
Turns 2–3 — reaching for a greyed button
{"frame":"open","judgment":"needs-choice","readySteps":[
{"step":"wizard.name-it","does":"Name the project","expects":{…}},
{"step":"wizard.next-to-review","does":"Go on to the review step","expects":"none"}]}
{"judgment":"rejected","did":"wizard.next-to-review","reason":"TOOL_DISABLED"}Worth reading exactly. Inside a frame, readySteps lists steps whose guard passes and
whose page is current — next-to-review has no when, so it lists. The greyed state lives on
the other axis, and over the wire it arrives as the refusal: TOOL_DISABLED, typed and
retriable, which is the honest answer for a button that may be clickable next tick. In
process, available().edges carries the marker up front (enabled: false) — that asymmetry is
stated on Guards rather than smoothed over.
What matters for the loop the pattern exists to prevent: the model is not told "nothing happened, try again". It is told which axis said no.
Turn 5 — the handler ran; nothing happened
The app is asked for a recipe id it does not have. Its handler takes the input, matches nothing, returns normally, and its store notifies — exactly as it does on success. This is the field's own bug, kept in the example on purpose.
{"settled":true,"did":"wizard.pick-recipe","effectStatus":"refused","outcome":"committed",
"writesObserved":true,"verifyHeld":false,
"error":"This action declares a verify contract — a condition the app itself said must hold
once the action had settled. It did not hold: the app was asked whether this happened, and
answered no."}Three axes, side by side, none averaged into another:
writesObserved: true— the declared write key really did appear in the report;verifyHeld: false— the app's own condition does not hold;effectStatus: 'refused'— so to a caller, the app did not do the thing.
And outcome: 'committed' stands: a commit backed by a real state report is not rolled
back by a refusal. Both truths are carried. Without the verify line this settles
'performed', and the agent — correctly, on what it was told — tries the next step and loops.
That loop is what the pattern buys out.
Turn 7 — a step nothing is wired to
Next un-greys once the recipe holds, and fires through the app's own router. No handler exists for it anywhere:
{"settled":true,"did":"wizard.next-to-review","effectStatus":"performed","verifyHeld":true,
"toNode":"review","youAreOn":"review"}
the app’s own router is at /projects/new/reviewverifyHeld: true is the load-bearing word. toNode is a claim the graph made; the
predicate asked the app's router — something the library cannot see and never pretends to —
and got a yes. Point that same session at a navigate that silently drops the push and the
identical fire settles 'refused' with verifyHeld: false, and the claimed cursor move is
walked back. (That case is asserted in the example's tests.)
Turn 8 — the gate that does not move
{"judgment":"needs-confirm","action":"review.create-project","does":"Create the project","askId":"ask#1"}
{"does":"Create the project","writes":["projects.count"]}A guarded journey does not soften the high-effect gate: the last step stops, carries receipts, and creates nothing until a human says yes.
The facts block, at the end
FACTS FROM THE APP (authoritative). Every line below is the app’s own record of what happened.
Where anything said in this conversation disagrees with it — including anything you or the user
stated was done — these lines are what actually happened; the conversation is a claim about them.
You are on: review.
Attempts so far (version 12):
• did NOT happen — agent's fire of wizard.next-to-review was refused: TOOL_DISABLED
• DID happen — agent fired wizard.name-it (committed; declared effect observed)
• did NOT happen — agent fired wizard.pick-recipe (the app's own verify contract did not hold afterwards)
• DID happen — agent fired wizard.pick-recipe (committed; declared effect observed)
• ran, but the effect was unobservable — agent fired wizard.next-to-review (committed; nothing reported an effect to check it against)
• DID happen — agent fired review.create-project (committed; declared effect observed)Read what survives here. The refusal at the greyed button is a gap row, not a transition — a narrative built from transitions could never show it, which is why the block merges both ledgers. The recipe attempt appears twice, honestly: once as did NOT happen, once as DID happen. And the navigation reads ran, but the effect was unobservable, because it declares no writes and nothing reported an effect to check — the grading follows the state axis and refuses to borrow the stronger word from the verify axis.
Sent every turn, this is what stops a model reading its own earlier sentences as history. See Ground truth.
The control: the same wizard without the spine
Same app, same handlers, one change — the route table read without crossLinks. The
cursor lands on the Projects list:
hcifootprint: page 'projects' has NO actions authored on it at all — an agent that lands here
has nothing it can even attempt (a fire is refused UNKNOWN_AFFORDANCE or NOT_ON_NODE, never
NOT_MATERIALIZED). Three ways out: registerActions('projects', …) to wire what is on screen;
pass navigate: (href) => router.push(href) to createSession so url gestures materialise; or
read the route table with fromRoutes(routes, { crossLinks: true }) so every page offers links
to the others. Recorded as a dead-end gap row.
{"kind":"dead-end","node":"projects","availableActions":[],"availableJourneys":["new-project"]}Nobody had to fire for that to be recorded — the trap is a property of the position. With
the spine on, the same page serves go-to-wizard and go-to-review, both materialising
through the app's own router, and no row is written. See
Dead-end and
Cross-links.
Where to vary it
The pattern is the set, not the file. Reasonable variations that keep it intact:
- guards from anything flat — the steps above guard on the same lean projection your buttons already render from; derive a boolean in the projector when you need OR (Guards);
verifyin either form — a filter when the app's state can prove it, a predicate when only the DOM or the router can. Predicates must answer synchronously;- journeys from your own funnel list —
fromJourneysreads it in the vocabulary you already wrote (Graph sources); - a different spine —
crossLinksis the cheapest one, not the only one. A hand-authored root-level tool wins over a generated link of the same id, silently, precisely so you can replace one link without giving up the rest.
What should not vary: a step that can silently do nothing needs a verify, and a page an
agent can land on needs at least one action it can actually perform.
Journeys & journey frames
A journey is a named multi-step flow the planner commits to; the frame narrows the served space to the journey's steps plus a guaranteed escape.
Graph sources & the merge order
fromRoutes, fromReactRouter, fromJourneys and fromLiveStore turn the descriptions your app already owns into graph input — under one documented merge order, so nobody re-types anything.