hacifootprint
Actions

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.

The human sensor needs no framework. hcifootprint/react is the skin that makes it disappear into a component: your control declares what it is, and the report call leaves your onClick for good.

import { watchPage } from 'hcifootprint/sensor';
import { ControlSurfaceProvider, useControl } from 'hcifootprint/react';

// once, where your app already builds its session
const watch = watchPage(session, { root: document.body });

// <ControlSurfaceProvider watch={watch}>…your app…</ControlSurfaceProvider>

function Send({ draft, send }) {
  const ref = useControl({ edge: 'compose.send', value: () => draft });
  return <button ref={ref} onClick={send}>Send</button>;
}

That is the whole adoption. onClick={send} is your own code, unchanged — the browser runs it, and the sensor records that a person did. There is no fire() call anywhere in your components, and nothing here can run your handler, so one human click can never become two ledger rows.

Five runtime exports, and four of them are hooks

what it is
ControlSurfaceProviderputs a watcher in scope for a subtree. watch may be null.
useControl(spec)returns a ref callback. Put it on the element.
useControlSurface()the watcher in scope, or null — for a component that needs to ask
useActionBinding(...)composes an existing listener with one executable live binding and returns { ref, hostProps, getBinding }
useWorking(spec)the async half: your busy flag becomes a work row and a busy label

useControl's spec is the core's own ControlDeclaration minus the element, because the ref supplies that: edge, and optionally instance, value, cadence and commits. It is derived from that type rather than restated, so the two can never drift.

The ref callback takes the structural SensorElement, which every HTMLElement satisfies — so it goes onto a <button>, an <input> or a <div> with no cast.

useActionBinding — one callable, one exact live binding

useControl is record-only: the sensor observes the browser after your listener runs. useActionBinding is the executable alternative. Start with a callable made by defineAction, render that same application behavior normally, and give the hook a host adapter that knows how to compose the listener and resolve the committed interactive element. The hook opens one binding only after the ref commits; render itself remains pure.

import type { MouseEvent } from 'react';
import {
  composeActionInvocation,
  createActionRuntime,
  defineAction,
  type ActionHostAdapter,
  type ActionInvocationMiddleware,
} from 'hcifootprint';
import { useActionBinding } from 'hcifootprint/react';

const runtime = createActionRuntime();
const save = defineAction('draft.save', {
  does: 'Save the draft',
  invocation: 'inputless',
  settle: { writes: ['draft.savedRevision'] },
  mutate: () => saveDraft(),
});

interface SaveProps {
  disabled: boolean;
  onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}

const buttonAdapter: ActionHostAdapter<
  SaveProps,
  HTMLButtonElement,
  HTMLButtonElement,
  HTMLButtonElement,
  ActionInvocationMiddleware<unknown, [MouseEvent<HTMLButtonElement>], void>,
  SaveProps
> = {
  composeInvocation(props, invoke) {
    return { ...props, onClick: composeActionInvocation(props.onClick, invoke) };
  },
  resolve(_props, host) {
    return { kind: 'resolved', interactive: host, valueElement: host };
  },
  readEnabled({ props }) {
    return !props.disabled;
  },
  readCoverage() {
    return 'verifiable';
  },
};

function SaveButton(props: SaveProps) {
  const binding = useActionBinding(runtime, save, props, buttonAdapter, {
    node: 'draft',
    availabilityKey: props.disabled,
    onInvocation: async (invocation, effects) => {
      const performed = await invocation.whenInvoked;
      if (performed.status !== 'performed') return;
      const evidence = await readSavedRevision();
      effects.settle({ status: 'verified', evidence });
    },
    onInvocationError: reportInstrumentationError,
  });

  return <button ref={binding.ref} {...binding.hostProps}>Save</button>;
}

Keep the definition, runtime, and adapter stable outside render (or deliberately memoize the application-owned equivalents). Ordinary prop changes update committed readers without replacing binding identity. Use attachmentKey only when a prop changes descendant resolution, coverage, or locator projection.

If the adapter exposes readEnabled or readBusy, availabilityKey is the committed generation of every fact those readers can observe. Change it whenever availability meaning changes. With a stable explicit key, unrelated rerenders preserve current offers; a changed key retires them. If the key is omitted, the hook conservatively publishes a new revision on every committed render so an unobserved false→true or busy→idle round trip can never revive an old offer. If inputKey and availabilityKey change together, the hook publishes exactly one revision. Supplying availabilityKey to an adapter with neither readEnabled nor readBusy throws because that key would name no runtime fact.

When a callable definition declares an allowed payload shape, it names that declaration inputSchema; the hook's input: (props) => value instead makes this live control own a bound payload. Supply inputKey with it:

