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.
Every human click your app already handles is a fact the ledger wants. Writing that down by
hand means a humanFire call per control — a real integration counted 53 lines of shim and
21 call sites — and every one of them is a place to forget invoke: false.
hcifootprint/sensor does it once, for the whole page:
Attach it, and release it
const = (, { : . });
// …the human clicks a declared button; the session gains a transition whose cause
// is { kind: 'fired', principal: 'user' }, and nothing else was wired.
.(); // idempotent — every listener, every pending timer, every declarationWhat it cannot do here: refuse. The sensor records after the browser ran your handler, so a guard that does not hold shows up as a refusal on the row rather than as an act that was prevented. If you need refuse-before-perform, that is a wrapper door and it stays yours — see what it does not do.
It is a zero-dependency leaf: importing it drags no session machinery and no footprintjs
(11.9 KB minified, proved in test/treeshake.test.ts). It reaches the engine through a
type-only port, so a page that only wanted a listener does not ship an engine.
No selector map. Ever.
The watch-list is session.available().edges and the binding each one carries. There is
no config file, no id registry, no instrumentation layer to keep in sync with your components.
The only thing you may add is DOM truth — an ARIA role, an accessible name — which improves
the page for every user and configures nothing.
The manifest is re-derived on every trusted event rather than cached, and that is a
correctness decision: a guard opening or closing changes the served edges without moving
available().version, so a version-keyed cache would go stale exactly when a guard flips.
Two evidence levels — and the order is the design
DECLARED beats RECOGNISED, always
The app's own statement outranks the sensor's reading of the page. That order is not a preference; it is the rule that keeps a wrong value out of the ledger.
DECLARED — you hand the element over. Identity is object identity, so nothing is computed and nothing can be ambiguous. The declaration may carry a value getter, so a payload is legal here:
const control = watch.attach({
edge: 'compose.send', // the affordance this control IS
element: inputEl, // handed over — never matched by name
instance: 't-7', // one row of a repeats container (optional)
value: () => draft, // THE DECLARED VALUE (optional)
cadence: 'commit', // per-control override (optional)
commits: () => armed, // is a gesture here the act YET? (optional)
});
control.detach();`commits` — the two-step control, and the trap under it
A confirm button is one element and two presses, and only the second is the act. Handing the
element over only once it is armed looks right and is the bug: unarmed it rests under the
label your locator names, so the RECOGNISED level below claims it by name and the ledger gains
an act that never happened. Hand it over always; answer false while the press does nothing.
A false is silence, and because a declaration outranks a name match on the same element
it closes both levels at once — the per-element stand-down reportedElsewhere cannot express.
Five fields and one method. That is the entire framework interface: Vue is onMounted /
onScopeDispose plus a template ref; Angular is a directive with ElementRef and
ngOnDestroy. test/sensor-framework-interface.test.ts drives the whole declared level from a
plain object with no framework at all, so "thin" is a test rather than a claim.
React has that skin already — hcifootprint/react is one hook per
control, and the value your component is already holding goes over with it.
RECOGNISED — nothing declared. The sensor walks up from the event target, computes role and accessible name from documented subsets, and looks up your graph's own element bindings. Unique match → reported. Two or more → refused. A payload is never legal here.
The app declares the value. The sensor never reads one.
This is the deepest rule in the subpath, and it comes from bugs a production integration had to unship: a composed combobox that read as empty because its real value lived in component state; a button reported as "currently empty" because the node being interrogated was never the one holding the answer. The DOM is a rendering of your app's state, not the state. Reading a value back out of it fails silently, fails differently per component library, and fails as a plausible-looking value — which is the worst failure this library can ship, because a wrong payload in the ledger is indistinguishable from a right one.
So there is exactly one door: ControlDeclaration.value(). Absent, there is no payload
key at all — never payload: undefined, never {}, never ''. That is not fussiness:
aff.noInput already refuses a real payload with PAYLOAD_INVALID, and the empty string that
motivated it overrode the app's own authored default. An omitted key can never re-create
that.
The rule is enforced by an absent surface, not by a rule anyone has to remember: checked,
form, children and a per-control name are simply not on the sensor's element port.
There is no member a value-scraper could reach for. The single value read that does exist
serves <input type="submit" value="Save"> — a label, not a payload — and a test fails if
any other module reads it.
Which edges each level may report:
| any edge | expects: 'none' or absent | a real schema | |
|---|---|---|---|
| DECLARED | yes | attached, no value sent | attached with value(); omit it and you get value-not-declared once |
| RECOGNISED | — | reported | honestly unwatched, blocked: 'payload' |
A declared value is validated at the door
The schema gate is source-blind and runs for invoke: false too — "every source answers for
the payload, deliberately, including the record-only sensor." So a wrong declared value earns
PAYLOAD_INVALID rather than a wrong ledger row. A framework binding cannot launder a value
onto the ledger either.
Redaction and payloads — say it out loud
redactedKeys governs state keys and never touched a payload. A declared value reported
into payload is governed by redactedFields.payload instead — opt-in, aimed
per channel, off unless you ask. If a declared value must not reach the model or the journal,
name its path there; nothing in the sensor hides it for you.
One getter, two readers of it. The same value() is forwarded to the session's value door, so
the served action row can say what that control holds before anything
is fired — the payload of a gesture that happened, and the row describing the control nobody has
used yet. Nothing about the sensor crosses with it: no element, no report kind, and a
per-instance declaration is not forwarded at all, because one row of a repeats container cannot
answer for the rest. detach() and stop() take the reader back with them.
Record-only, in the type system
type RecordOnlyFire = Omit<FireOptions, 'invoke'> & { readonly invoke: false };The browser has already run your onClick by the time anything here records it. A fire that
also invoked would run one human click twice — so invoke is not a habit somebody could
forget, it is pinned. An executing fire is inexpressible through the port, and downstream
#invokeHandler returns on its first line for invoke === false. Every truth gate still runs;
the arms that only make sense for an executing caller (TOOL_DISABLED, NOT_MATERIALIZED) are
skipped by the engine's own design, because a greyed button a human really clicked is still
something that really happened.
One human act, one ledger row
The library has no dedupe primitive to lend — FireOptions carries no idempotency key and the
transition log is append-only — so this is the sensor's to own. Three collisions, three named
answers:
- Two doors. Mid-migration you still report some controls yourself. Name those edges in
reportedElsewhereand the sensor stands down for them, saying so incoverage()withblocked: 'door'. Read once, atwatchPage: a list that changed under a live watcher would silently re-open the double-row bug it exists to prevent. It applies to both evidence levels — one exclusion surface, not two. Delete your own door, delete this with it. - Two event classes, one activation. Enter on a
<button>fireskeydownand then a browser-generatedclickwhoseisTrustedis true. A known control reached by an event class that is not its committed moment is silence — not a row, and not an off-graph advisory either. The element is recognised; this simply is not its moment. - Two events, one turn. A
(edge, instance)already reported in the current synchronous task is not reported again. A<label>and the control it labels, both handed over for one edge, is a real page shape that delivers one human act as two clicks.
The agent's own clicks are not human acts
element.click() produces an event identical to a person's in every way except isTrusted.
The sensor reads that bit, because a production integration shipped without reading it and
recorded the agent's own synthetic clicks as human actions — source: 'user' on machine
motion is a lie in the one field the whole provenance model rests on.
The decline is diagnostic, not silent: { kind: 'synthetic-event', edge, instance?, actuation } names what it declined, reported only when the gesture would have been
attributed. That is what lets you delete your own isTrusted filter and watch the sensor catch
exactly what yours did.
It is workaround-grade and named as such. fire() is already the one invocation chokepoint;
a future one-door perform() over it — where the caller states who it is — makes this whole
class unreachable rather than merely detectable, and reportedElsewhere is already the
stand-down list such a control would register itself on.
Cadence — a library policy, not your rediscovery
A click has one moment. Typing does not: a human types thirty characters and means one act, and every row bumps the session's version. So the answer is decided here:
type Cadence = 'commit' | 'per-keystroke' | { readonly debounceMs: number };'commit'— the default. Commit-on-blur, and it needs exactly one listener:change. The browser fires it precisely when the human finishes — on blur, and on Enter; for a<select>, on pick. Adding ablurlistener alongside would be the double-row bug.{ debounceMs }—input, coalesced, last value wins. Named because a live-search field genuinely wants intermediate values on the ledger. The value is read when the window closes, so "last value wins" costs no buffering.'per-keystroke'— everyinput. Opt-in, and the cost is the point: one session version bump per keystroke.
Set it per watcher and override it per control. The clock comes from the root's own view, or
from options.timers for a test or non-browser host — and a debounced cadence with no
reachable clock is refused with cadence-unavailable, never quietly downgraded to the loudest
setting.
A window belongs to the value stream and to nothing else, so a page-wide { debounceMs }
never reaches a click: two clicks are two rows however loud the cadence is. The moment that
committed the act decides, not the setting alone — a window over clicks would swallow the
second of two real acts into the first one's window, and hold fire()'s truth gates open past
the cursor they are meant to be judged against.
Honesty on ambiguity — four gaps, four signals, never a guess
onReport receives one typed row for everything the sensor did and everything it declined.
There is no arm that writes a guessed session row:
| arm | what happened |
|---|---|
reported | recognised and recorded — result is the session's own answer, refusals included |
off-graph | real motion on a real control your graph never declared |
ambiguous | two or more live edges answer to one role + name; the sensor refuses to pick |
synthetic-event | code did this, not a person — with the edge it declined |
value-not-declared | a handed-over control for an action that takes a value, with no value() |
unwatched | a live edge the sensor is not watching, with the wall it hit |
cadence-unavailable | a debounced cadence with no clock to run it on |
watching | an advisory withdrawn — you lifted the wall, so the earlier one no longer holds |
sensor-error | the sensor itself threw — isolated, so your dispatch is never broken |
A click that never touched a role-bearing element is not reported at all: clicking a paragraph is not an interaction your graph failed to declare.
Advisories are said once per sentence and taken back when they stop being true. attach()
lives on the handle watchPage returns, so at the moment the first advisories go out a
declaration is impossible — every value-taking edge is advised about before you have had your
chance. Hand the control over and that edge reports watching; let it go again and the
advisory comes back. A report stream that could not retract would leave you believing a wall
you had already torn down, while coverage() said the opposite.
coverage() answers the other direction — one row per served edge, watching or
unwatched with the sentence saying why and which of three walls it hit (gesture, payload,
door). So the count you read back is the count your graph declared, and an edge can never go
missing between the two. The sensor never claims a locator resolves to a real element — a
watching edge that nothing answers to shows up as an edge with zero reported rows, which is the
same boundary the drift harness already draws.
Capture phase, and why it is correctness
Every listener registers with capture: true. Capture runs before your handlers, so
recognition and fire()'s truth gates evaluate against the cursor as it was when the human
acted. Let your onClick navigate first and the same click is judged against the page it
landed on — inventing STALE_CURSOR and NOT_ON_NODE refusals for actions that were
perfectly legal.
SSR, teardown, and location
root is required. The library compiles with lib: ["ES2022"] and no DOM, so naming a
browser global in src/ is a compile error — the app hands the environment in and the library
never reaches for one. The port is structural, so a real HTMLElement, Document,
ShadowRoot or Window satisfies it, and both halves are proved by a compiler probe rather
than asserted.
One watcher per shadow root. The DOM retargets a composed event that crosses a shadow
boundary: a listener on document.body reads event.target as the host, never the control
inside it, so the sensor computes the host's role and name and recognises nothing. (change
does not compose at all, so it never crosses.) Nothing is mis-attributed — a host presenting no
role is silence, exactly as clicking prose is — but nothing is reported either, and
coverage() cannot see that wall to name it: it speaks about your graph, and a locator is
never claimed to resolve to a real element. So hand the shadow root itself in. It satisfies
root, ids resolve against it rather than the outer document, and inside its own tree there is
no retargeting to lose.
stop() is idempotent and releases everything: every listener (through one shared capture
options object, because removeEventListener only cancels a registration whose capture flag
matches), every pending cadence timer, every declaration. attach → detach → attach nets
to one entry and watchPage → stop → watchPage nets to one listener set — the React
StrictMode shape, the same contract presence handles already keep.
watchLocation is off by default, and the cost of the other choice is worth stating: page
ids are author-chosen names, not URL paths, so handing location.pathname to sync() unasked
moves the cursor to a page that does not exist — available() then honestly serves nothing and
your whole agent surface goes quiet. Turn it on when your page ids are your paths; otherwise
own the mapping yourself, which is one line:
session.sync(matchRoute(graph.spec.pages, location.pathname) ?? location.pathname);What it does not do
It does not perform anything, ever. It does not read values out of the DOM — it only ever calls
the getter your app handed over. It does not write visibility —
PresenceIndex is already the presence sensor, and its own law applies here
too: no amount of mount-counting can see CSS, so visibility stays an explicit signal. And it
reports after the browser ran your handler, where a hand-written humanFire wrapper reports
before — so a guard refusal cannot block the act. If you need refuse-before-perform, that
belongs to a wrapper door, and the choice is yours to make rather than ours to hide.
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 React binding
A skin over the human sensor — one hook per control, no reporting call in your app, and the value your component already holds handed over rather than read off the DOM.