Releases: footprintjs/hcifootprint
Release list
v2.5.0 — the question is on the record
requestInput() — the HITL request lifecycle: the offered-set law (refusal names the list, request stays open), absence established never assumed, first-terminal-wins with late answers kept and quoted, kind-governed and surface-routed with counted gaps. declareLifecycle() — declared state charts that own WHETHER while the host owns WHEN; terminals never reopen, structurally. See CHANGELOG.md.
v2.4.1 — the core, decomposed to the mantra
[2.4.1] - 2026-08-26
Changed
- The core, decomposed to the mantra:
connection.ts2,614 → 992 lines.
Phase two of the split — composed units, not just moved leaves. The
runtime now composesKindGovernor,SurfaceBoard, and
TransitionLedgeras owned fields (the ecosystem rule: compose a store
as a field, no base classes), and the ~740-line heart ofconnect()
moved behind a spelled-out seam:ConnectionCorelists every capability
the builder may use, so a capability not listed is one provably unused.
The closure web inside the builder stays together BY DESIGN — it shares
per-connection state, and splitting it would mean turning shared locals
into a context object: a rewrite wearing a refactor's clothes. Verbatim
behaviour, all 3,033 tests green, the React treeshake boundary and byte
cap unchanged.
v2.4.0 — the breakable walk, in a module split to the library's own mantra
[2.4.0] - 2026-08-26
Added
- The breakable walk — the human door, with the reason on the record.
walk.interrupt({ by, reason })stops a plan at the next step boundary;
the in-flight step finishes and settles normally, because an L1 transition
is atomic — L2 stops future steps, L1 transitions are never torn. The
manifest carriesinterrupted: { by, reason, beforeStep }, distinct from a
refused row, because "the plan was wrong" and "the person knows something
the plan didn't" must be treated completely differently by whoever
replans. The reason is REQUIRED — a break without one leaves the model
replanning blind, which is how it replans the same thing. The walk
survives the break (replanning continues under the same walk id), an
interrupt armed between plans is consumed at the next run's step zero —
the intent stands — andrun(steps, { onRow })streams rows as they land:
the FE's live loop, and where the stop control belongs, beside it. A
throwing listener never changes what the walk does or records.
Changed
connection.tssplit by concern: 2,614 → 1,891 lines, nine small units
extracted —stored(the internal data shapes; data separated from
logic, one shape one owner many operators),progress-ledger,
settlement,input-validation,principals,authoring,
binding-facts,declarations,observer-capture— each with its own
header stating why it exists, mapped insrc/action/README.md. Verbatim
moves, zero behaviour change, all 3,033 tests green. Walk ids are now
per-runtime rather than module-global (a correlation nicety, not a
security fix). And the 3.0 subpath-door decision is RECORDED with a CI
fence:test/barrel-surface.test.tspins the root surface so growth is a
deliberate edit and shrink waits for the major — a decision that cannot
drift is one that does not need re-arguing.
v2.3.1 — the 2.x story, documented where each audience looks
[2.3.1] - 2026-08-26
Documentation
- The 2.x story, documented where each audience looks — because docs are
the adoption surface. Guide pages for the walk, kind governance, and
surfaces-and-gaps join bring-your-own-skin in the docs directory, each
opening with WHY before HOW and carrying an example that COMPILES against
the published package (verified, not assumed). Folder READMEs land beside
the two newest modules, matching the house convention every other module
already follows:src/action/README.mdmaps one file to one concern with
the reason each exists, andsrc/react/README.mdstates what the hook is
(one skin over the framework-neutral core), what it deliberately is not
(no strategy option — a seam inside a skin would duplicate the seam the
core already is), and the four laws any bring-your-own skin must uphold.
v2.3.0 — channels: surfaces serve kinds, and a miss is a counted fact
[2.3.0] - 2026-08-26
Added
- Channels — surfaces declare what they can serve, and a miss is a counted
fact.runtime.declareSurface({ surface, node, collects, shows })says by
KIND what one frontend surface can obtain from a person and what it can
render;runtime.surfacesFor({ collects } | { shows })answers who can
serve a kind. An action never names a page and a surface never names an
action — both name kinds, governed by the mounted catalog with the same
connect-time refusal a definition gets, and a QUERY is governed too,
because otherwise the gap record fills with typos and stops meaning
anything. The degradation record is the point, not the fallback:
runtime.channelGaps()holds every kind somebody needed served when
nothing could, COUNTED — asked-once and asked-forty-times-a-day are
different priorities wearing the same row — so a month of degraded turns
reads back as a product backlog written by actual usage. One live surface
per id, duplicate refused; retirement is idempotent, owned by its handle,
and can never take down a successor declared under the same id. - The skin contract (
docs: bring your own skin). The React hook is
sugar, not the seam — a consumer writes their own framework skin against
connectActionand the host adapter, and the library owes them the four
laws a skin must uphold: publish only after commit; advance the generation
when committed input changes (the shipped stale-input defect, by name);
retire on unmount and revision change; never hold "the current
transition". The dependency-free Angular test is named as the template for
proving one. There is deliberately nostrategyoption on the hook: a
strategy parameter inside a skin would be a second extension point
duplicating the seam the core already is.
v2.2.0 — kind governance: the vocabulary, governed before matching exists
[2.2.0] - 2026-08-26
Added
- Kind governance — the vocabulary, governed before matching exists.
needsandproducesname kinds, and two teams declaringarrayto mean
different things is the stringly-typed failure rebuilt one level above ids
(the reference app contains exactly this collision, live).declareKinds()
builds the default catalog from per-team contributions — the runtime owns
the merge, and a duplicate is a refusal naming BOTH contributors, never a
silent last-writer-wins. Mount it withcreateActionRuntime({ kinds });
an unknown kind is then refused at CONNECT time, where the developer is
looking, in the catalog vocabulary. The interface is two methods (has,
describe) plus a fingerprint — enumeration lives on the concrete default
only, so a future remote adapter is never forced to promise a listing it
cannot give synchronously. A mounted catalog is immutable, which makes it
memoizable: consulted once per kind, ever, so an adapter can never cost the
offer-serving path.KindRecord.revisionrides the fingerprint, because
kind names are forever while meanings evolve, and two sides holding
different meanings of one name should refuse loudly at a seam instead of
matching silently. And no catalog mounted is a VISIBLE state:
runtime.kindGovernance()reports every kind seen and the ungoverned
remainder — an unarmed check indistinguishable from a passing one is the
disease this family keeps curing.
v2.1.0 — the walk: many actions as one correlated route
[2.1.0] - 2026-08-25
Added
- Layer 2 — the walk: many actions as one correlated route.
beginWalk()
admits a PLAN up front and executes it with no model call between steps,
answering with a MANIFEST — ran, refused, never-reached, per row — because a
screen action cannot be rolled back, so partial execution must be legible
rather than summarized as "failed". Admission refuses from declarations
alone, before anything runs: a step whosedecisionOwnerthe walking
principal does not own ("an agent may not pre-plan a decision it does not
own"), a principal outsidemayInvoke("refused at plan time rather than at
step three of a half-executed screen"), and acarrywhose producer
declares noproduces— a carried value must come from a declared output,
never from whatever a handler happened to return. Execution re-derives every
step's offer at its own turn: batched is not blind, and a guard that stopped
holding mid-plan is a refused row with L1's own teaching sentence. One turn,
one walk, MANY plans — a refused step means the caller replans under the
same walk id, andwalk.record()holds the route actually taken. L2
introduces no primitive L1 lacks; the walk identity is a correlation, not a
concept.
v2.0.0 — the Action Binding Protocol
[2.0.0] - 2026-08-25
The Action Binding Protocol. Declare an action once; bind it to every live
control that can perform it. Four identities with four cardinalities — a
definition is what a capability IS, a binding is where it is connected, an
offer is the facts it was exposed under, a transition is one invocation — and
no consumer ever recovers one from another by string surgery. This is the v2
contract: createActionBindingRuntime is now createActionRuntime, offers are
principal-scoped (runtime.forPrincipal(p).offers() — the principal belongs to
the reader, not the binding), progress reports declared/observed/unreported
at close, abandoned requires an explicit authority (never inferred from a
detach), and offered inputs are revision-exact: an invocation can never read a
newer committed input while carrying an older offer.
Fixed
- Late evidence is kept, marked late, and reopens nothing. A settlement
arriving after the terminal used to vanish into a return of the first one.
It now rides the transition aslateSettlements— a QUOTATION of what the
late caller claimed and the payload it brought, never validated as if
accepted and never allowed to reopen a terminal. Absent when none arrived,
because an empty list would claim "we watched and none came." - A stale offer is no longer called a forgery. Invalidation deletes a
retired offer from every map, so the old refusal accused a slow caller
replaying yesterday's offer and an attacker of the same crime. The refusal
now says what the runtime can still establish: a binding serving a current
offer names it as the next move; a registered but offerless binding says the
surface moved on; an unknown binding admits it cannot tell never-ours from
since-detached, and will not guess.
Added
- Action Binding now joins what was offered, invoked, and settled.
defineAction()declares one callable action, framework-neutral connections
bind its live instances, andruntime.available()returns self-describing
none,bound, oropenoffers.runtime.invoke()is the single broker for
offered execution: it accepts only the exact retained offer, captures bound
input when that offer is minted, validates caller input for open offers, and
carries a non-secret input receipt through the invocation, transition, and
settlement history. Definitions explicitly declareinvocation: 'inputless' | 'scalar' | 'host'; broker eligibility is never guessed from
Function.length. Open offers require one caller slot and are withheld when
no schema tells a UI/agent how to construct that input. - Channel declarations have a stable Layer 1 home. Callable definitions can
store typedneedsandproduceskind declarations. They are validated and
frozen but deliberately inert until the later channel/surface broker exists. - Input contracts have an enforcement port. Schemas with synchronous
.safeParse()or.parse()methods are enforced directly; applications can
supplyinputSchemaAdapterfor formats such as JSON Schema plus Ajv. Strict
activation refuses an unenforceable schema instead of presenting metadata as
an active gate. - Live fact generations are explicit.
connection.update()is inert when
facts do not change, whileconnection.touch()deliberately retires existing
offers without running application readers. React input bindings require an
inputKey; changing it atomically publishes the newly committed input reader
and invalidates the prior offer without changing binding identity. - Invocation outcomes distinguish refusal from failure.
refusedmeans the
application handler never started;failedmeans it started and then
threw/rejected. Preflight input refusals immediately settle their effect as
not attempted, and connection observers now receive direct, brokered, and
host-continuation invocations through one narrow settlement capability. - React availability generations are explicit.
availabilityKeynames the
committed facts read by adapter enabled/busy readers. Omitting it chooses a
conservative revision after every commit; simultaneous input and availability
changes still publish one revision.
This will be the first public callable Action Binding shape. Its cross-copy brand
is hcifootprint.action-definition.v1; that suffix versions the frozen branded
record, independently of the npm major version. The existing navigation-graph
ActionDef.input field remains unchanged; callable definitions deliberately use
inputSchema.
v1.13.0 — actions that happen once, and ledgers that tell the truth about what they dropped
[1.13.0] - 2026-08-20
Added
-
concurrency: { mode: 'once' }— the receipt survives settlement
(Context Integrity, the choice seam).single-flightsuppresses a repeat
while the first occurrence is unresolved; the measured duplicate-execution
failure happened after it settled.'once'is a third mode at the same
declaration door: one EXECUTED occurrence per scope for the session, refused
DUPLICATE_EXECUTIONwith the receipt (priorTransitionId) and the authored
howToRepeatsentence. Two field lessons are the law's shape: only a person
acting on the screen (a user-attributed transition after the receipt) reopens
the action, and the reopened repeat FIRES carryingrepeatedon the result
AND the transition record — report, don't refuse. A refused first occurrence
minted no receipt; an'unobservable'one counts (unprovable non-execution
is not non-execution). While the first is unsettled,'once'answers exactly
as single-flight (PRIOR_FIRE_PENDING). The served row carries the verdict
(alreadyPerformed, action scope only) and Mode B greys the control out —
never send a person to approve a fire about to be turned away. Scopes,
payload identity and the matcher are single-flight's own (sameFire, one
owner of "what counts as the same fire again"). -
TransitionRecord.instance— the repeats-container card a fire named now
rides its receipt. A record that says what was pressed but not which row
cannot answer "did we already cancel order #57?". -
Bounded ledgers state their retention window, and every silent cut now
speaks (Context Integrity, phase 0).session.offersRetention()/
session.acknowledgementsRetention()answer{ minted, dropped, firstRetained, lastRetained }— the window that is still answerable, not
only the loss — andsession.offerStanding(id)asks the three-way
retained/evicted/unknown question without firing anything. Oversized
sensing trails join the house standard: evictions are counted
(sensedTrailsDropped()), andsensedTrail()on an evicted trail says
EVICTED with the surviving count instead of an answer indistinguishable
from "never sensed". The data channel's silent caps now announce
themselves the way its string cap always has: an array cut at 30 appends
… N more omitted, an object cut at 40 keys carries"…": "N more key(s) omitted". And a repeats row's render cap states its size:
instancesTotalrides the served row and both instance refusals, so 50
keys of 200 can never read as 50 of 50 (fireability was always uncapped;
now the served row says so). -
The unknown-instance refusal says which kind of wrong it is (Context
Integrity).INSTANCE_UNKNOWNnow carries
verdict: 'never-existed' | 'unsupported', decided by the coverage of the
set the id was compared against — the same source the served row's
enumerationstates. Absent from the app's declared existence source (a
selectorset enumerates everything that exists) →'never-existed', and
the refusal may finally say so. Absent from the mounted window →
'unsupported': not backed by anything served, and that is ALL the refusal
knows — a window has an edge, the world continues past it, and under a
capped window an id past the render cap is exactly the case
'unsupported'protects. A selector that throws or answers a non-array
falls back to the mounted window, and the verdict falls back WITH it: a
nonexistence claim never outlives its evidence.
v1.12.0 — an effect that is already true says so
An effect that is already true is not a pending one. When an action's declarative verify contract covers every key it declares it writes and already holds at fire time, the fire never waits for a state report that nothing will send — it settles on its own handler and answers alreadyTrue.
alreadyTrue — for an action whose effect the world already holds
What changes. Press a control while the thing it does is already the case, and the fire says so on the very first result instead of waiting forever. It carries the conditions that already hold, plus one sentence telling the reader nothing needed changing and not to press it again. Your handler still runs — deciding on your behalf that a press was pointless is not this library's call.
Why it wasn't there. An agent opened a domain view while already inside that domain. The app's store publishes when a value changes, so writing the value it already held notified nobody, the report never came, and the fire waited for it with no clock over it. did_it_work could only answer still-pending; the agent read that as the app still working, and spent fifteen of its thirty steps on an outcome that could never arrive. Nobody was at fault — the app's netting is correct and the library's refusal to guess is correct. The library is simply the only party standing where both halves are visible.
How it improves. Declare the postcondition beside the action and the library can see the outcome already holds before anything runs. An action with no declarative verify behaves exactly as it did in 1.11.0 — nothing is guessed on its behalf.
Four refusals learn to teach
ACTION_REQUIRED, JOURNEY_REQUIRED, TRANSITION_ID_REQUIRED and KEY_REQUIRED were raised as bare codes. Each now carries a plain sentence saying what is needed, the valid values where a valid set exists, and the fact that nothing was performed — a missing argument is not the app turning something down. KEY_REQUIRED deliberately serves no list and says why, because no ceiling of valid keys exists.
Every code is unchanged for consumers catching them today. Clean-room probed on the packed tarball: 66/66.