useActionBinding(runtime, archiveOrder, props, buttonAdapter, {
  node: 'orders',
  instance: props.order.id,
  input: () => props.order.id,
  inputKey: props.order.id,
});

inputKey is the opaque semantic generation of everything the reader can observe. Keep it stable while the reader means the same payload and change it whenever props or closed-over store state can change that payload. It is required with input, forbidden without it, compared with Object.is, and never serialized or used as identity. attachmentKey has a different job: host resolution and locator projection.

When the key changes, the commit atomically installs a reader owned by that exact render and retires old offers. This closes the exact stale-input race: replacing latest.current from the o-57 render with the o-58 render without advancing the binding revision would let an offer minted against the old revision invoke through the new reader. The hook now publishes a fresh revision as part of that commit, so the old offer fails stale before the o-58 reader can be used. Same-key rerenders keep offers live only when the adapter has no availability readers or also receives an unchanged explicit availabilityKey; omitting that availability key deliberately retires offers on every commit.

The reader runs once when runtime.forPrincipal(principal).offers() mints a new bound offer—never during render or commit—and invoke(offer) under authority for that same principal uses the capture without rereading it. Recreating a port for the principal is equivalent. A failed publication disconnects the binding, so an instrumentation failure cannot leave an old offer pointing at new committed props.

Parseable inputSchema declarations are enforced directly. Other formats such as plain JSON Schema need the runtime's synchronous inputSchemaAdapter; disclosure mode is the explicit metadata-only alternative. See Live bindings for the bound/open/none offer modes and adapter example. That page also lists the grouped guard, settle, and principal contracts. In this framework-neutral runtime, strict activation rejects guard.when, guard.enabledWhen, settle.verify, and principal.requiresHumanApproval because no state, evidence, or approval port exists here; disclosure carries them without claiming enforcement. principal.mayInvoke is always enforced before live readers run.

Coverage is a fail-closed boundary. The hook connects and projects sensor ownership only for explicit executable or verifiable coverage. Missing, identity, or semantic coverage leaves the original listener untouched and creates no binding; use useControl when record-only sensor ownership is the intended level. If a custom component emits an output while its app-owned enabled reader says false, that occurrence has already happened: the hook still executes the existing listener once and records it. Enabledness continues to gate agent offers and direct protocol invocation.

Projecting a binding to a page watcher arbitrates reporting ownership only. The resulting binding transition does not invent a human principal, attribution basis, or certainty. Keep the sensor-owned path when the receipt must carry its existing human provenance, or add an explicit provenance rail before describing a connection-owned occurrence as human-attributed.

The composed callback is generation-owned. A callback retained from an old host may still finish its own application listener, but it cannot attribute that work to the replacement binding. onInvocation receives the exact invocation and a frozen ActionSettlementCapability containing only binding and settle; it can settle only that invocation's effect rail. Observer failures are severed from the application result and can be routed through onInvocationError. Neither observer can replace the listener's return value or thrown value. The observer is installed on the core connection, so it receives both the human host continuation and runtime.forPrincipal('agent').invoke(offer) from an agent; verification does not disappear when the same React binding is driven through the broker. Broker code obtains offers and invokes them only through that principal-scoped port. Runtime history lookups use structured definition, binding, and transition refs; the string fields inside them are display projections, not lookup doors.

Those two invocation doors do not have to return the same type. invocation.behavior is 'host-continuation' for the composed UI callback and 'mutation' for direct or brokered execution, and narrows whenInvoked to the corresponding result. The React adapter supplies the host callback type; the action definition supplies the mutation type. A void onClick may therefore support an async action without either result being widened or misreported as the other.

The capability accepts status: 'verified' only when two independent facts agree: the definition authored nonempty settle.writes, settle.goTo, settle.verify, or observable settle.observability, and that exact invocation ran with verifiable live coverage. Reads, progress stages, and observability: 'unobservable' do not establish an evidence contract. The example above satisfies both gates with writes on the definition and verifiable from the adapter.

If the definition declares settle.progress, brokered or direct definition invocation receives its transition-owned lifecycle and may report declared stages. Closed progress records exact declared, observed, and unreported; required: true with no observation is a silent integrity: 'unmet' finding and never changes the listener result. Detaching or unmounting the React host does not abandon an in-flight transition. Abandonment requires explicit cancellation, deadline, or exhausted-evidence authority, and the first effect terminal wins.

The value your component already holds

This is the whole reason a framework binding is worth having. The sensor never reads a value off the DOM, so a value-bearing control is honestly unwatched until an app declares one — and a component is exactly the thing that already has it in a variable:

const ref = useControl({
  edge: 'compose.send',
  value: () => draft,        // your state, handed over
  cadence: 'commit',         // per-control override (default: commit-on-blur)
  instance: ticket.id,       // one row of a repeats container
  commits: () => armed,      // "is a click on me the act yet?" (see below)
});

