Sessions & fire()
fire() is synchronous and honest — effectStatus says what is known at return time, whenSettled resolves once with the final truth, and a declared input contract is advertised and enforced.
graph.createSession(opts?) returns an InteractionSession. Three calls report reality into
it — fire() (an action), updateState() (a store tap), sync() (the router) — and every
settled transition lands in a real footprintjs
commit log, which is why session.why(key) answers "why is the app in this state?" as a
causal slice, not a guess.
fire() — two answers, because there are two truths
fire() is synchronous, but the handler it invokes is always deferred. So the result carries
two answers instead of averaging them:
const = .('catalog.add-to-cart', { : 'user' });
if (.) {
.; // 'pending' | 'unobservable' — what is known AT RETURN TIME
void ..(() => {
.; // 'performed' | 'refused' — the final truth, delivered once
});
}effectStatusis the INVOCATION axis, read at return time. It is structurally never'performed'there (the handler is always deferred):'pending'when something will run,'unobservable'when nothing is bound.whenSettledresolves ONCE with the final truth (FireSettlement: status, outcome, a transition snapshot, error/produced) and NEVER rejects — fire-and-forget is the dominant call pattern, and refusals arrive as data (effectStatus: 'refused'). A fire the app never reports on stays unresolved — the honest mirror of pending.- A handler that reports failure by returning
{ok: false}takes the throw's path: the outcome flips to rejected/rolled-back, a claimed navigation walks home, and the failure becomes the settlement's reason. The test is deliberately narrow (own property, strict=== false), so afetchResponse stays data.
effectStatus is INVOCATION, not acceptance
effectStatus answers did anyone perform it — it is about the invocation, never about
whether the app accepted what was done. effectVerified is the STATE axis: did the declared
write keys appear in the settled delta? They disagree honestly all the time, and pairing them
is how you read a settlement:
effectStatus | effectVerified | what actually happened |
|---|---|---|
performed | true | our side ran and every declared write key landed |
performed | false | it ran; the delta did not carry the keys it claimed — a drifted claim |
performed | 'unobservable' | it ran; nothing could check it (no declared writes, or no state tap) |
refused | true | the handler failed after the app's real report landed — the commit stands, the refusal stands |
refused | 'unobservable' | it failed before anything could be observed; a claims-only commit rolls back |
unobservable | 'unobservable' | nothing was bound to run, or tracking stopped (superseded) |
pending | — | only ever on the synchronous FireResult: the handler is deferred, so this is the honest answer at return time |
Those are the pairings you will actually meet, not a closed enumeration: the two are answers to different questions, so any value of one can in principle meet any value of the other — which is the whole reason they are two fields.
No word here changed meaning. These are the same four effectStatus values 0.4/0.5
consumers already branch on, and the same effectVerified; what is new is a third axis beside
them (verifyHeld, below) and the discipline of never averaging any two of them into one
comfortable word.
Verify: proving the click did something
The library can see that your handler returned. It cannot see that a radio got selected, or
that the button it clicked was live. Reported from a production integration: a radio fire came
back 'performed' while nothing was selected, and a wizard's Next returned 'performed' while
the button it clicked was disabled — the agent looped five times, correctly, on what it was
told. Their workaround was ~60 lines of before/after DOM-signature comparison per action.
So the app declares the check itself, once, next to the action. Two forms, one meaning — this must hold once the action has settled:
// DECLARATIVE — a filter over projected state, read by the same evaluator as every guard
'pick-recipe': { does: 'Pick the recipe', verify: { 'project.recipe': { ne: '' } } },
// PREDICATE — synchronous, handed a DETACHED snapshot; its closure may read the DOM
'next': { does: 'Go on', verify: () => document.querySelector('.step-2') !== null },Where it is asked. At settlement, and at exactly the three points a fire would otherwise come to rest as a success:
- an attributed state report — asked after the delta lands, because the contract asks about the world that report just created;
- a tapless handler completing — there the handler finishing is the settlement signal;
- a synchronously-committed fire whose handler completed — the click with no declared
writes, which is precisely the fire that used to earn
'performed'on the strength of a handler returning while nothing on screen moved.
Nowhere else, because nowhere else did anything run: a fire with nothing bound already settles
'unobservable', and re-asking there would invent a verdict about an action nobody performed.
A refusal that teaches. If the contract holds, nothing changes and the settlement carries
verifyHeld: true. If it does not, the settlement is effectStatus: 'refused' — an existing
word, because a handler that threw and a handler that did nothing mean the same thing to an
agent — and error carries a structured VerifyFailure: an authored sentence safe to show a
model verbatim,
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.
plus, for the declarative form only, the conditions that did not hold as evidence. A
predicate is opaque by construction — it answers yes or no and hands over no conditions, so
naming one would be a guess about code the library cannot see. A claims-only commit rolls
back, walking the cursor home if the action had claimed a navigation; a commit backed by a
REAL state report stands while the settlement still refuses.
What it cannot check it never refuses on: an unknown state key, or a predicate that threw
(isolated, warned) comes back verifyHeld: 'unevaluable'. A predicate returning anything but
true/false — a Promise, most dangerously, since a pending one is truthy — is unevaluable
too. A refusal needs proof; a confirmation needs everything. A wrong rejection blocks an
action your app would have accepted, and the caller has no appeal.
The field is named for the declaration that produced it, never the bare word verified —
effectStatus asks whether anyone performed it, effectVerified asks whether the declared
write keys appeared, and verifyHeld asks whether your own condition holds. All three can
disagree honestly, so none of them may share a name. Worked end to end in
Guarded journeys.
Asking later — settlementOf
whenSettled belongs to the ONE caller that called fire(), and a promise cannot cross a
wire: a remote agent — or the relay in front of it — holds a transition id and nothing else.
That was a real dead end in the field, patched downstream with a listener and a stopwatch. The
answer is a door anyone holding the id can knock on:
void .(); // the same settlement, as a promise
.(); // …or without waiting: undefined while open
.(); // every id an answer is still coming forSame laws as whenSettled: one answer, first settlement wins, never rejects. And three more
doors on the same truth — port.whenSettled(id) for a caller holding only the
Mode B port, the did_it_work tool for a model, and
mcpServer's fold, which usually settles it before the result even leaves.
settlementOf and settlementIfKnown both throw synchronously on an id that can never
settle (unknown, or a stimulus/sync row), naming the fires that are live. That refusal is
deliberate: a promise nobody will ever resolve is exactly how a mistyped id becomes a
confident lie four seconds later.
The async story end to end — returning your handler's promise, threading a transitionId on the
state rail, and the one served await — is Waiting for the app.
A fire the app never reports on keeps its promise open — there is no timeout arm, because
FireSettlement excludes 'pending' by construction, so a timed-out answer could only be a
guessed 'unobservable'. Waiting honestly forever is the truthful shape; when you need an
answer that cannot wait, ask a non-blocking door instead.
What is still live — awaitingSettlement
session.awaitingSettlement() lists the ids whose settlement question is still open, in fire
order. It is not the same list as pending(), and the difference matters: pending()
names fires awaiting the app's state report, which a step declaring no writes never
joins — it still has a handler running and a settlement coming. Every pending fire is awaiting
a settlement; not every fire awaiting a settlement is pending. Asked "what is still live?",
pending() alone answers "nothing" about an action that is running right now.
The settlement rule
A step that declares writes does not settle synchronously when a state tap is wired —
fire() returns settlement: 'awaiting-state' and the transition sits pending until the app
reports the real delta via updateState(). Until then the fired step is excluded from
readySteps (advertising it would tell the model to double-fire), and downstream steps that
depend on its write are not ready yet.
Therefore an agent must re-read whats_here (or re-open the journey) after any write-step
— and re-sync() after any claimed navigation: a result carrying toNodeClaimed: true
moved the cursor on the graph's declared goTo — a claim about your app, not an observation.
Only sync() confirms it; a mismatch is reconciled and recorded instead of silently
diverging.
The input contract — advertised, then enforced
Every served action row carries expects — the declared input contract, visible BEFORE
the fire, for JSON Schema, Zod and non-serializable validators alike. Since 0.6.0
available().edges carries the same field, from the same derivation: the schema on an edge
is the LIVE validator (in-process convenience), expects is the wire-shaped contract, and a
consumer reading the session directly no longer has to re-derive which kinds serialize and
which decline. A live validator never crosses the wire — that is the firewall, not an
oversight.
input: 'none' — the action that takes nothing
A uniform { value: string, required } relay contract forces a model to send value: "" to a
click-only control — and in the field that empty string reached the handler and overrode the
app's own authored default, selecting nothing. Declare the truth instead:
'like': { does: 'Like the open dress', input: 'none' },The model is told (expects: 'none') before it can guess. A payload sent anyway is refused
PAYLOAD_INVALID with the shape it sent — this action takes no input — omit the payload
(received { value: string }) — so it never reaches the handler. A blank payload
(undefined, '', {}, an object of undefined-valued keys) is accepted and erased: protocol
residue is not intent. An explicit null is not blank — it still answers for its shape.
Exactly this door. A schema-bearing action is untouched ('' is a real value there — clearing
a field), and an action that declares no input at all is untouched too: absence means the
library does not know the shape, and it never guesses one.
And a plain JSON Schema
is now enforced at fire time (checkPayloadShape, default true) — before 0.4.0 only
.safeParse/.parse validators ever ran, so a schema described the door while a guessed key
sailed through it to arrive at the handler as undefined.
The refusal teaches:
missing required 'value' — expected { value: string }, received { name: string }.
The rejection and the advertisement render the SAME shape string, so a
planner correcting from either lands in the same place. Messages are built from key names and
type names only — a payload value never enters a string bound for the model or the gap
ledger.
The checker is teachable, not complete: required keys, declared primitive types, closed
objects, one level of nesting. What it cannot judge ($ref, allOf, anyOf, oneOf,
enum, format, pattern) it declines to judge and passes — the same stance
guardUnevaluated takes on an unevaluable key, because a wrong rejection
blocks an action the app would have accepted and the caller has no appeal.
checkPayloadShape: false restores the 0.3.0 pass-through byte for byte.
Typed rejections
A refused fire() returns { ok: false, reason, ... } — never a success-shaped no-op:
UNKNOWN_AFFORDANCE, STALE_CURSOR, NOT_ON_NODE, GUARD_FAILED (with evidence),
PAYLOAD_INVALID (with issues), BLOCKED_BY_OVERLAY, NODE_NOT_VISIBLE,
STILL_MOUNTING (retriable), INSTANCE_REQUIRED, INSTANCE_UNKNOWN, TOOL_DISABLED
(retriable), NOT_MATERIALIZED (declared but unbound — carries the declared gesture; see
Actuation).
TOOL_DISABLED has four wires reaching it, so an app can report a greyed-out control
wherever it already knows: enabled: at registration, handle.setEnabled(…), a live store's
LiveAction.enabled, and the declarative enabledWhen on the action itself. A failed
when HIDES a control; a false enabledWhen serves it carrying enabled: false on
available().edges and refuses execution fires — the full table is on
Guards. High-effect steps return
judgment: 'needs-confirm' — and by default confirm: true is the agent's own request to
proceed, tied to no recorded decision. Create the session with
requireHumanApproval to refuse a
crossing the library cannot prove a person authorized; that adds five more typed refusals —
APPROVAL_REQUIRED, APPROVAL_SPENT, APPROVAL_MISMATCH, APPROVAL_STALE,
APPROVAL_DECLINED — see Receipts.
FireResult's refusal set grows the same way the gap ledger's kinds do: a new reason is
always a new fact, never an old one relabelled. Read the reasons you know and let the rest fall
through as "refused, and here is the word" — an exhaustive never check over today's set is the
one consumer shape a future reason stops compiling.
The gap ledger
Every refused fire (kind: 'fire-rejected'), every explicit reportGap(...)
(kind: 'reported'), every allowed no-op tour fire (kind: 'unmaterialized-fire'), every
ENTRY_NOT_MATERIALIZED journey-commit refusal, and every page the cursor rests on where
nothing could be performed at all (kind: 'dead-end' — see the page gate in
Actuation) becomes a token-lean, name-only row —
the ask, the position, what WAS available, and (for rejected/unmaterialized fires) the
gestureKind that names WHICH wiring is missing. Cluster the rows and you have a
demand-driven backlog:
session.gaps(); // export the whole ledger to your analytics / triage
session.onGap((row) => { /* stream rows live */ });Rows are deliberately name-only — never descriptions, values or transcripts — so a batch triage LLM can cluster thousands of them cheaply.
kind grows: 0.3.0 added 'unmaterialized-fire', this release adds 'dead-end', and a
later release may name another shape of unmet demand — recording what nobody could serve is the
ledger's whole job. What never happens is a kind changing meaning; a new one is always a new
fact, never an old one relabelled. So read a row by the kind you know
(if (gap.kind === 'fire-rejected') …) and let the rest fall through as informational. An
exhaustive never check over today's four is the one consumer shape a future kind will stop
compiling.
A destination the app mints
The cookbook for an action that creates a thing and then goes to its page. The address does not exist until the handler runs, so the claim is a page NAME and never an address — a half-address is not an address.
Presence & visibility
Registration observes MOUNTED, visibility is an explicit signal, and everything derived rather than observed carries an honesty marker.