Map & Walker
The official vocabulary, in one sentence: you declare the JourneyMap; the session is the Walker; the recording carries both.
You declare the JourneyMap; the session is the Walker; the recording carries both.
That sentence is the whole model. The map is static data you author once — pages, the things inside them, the journeys worth finishing. The walker is the live session standing somewhere on it. And because every move goes through the session, the recording holds both halves: the map as declared, and the path actually walked over it.
Since 1.10.0 the two nouns have names in the API, as permanent aliases of the doors that have always been there:
| The official name | The door it is an alias of | What it is |
|---|---|---|
defineJourneyMap | buildNavigationGraph | the authoring call — one object literal, validated and frozen |
JourneyMap | NavigationGraph | the compiled map that call hands back |
Both names ship forever and neither is a rename: they are the same function object and the same type, so a codebase can speak either dialect, and a mixed codebase is not a mistake.
import { } from 'hcifootprint';
import type { } from 'hcifootprint';
const : <'catalog' | 'checkout'> = ('shop', {
: {
: {
: '/catalog',
: {
'add-to-cart': { : 'Add the open dress to the cart', : ['cart.items'] },
'go-checkout': { : 'Go to checkout', : 'checkout' },
},
},
: {
: '/checkout',
: {
'place-order': { : 'Place the order', : { 'cart.items': { : 0 } } },
},
},
},
: { : { : 'Buy a dress end to end', : ['add-to-cart', 'place-order'] } },
});
const = .({ : 'catalog' }); // the walker IS the sessionThere is no Walker to construct
The library exports no Walker object, and that is deliberate. A walker is not a thing you
wire up — it is the session you already create, standing on a node, moving. Exporting a
synonym for it would turn a way of reading the library into a second runtime object to keep
in step with the first.
The three movers
A cursor only ever moves for three reasons, and each one is recorded as a
Cause — kind (was an offered edge fired, or did the world
move?) plus principal (whose move was it?).
| Mover | What it is here | What moves the cursor | On the record |
|---|---|---|---|
| human | the Principal your app already serves — real clicks, in your own controls | watchPage senses the click, or contextful catches the app's own call | Cause.kind: 'fired', principal: 'user', with Attribution grading what that claim is worth |
| agent | the model, through the MCP door | the four served verbs — whats_here, why, do_action, did_it_work | Cause.kind: 'fired', principal: 'agent', plus the offerId of the row it planned against |
| guard | your DATA, deciding | an action's when / enabledWhen judged against the state your store pushed | the guard's own evidence, and blockedBecause / unblockedBy when it says no |
And a fourth thing that is not a mover, because nobody chose it: the world moves on its own —
a back button, a server push, a session expiry. That is Cause.kind: 'stimulus', recorded
rather than silently absorbed, because a cursor that moved without an offered edge is exactly
what an agent must not be allowed to miss.
Where the reader is: page, container, state
Sync pages; observe the deeper place. sync() moves the walker and decides what is served;
observeFocus() says which tab or area the reader is in. Declare containers, and report the
deepest one on screen.
Position has three tiers, and each has exactly one door.
| Tier | The call | What it answers | Does it move what is served? |
|---|---|---|---|
| page | session.sync('run-detail') | which screen — the router's report | yes — actions are served from the page |
| container | session.observeFocus('run-detail.why') | where INSIDE it — a declared tab, area or modal | no, on purpose |
| state | session.updateState({ … }) | what is true in there. Not position | no |
The container tier is the one that gets skipped, because nothing forces it: declaring tabs:
compiles, mounts, masks and narrows guards perfectly well while never once saying which tab
the person is looking at. Declaring a container gives you mount-tracking; observing it is
what gives you position.
And it has to be its own door. A tab is not a place the walker can stand — actions are served
from the page, so a cursor parked on run-detail.why would be served nothing at all. Nor
could a fire carry it: focus used to move only on fire() and sync(), and a person
clicking a tab fires nothing, which is exactly the case worth reporting.
The same session, told two ways
import { } from 'hcifootprint';
const = ('runs', {
: {
'run-detail': {
: '/runs/:id',
: { : { : 'Export this run as JSON', : ['run.exported'] } },
: {
: { : 'Why this step ran', : { 'open-slice': { : 'Open the causal slice' } } },
: { : 'The run timeline', : { : { : 'Scrub to a step' } } },
},
},
},
});
const = .({ : 'run-detail' });
// COARSE — the router reported the page, and nothing reported the tab.
.('run-detail');
// DEEPEST — your own tab handler, reporting both facts about the tab it opened.
function (: 'why' | 'timeline') {
.(`run-detail.${}`); // which tab is VISIBLE
.(`run-detail.${}`, { : 'user' }); // where the READER is
}What the model is handed, page-only:
You are on: run-detail.
No actions have been performed in this app this session.…and with the deepest container observed:
You are on: run-detail.
Focus: run-detail.why.
No actions have been performed in this app this session.Every served answer carries the same two halves as data — youAreOn is the page that serves,
and lookingAt is the deeper place, present only when there is one:
{ "youAreOn": "run-detail", "lookingAt": "run-detail.why" }Without that, a screen-driving agent has to deduce the open tab from whatever unlabelled state keys happen to move, and every turn it spends deducing is a turn it does not spend acting.
Three facts, three doors
show(), observeFocus() and sync() answer different questions, and an app that flips a tab
reports the first two:
| Door | The fact it states | What it changes |
|---|---|---|
session.show('run-detail.why') | this tab is visible (its siblings are not) | which actions whats_here serves |
session.observeFocus('run-detail.why') | the reader is in it | session.focus, lookingAt, the Focus: line, focusHistory |
session.sync('run-detail') | the walker is on this page | the cursor, the version, and everything served |
observeFocus refuses by name rather than guessing: a node this map does not declare, and a
node belonging to another page (sync that page first). Observing the page itself says the
reader came back up with no container open. A container the app also says is hidden — a closed
modal, a tab whose sibling is shown — is walked home to the nearest place that is really there,
because a closed modal cannot hold anyone.
Hand sync() a container path and it does the safe thing rather than the literal one: it syncs
the page that owns the container and warns once, naming observeFocus. Before 1.11.0 that call
put the cursor off-graph — a session that served nothing, silently. An undeclared path is
still honest: the cursor follows reality off-graph, exactly as it always has.
"system unknown changed: …" — the other half of the same symptom
When in-page position rides an ordinary state key, this is what the model reads in the session ledger:
system unknown changed: runDetail.activeTab, runDetail.selectedStepThat line is the honest floor for a report that named no stimulus and no principal: the world moved, nobody said who or why, and the key names are all anyone gets. The first cure is the rule above — report position as position, so the tab stops riding a state key at all.
Known limit: labelling a state push costs its attribution
The other half has no clean cure today. updateState(delta, { stimulus: 'push' }) improves the
line, but stimulus is documented to mean world-initiated — such a delta must NEVER settle a
pending fire. So an app that wants its own pushes labelled has to choose between a readable
line and a working did_it_work, and it will always pick the second. A label that does not
touch attribution is the fix; it is not built in this release, and neither are authored
labels for state keys themselves.
The CI half
lintGraph reads the map alone and can never see a runtime call, so it proves the half that is
authored: a tab no declared control can put the cursor inside — nothing lives in it, and
nothing binds to it as a tab switch — on a page that authors controls beside it. That is the
unevidenceable-tab note (advisory, never an error: an app that observes its tabs has already
handled it). See the drift harness.
The five things a walker does
Nothing on this list is new machinery. It is the same five moves any walker makes, and the column on the right is what already answers each one.
| A walker… | Here it is |
|---|---|
| looks — what is around me? | whats_here — one row per action offered on this node, from available() |
| navigates — go somewhere else | do_action on an action that declares goTo; the served row carries goesTo, and howToReach walks the declared hops first |
| moves inside a screen | a fire that lands on a deeper node — an area, a tab, a modal — or observeFocus, the door a person's tab click goes through, moves the focus without changing the page or what is served: session.focus and lookingAt, with focusHistory recording every move and who moved it (FocusMove.cause), including the moves where focus stayed. See the three tiers |
| keeps a task list | journeys — commitJourney opens a frame, and journeyStanding says where the flow stands (in-progress, awaiting-human, with-the-human, blocked, failed, done) |
| verifies — did that work? | did_it_work — the settled facts of one fire (effectStatus, outcome, effectVerified, verifyHeld, arrival), never a guess |
The honesty backstop under the last row is the drift sensor: lintGraph and
checkGraph fail in CI when the map has drifted from the app, because a walker that verifies
against a stale map is verifying against fiction.
The same pattern, at three altitudes
footprintjs walks stages,
agentfootprint (defineSkillMap) walks
skills, and this walks screens — one declared map, one walker on it, one recording that
carries both.
Demos
Two runnable demo apps in the repo plus the three-commit dress shop — every one runs with no API key, no network, and behaves identically every time.
The navigation graph
buildNavigationGraph turns the container tree you already picture — pages, areas, tabs, modals, tools — into a validated, frozen graph with typed node paths.