Read at report time, from the render you are looking at

Your getter is written inline, so its identity changes on every render while the control does not. The hook keeps the newest committed getter and re-declares nothing, so a re-render costs nothing and the value on the ledger is the one that was on screen when the human acted. "Committed" is load-bearing: React may begin a render, yield and throw it away, so the newest getter and the newest one the human could see are different answers — the hook takes the second. Change the edge, the instance, the cadence window, or whether a getter exists at all, and it is a new control — those are the control's identity, and it is re-declared.

A control that is not the act yet

The commonest control a binding gets wrong is a confirm button: one element, two clicks, and only the second one does anything. The obvious move — hand the element over only once it is armed — is the bug. Unarmed, the button rests under the label your action's own locator names, so withholding the declaration does not withhold the report: it just moves the answer to the recognised level, which reads that label off the page and records a delete that never happened.

So the element goes over always, and commits is where you say which press is real:

const [armed, setArmed] = useState(false);
const ref = useControl({ edge: 'archive.clear', commits: () => armed });

<button ref={ref} onClick={() => (armed ? clearArchive() : setArmed(true))}>
  {armed ? 'Really clear?' : 'Clear archive'}
</button>

false is silence, not a report — nothing the graph declares happened. And because a declaration outranks a name match on the same element, it closes both evidence levels at once. That is the per-element, per-moment stand-down reportedElsewhere cannot express: that one is per-edge and page-wide.

What you delete, and what you keep

Adopting the hook deletes reporting, never behaviour. In the live-desk demo it removed seven of nine hand-written report calls and the refusal plumbing that went with them; the whole page now needs one watchPage call and one declaration per control.

What it cannot delete is refuse-before-perform. A hand-written wrapper reports first, so a guard that does not hold blocks the act. The sensor listens in the capture phase — before your handler, but still after the human clicked — so it records what happened and cannot stop it. That is a choice, not an accident:

  • Guarded action, and the refusal must arrive first? Keep your own wrapper, and name that edge in reportedElsewhere so the sensor stands down for it. One act, one row.
  • Everything else? Use useControl for record-only sensor ownership, or useActionBinding when the exact live control must also be executable by an agent.

live-desk keeps exactly three controls on its own door for that reason, and says so in the code.

useWorking — your own spinner flag, on the ledger

The fourth hook is the async half, and it takes nothing you do not already have. A component that renders a spinner has a boolean; the words under that spinner; and the error it shows when it fails. useWorking turns the two edges of that boolean into the two calls the core has always had — beginWork / done — and stands your busy label on the control while it runs.

import { useWorking } from 'hcifootprint/react';

