hacifootprint
The map

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 nameThe door it is an alias ofWhat it is
defineJourneyMapbuildNavigationGraphthe authoring call — one object literal, validated and frozen
JourneyMapNavigationGraphthe 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 session

There 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 Causekind (was an offered edge fired, or did the world move?) plus principal (whose move was it?).

MoverWhat it is hereWhat moves the cursorOn the record
humanthe Principal your app already serves — real clicks, in your own controlswatchPage senses the click, or contextful catches the app's own callCause.kind: 'fired', principal: 'user', with Attribution grading what that claim is worth
agentthe model, through the MCP doorthe four served verbs — whats_here, why, do_action, did_it_workCause.kind: 'fired', principal: 'agent', plus the offerId of the row it planned against
guardyour DATA, decidingan action's when / enabledWhen judged against the state your store pushedthe 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.

TierThe callWhat it answersDoes it move what is served?
pagesession.sync('run-detail')which screen — the router's reportyes — actions are served from the page
containersession.observeFocus('run-detail.why')where INSIDE it — a declared tab, area or modalno, on purpose
statesession.updateState({ … })what is true in there. Not positionno

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:

DoorThe fact it statesWhat 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 itsession.focus, lookingAt, the Focus: line, focusHistory
session.sync('run-detail')the walker is on this pagethe 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.selectedStep

That 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 elsedo_action on an action that declares goTo; the served row carries goesTo, and howToReach walks the declared hops first
moves inside a screena 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 listjourneyscommitJourney 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.

On this page