Live bindings & fromLiveStore
Components register what they have when they render — and if your app keeps a live action store, fromLiveStore does the subscribe-and-register bookkeeping for you.
The wire: declare statically, bind at mount
A tool needs only does to exist in the plannable spine; its handler arrives when the
component that renders it mounts:
const group = session.registerActions('catalog', {
handlers: { 'search': (input) => shop.search(input) }, // your function, by reference
// actions: { ... } — or declare NEW leaf tools here-and-now
// instance: 'o-123' — one card of a repeats container
});
group.setEnabled('search', false); // greyed out → TOOL_DISABLED (retriable)
group.unregister(); // on unmount — idempotentYou never invent a group name — the returned ActionGroupHandle IS the identity. Node paths
are typed against the graph, routes-source pages included.
fromLiveStore — the third source
If your app already keeps a live store of the actions on screen, you don't write that
bookkeeping per component. fromLiveStore(store) accepts the smallest respectable store
contract — subscribe(onChange) + actions(), the shape React itself blesses — and drives
the wire above for you:
import { buildNavigationGraph, fromLiveStore } from 'hcifootprint';
const graph = buildNavigationGraph('desk', {
sources: [fromLiveStore(appActionStore)],
pages: { inbox: {}, settings: {} }, // the graph declares PLACES; actions arrive live
});
const session = graph.createSession(); // attaches the live source
// later: session.detachSources(); // idempotent — releases everything it attachedEach published action is { node, name, instance?, enabled?, ...RegisteredActionDef }, and
${node}.${name} (+instance) is its identity across snapshots. The reconcile rules:
- new → registered (an action for an already-declared tool BINDS silently — attach last, only bind; a genuinely new one mount-declares);
- gone → released;
enabledflip → flows toTOOL_DISABLED;- unchanged → never re-registered — a chatty store causes zero warnings and zero phantom structure bumps.
The direct door also works, without graph involvement:
const detach = fromLiveStore(store).attach(session).
A live row declares everything an authored action does
The ...RegisteredActionDef in that shape is the whole authoring vocabulary, and it is easy to read
past. A live row may declare enabledWhen, and it works end to end — this is worth saying in
plain words, because for a long time it was true and written down nowhere, and integrations wired
enabled: false by hand because nothing said the declarative door was open to them:
store.actions();
// [{
// node: 'categorise', name: 'next', does: 'Continue to review',
// handler: () => wizard.goNext(),
// enabledWhen: { 'receipt.uploaded': { eq: true } }, // ← a DECLARATION, from a live row
// blockedBecause: { says: 'Waiting for the receipt to upload', clearedBy: 'app' },
// }]An action a store introduces this way gets everything a hand-authored one gets: the row carries
enabled: false while the condition fails, the refusal carries the failing conjuncts as
evidence, and
unblockedBy names whatever action claims to write those same keys.
writes, goTo, confirm, input, verify and
blockedBecause travel the same way.
The carve-out, and it follows from the merge order. Live actions attach last and only bind, so
a row whose id the graph already declares binds its handler and nothing else: the declaration in
the graph keeps its own enabledWhen (and everything else it declared), because that one is the
audited copy. Declaring from a store is how you describe an action the graph does not have —
feature-flagged, server-driven, discovered at runtime. To change what a declared action says, change
the declaration.
One field is a live bit the reconcile tracks: a changed blockedBecause sentence is picked up on
the next store read and re-declared, so a reason that moves with your store reaches the row. A reader
form (blockedBecause: () => …) is never re-declared, because it is already read fresh at every row
assembly — its answer changes without the store having to say anything.
When it re-reads — the invalidation contract
Two halves, and only one of them can be yours:
Your store must emit whenever the action surface changes. NAVIGATION is covered for you — the source re-reads on every page change the app reports through
sync().
The failure that half closes is quiet and it is the shape most apps have. A store whose actions are derived from the router has no change of its own to announce when the page changes: the route moved, the store's own state did not, so nothing emits. Without the re-read the surface after a navigation is whatever the last emission left behind — the previous page's actions, served confidently as the actions available here.
// This one line is the whole other half: the report re-reads the store,
// so 'archive' arrives bound to the archive page's actions.
.((.., .) ?? .);Three properties worth knowing:
- Only an OBSERVED page change re-reads — never a navigation the app merely claimed. A claim moves the cursor before the app's own handler has run, so a read there describes the page the app has not left yet, and would bind those answers to the new position.
- A re-read that changes nothing is free. The identity ledger re-registers nothing, so a no-change re-read is a diff and no world motion — no version bump, no warnings, no phantom structure churn. A repeat report of the same page re-reads nothing at all.
- Nothing re-reads at report time.
whats_here, the facts block andavailable()read the served structure; a read that re-registered tools would mutate the answer it is in the middle of giving.
The hook is LiveBindingPort.whenPageChanges, and it is optional and severable: a hand-rolled
port without it degrades to store-emissions-only behaviour rather than breaking. session.whenPageChanges(fn)
is the same door if you want it directly — its listeners are expected to change the session, which
is exactly why it is not on('transition'), whose listeners promise never to.
The error stance — split by WHO is on the stack
The FIRST read at attach is loud: an invalid action is an authoring error and dies at
createSession — and the throw cleans up after itself (both subscriptions and any
already-registered bindings are released, so a failed attach leaks nothing). A LATER read runs on
somebody else's stack — the app's own notify loop, or the session mid-hop — where a throw would
abort their work: the app's iteration over its other subscribers, or a navigation that already
happened. Those reconciles are isolated: a failure warns through the session's onWarn sink
and leaves bindings as-is; the next read simply retries.
And it is disclosed. Bindings from before the failure are still on offer, and serving them with nothing said presents a stale list as current fact — the same confident staleness the re-read above exists to end. So a caught failure also files one row in the gap ledger:
{ "kind": "reported", "reason": "other", "principal": "system",
"request": "live action store read failed; serving bindings from before the failure" }One row per failure streak, cleared by the next read that works: a store that throws on every emission is one broken read, not forty unmet demands. The row carries an authored sentence and never the store's own error text — that is your runtime string. The dev warning carries the error; the row carries the consequence.
And the model is told. actionsMayBeStale is what carries this row past your triage ledger into
the facts block a model reads, as a line this library authors:
• the app could not re-read its own list of actions here — anything listed after this may be from before that.The row's own request never crosses into that block — it is data, and the facts block admits
authored sentences only. It is the one 'reported' row the block prints: every other one is unmet
demand for your team to triage, while this one is a fact about the surface the model is looking at
while it looks. Without it the disclosure reached a developer's console and the reader about to
act on the list was served it as current fact with nothing said.
Honest limits. The library cannot tell you why a read failed, and it will not guess whether the stale bindings are still correct — they may well be. It also cannot see a change your store never announces: if your action surface moves for a reason that is neither an emission nor a reported page change, nothing here will know.
Dead-end: a page where nothing can act
Binding is what stands between a declared action and a real one — so the session watches for
the room where that gap swallowed everything. When the cursor comes to rest on a page where an
agent fire of every served action would refuse NOT_MATERIALIZED — no actions at all, or
none of them registered, url-materialisable or instance-wired — it records a
kind: 'dead-end' gap row and warns once.
Nobody has to fire for the trap to exist, so nobody has to fire for it to be recorded. An agent that lands there gets a true-but-useless list of things it cannot do, and loops.
Once per (node, served structure). The row is an observation, not a verdict, and it is deduped on the page plus the served-structure fingerprint — what is registered, what is visible, what is greyed — deliberately not the structure version, which also bumps when a journey frame opens or closes. Churn that cannot wire anything must not multiply rows for a page whose answer never moved; a page still dead after the next real wiring change is one new fact and earns one new row. A mount that fixes the page simply ends them.
The warning always names the same three fixes:
registerActions('<page>', …)— wire what is on screen;- pass
navigate: (href) => router.push(href)tocreateSessionso url gestures materialise; - read the route table with
fromRoutes(routes, { crossLinks: true })so every page offers links to the others.
The sentence in front of them does not: it names only the refusal that is actually true of
that room — NOT_MATERIALIZED where actions are served but unwired, GUARD_FAILED where every
authored action is hidden behind a closed guard and none of them is wired, and
UNKNOWN_AFFORDANCE/NOT_ON_NODE where nothing is authored there at all. A dev warning that
names the wrong refusal sends someone hunting the wrong bug.
Two things it deliberately does not call a dead end. A guard-closed action is wired:
its refusal is GUARD_FAILED, the next state report may open it, and calling it missing
wiring would prescribe a fix already done. And an off-graph cursor — a page the graph has
never heard of — is the other trap and a permanent one: no mount can add a door to a page
that does not exist (registerActions throws on an unknown node), so it gets its own
sentence, its own offGraph: true on the row, and is asked exactly once for the session's
life. Its cures are different too: author the page, or sync() the id the graph actually uses
for that screen.
The gate arms only where materialisation is a live question — something is registered
somewhere, or the session holds a navigate — and never in a
tour session, where nothing being bound is the entire point.
What it does to the trace
fromLiveStore rides registerActions, so mounts land exactly like hand-written ones —
structure-axis version bumps, coalescing, dormancy/drift telemetry all apply unchanged. No
new record shapes. And it is a zero-value-import leaf: a static-graph consumer never
bundles it — see Tree-shaking.
See it live in the Live Desk demo: a support inbox whose graph declares places and not a single tool.
A read is an action
How the agent gets your app's data — declare a tool whose handler RETURNS it. The return rides `produced`, sanitized and capped, on the data channel; serving it is never a claim that the model used it.
The human sensor
A framework-free, record-only DOM sensor. The graph you already authored IS the instrumentation manifest — the app declares values, the sensor never reads them, and anything it cannot attribute is reported honestly or not at all.