function SaveButton({ session, saveTool }) {
  const save = useMutation({ mutationFn: saveToServer });

  useWorking({
    busy: save.isPending,          // your flag — the one the spinner already reads
    label: 'Saving your draft…',   // your words
    error: save.error,             // the error you already render
    actions: saveTool,               // the control that should carry the label
    session,
  });

  return <button onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save'}</button>;
}
the fieldwhat it is
busyyour app's real spinner flag. A boolean kept only for us is a second copy of a fact, and the copy nobody looks at is the one that rots
labelyour words, carried as data — the work row's label and the control's busy
errorread by presence at fall time; absent closes the row cleanly, and null is absent (React's own spelling of nothing went wrong)
toolsone control handle or many. Omit it for work no single control stands for
sessionnarrowed by its type to two doors: beginWork and warn
transitionIdoptional, and read where the row opens. Names the fire this work belongs to; without it the row is honestly unbound

Each field is read at its own edge, and that is the whole timing contract. The id is read where the flag goes up, because the core decides where work lands at call time and never revisits it; the error is read where the flag comes down, because that is the commit that knows how it ended.

The id has to be in hand at the rise

A mutation's isPending flips where you call it, and the id only exists once the function that fires has run — one commit later. An id that arrives then does not move the row it missed: the row keeps saying what it said at the rise (for that component, unbound), and the hook warns once rather than dropping an input you plainly meant. Bind it by setting the flag and the id from one state update after the fire result hands the id back, or open the work inside the handler before its first await, where it binds itself.

Every rise is its own row. A flag that flaps three times writes three rows — two pieces of work at two different times are two facts, and nothing here reuses a row or dedupes by recency. StrictMode's double-invoke is one piece of work: the edge detector is a ref, which survives the simulated remount, so the row is re-adopted rather than opened twice.

Pointing at one action inside a group. ActionGroup.setBusy names the action first, so a group is deliberately not assignable — passing one is a compile error rather than a call that labels an action named "Saving…". Name the action where the group already knows how:

actions: { setBusy: (label) => group.setBusy('save', label) }

Written inline like that it is a new object every render, which this hook honestly treats as a new control — and it costs nothing: world motion is coalesced and compared by fingerprint, so a take-back-and-re-say inside one window cancels to nothing.

Unmount is deliberately asymmetric

A component going away is not the work ending, so the work row stays open — openWork() keeps serving it and did_it_work keeps saying stillWorking. Closing it would mint a verdict out of silence, and no timer will ever end it either. The busy label is cleared, because a label is a claim about a control and the thing that was keeping it true has gone. What is unknown stays open; what was claimed is taken back. One dev warning says so, once.

It cannot report that something worked. The two doors it drives settle nothing: done(error) is recorded on the work row and reaches no door that answers how a fire came to rest. The failure spine stays a handler throw, a returned { ok: false }, or reject() — so the worst a wrong flag here can do is say the app is working when it is not.

Mounting, unmounting, and the first commit

watchPage needs a browser root, so an app builds the watcher in an effect — and effects run after the refs beneath them. The first commit of a real app therefore has no surface, and that is an ordinary case rather than an edge one: useControl returns a ref that does nothing while watch is null, and when the watcher lands, every control below attaches itself. Nothing to retry, nothing to guard.

const [watch, setWatch] = useState<PageWatch | null>(null);
useEffect(() => {
  const page = watchPage(session, { root: document.body });
  setWatch(page);
  return () => {
    setWatch(null);
    page.stop();
  };
}, [session]);

The same shape covers server rendering (there is no root on a server, so there is no watcher) and StrictMode, whose double-invoke is setup → cleanup → setup: watchPage → stop() → watchPage nets to one live listener set, and attach → detach → attach nets to one declaration. A component that uses the hook with no provider above it renders perfectly and reports nothing — adopting this subpath can never change whether your app renders.

An optional peer, and a separate subpath

react is an optional peer, and hcifootprint/react is the only place in the package that names it. A consumer who never writes from 'hcifootprint/react' never resolves react — no dynamic-specifier trick, just an ordinary static import in a folder you did not ask for. The record-only control and working hooks reach the core through types alone. The executable action hook value-imports only the framework-neutral connection, coverage, and host-adapter leaves — never a watcher, session, graph, registry, or footprintjs. The boundaries and size ceilings are pinned by test/treeshake.test.ts and test/react-treeshake.test.ts, because "it drags no engine" is the kind of promise that only stays true while something measures it.

The peer range is * on purpose, and it is the honest one. optional means "need not be installed"; it has never meant "version ignored when present", so a floor written there is a rule about your whole tree — and hcifootprint does not need react at all. Writing >=18 turned npm install hcifootprint into an ERESOLVE failure for a React 17 app that never imports the subpath. The subpath's floor is real and is React 18 (it uses useInsertionEffect); it is enforced by the import itself, so it can only reach someone who actually imports it.

Redaction and declared values

redactedKeys governs state keys and never touched a payload. A value you declare here rides into payload, which is governed by redactedFields.payload instead. If it must not reach the model or the journal, name its path there — nothing in the hook hides it for you.

What is deliberately not here

  • No createControlSurface. The one job such a wrapper could do — supply document.body as a default root — is impossible inside this package: the library compiles with no DOM types, so naming document in src/ is a compile error. That is exactly why WatchOptions.root is required, and a wrapper that only renamed watchPage would be a second name for one thing.
  • No implicit handler registration in useControl. registerActions remains the existing session mount door and needs no framework. The record-only hook cannot reconstruct a resolved edge id from node and id. Applications that need an executable live binding opt into useActionBinding with an explicit runtime and callable definition.

Vue and Angular

Nothing above is React-only except the scheduling, and that is true of all four hooks.

For a control, the framework interface is five fields and one method — watch.attach({ edge, element, instance?, value?, cadence?, commits? }) — so Vue is onMounted / onScopeDispose plus a template ref, and Angular is a directive with ElementRef and ngOnDestroy.

For working, it is five lines: beginWork and setBusy where the flag goes up, done() and setBusy(undefined) where it comes down, in whichever of that framework's three moments already exist. There is no subscription to adopt and no scheduler to hand over.

For executable action binding, the same framework-neutral connection lifecycle sits underneath: connect and attach at host commit, update from committed readers, compose the existing output once, and disconnect from the framework's destruction hook. The test-only structural Angular lifecycle proof exercises those same doors without shipping or importing Angular into the core.

The record-only control and working skins are driven from a plain object with no framework at all — test/sensor-framework-interface.test.ts and test/work-framework-interface.test.ts — so "thin" is a test rather than a claim, and the day a Vue or Angular skin lands, nothing in the core has to move for it.

On this page