hacifootprint
The map

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:

piecejob in the pattern
when on each stepa step whose precondition has not happened is not offered at all
enabledWhen on Nexton screen, greyed, until the app's own condition holds
verify on every stepthe app's own answer to did that actually happen?
crossLinksthe always-reachable spine — no wizard page is a room with no doors
groundTruth each turnthe 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 crossLinkswithout 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/review

verifyHeld: 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);
  • verify in 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 listfromJourneys reads it in the vocabulary you already wrote (Graph sources);
  • a different spinecrossLinks is 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.

On this page