hacifootprint
Actions

The walk

Many actions as one correlated route — a plan admitted up front, executed without a model call between steps, answered with a manifest.

Why

A model that returns to its orchestrator between every screen action spends a round trip per step re-deciding things it already decided. And a batch that plows ahead blindly is worse: a screen action cannot be rolled back, so "the batch failed" while the screen sits two steps along is a lie of summary. The walk is the middle: admission checks what declarations can prove before anything runs; execution re-derives every step's offer at its own turn; and the answer is a manifest — ran, refused, never-reached, per row — so partial execution is legible instead of summarized.

Browser automation can batch operations too. The walk's guarantee is reuse of the app's declared actions and live invocation checks; it does not require or imply a model round trip per browser click.

The example below uses a fixed local archive and records an open request; it makes no model or network call. Replace those application functions with the ones your app already owns.


const  = ('runs.find', {
  : 'Find the most recent zone-redundancy run',
  : 'inputless',
  : { : 'run.ref' },
  : { : { : ['searching', 'found'], : true } },
  : async (?: ) => {
    ?.('searching');
    const  = await .('zone-redundancy');
    ?.('found');
    return ;
  },
});
const  = ('runs.open', {
  : 'Request that one run opens on screen',
  : 'scalar',
  : ,
  : (: string) => .(),
});

const  = ();
(, , {
  : 'runs',
  () {
    .?.( => .());
  },
});
(, , { : 'runs' });

const  = (, 'agent');
const  = await .([
  { :  },
  { : , : { : 0 } },
], { :  => .(.) });
// manifest.rows    → [{ status: 'ran', transition }, { status: 'ran', transition }]
// manifest.counts  → { planned: 2, ran: 2, refused: 0, neverReached: 0 }

The first action's declared output is carried explicitly into the second action's validated payload. The progress subscription reports stages inside the first invocation; onRow reports each manifest row as it lands. ran rows land after whenInvoked settles. Neither requires a model call between these known steps. A missing offer, ambiguous instance, refusal or failed invocation stops later steps; the caller can inspect the manifest and replan under the same walk identity.

Invocation completion and effect truth

manifest.completed means every invocation performed: the walk awaits whenInvoked, not whenEffectSettled. In this example, an open request returning is not proof that the screen committed or that a business effect succeeded. The transition's effect status and authoritative settlement remain separate. A closed progress stream is not that settlement either.

Batch steps whose next inputs and availability are known. If the next step depends on a delayed commit, a fresh observation or a decision nobody owns yet, return to the host/planner or person, read the actual evidence, and submit the next plan. The walk does not invent a pause/resume protocol, infer a dependency graph from needs, or automatically become a Session journey served through MCP/WebMCP.

Browser observations can be authoritative evidence when the app's adapter defines and reports that contract. Merely clicking or seeing a change does not automatically verify the intended business outcome. The binding settlement rules keep a handler result, execution progress and effect evidence distinct.

Parallel independent actions

Available since 3.2.0. beginWalk returns an ActionWalkWithParallel that extends the existing ActionWalk; sequential run and its output carry are unchanged. Each item uses the public ActionParallelStep type, which excludes output carry between items. Use parallel only for actions the application knows are independently available.

One tab still has one current page

Navigating from page 1 and then using page 2 is dependent work. Keep that sequence out of a parallel group, and read the application's actual readiness evidence before acting on the next page. The callable runtime treats node as a binding label. It does not inspect the browser, prove visibility, establish independence or create isolated contexts. Different node names are not a parallel-safety test.

The host must disconnect a control when it unmounts. If hidden controls remain mounted, their enabledness must reflect the active page. Only describe separate browser contexts when the host actually provides them. Parallel work fits independently available actions or background work that does not compete for the same navigation state; an action's name or placement alone is not proof.

Two independent background reads

This checked example uses fixed local values and makes no network or model calls. Both connections use the same background label: their independence comes from the application functions, not the label.

import { , , ,  } from 'hcifootprint';

const  = ('summary.read-orders', {
  : 'Read the order count from the local snapshot',
  : 'inputless',
  : async () => ({ : 12 }),
});
const  = ('summary.read-stock', {
  : 'Read the stock count from the independent local snapshot',
  : 'inputless',
  : async () => ({ : 7 }),
});

const  = ();
(, , { : 'background' });
(, , { : 'background' });

const  = (, 'agent');
const  = await .([
  { :  },
  { :  },
], {
  : 2,
  :  => .(., .),
});
// manifest.execution → { mode: 'parallel', concurrency: 2 }
// manifest.rows stays in input order; onRow reports each row as it completes.

Every dispatch resolves the current offer and uses the existing principal, input and live-binding gates. Each invocation keeps its own transition, any declared progress channel and effect receipt. There is no second action registry or ledger, and no automatic Session/MCP/WebMCP bridge.

The concurrency limit counts open whenInvoked lifetimes only. If a handler returns after starting a backend job, its slot becomes free even while that job or an effect is still running. This limit does not cap backend jobs or effects that outlive their handlers, and it does not create worker threads. The host still owns any longer-lived work limit and the evidence that establishes its result.

Limits and results

  • Omit concurrency to dispatch up to all N items. An explicit limit must be a positive safe integer; the actual limit is at most N and rides the manifest as execution: { mode: 'parallel', concurrency }.
  • carry is not accepted inside a parallel group. A consumer of another action's output belongs in a sequential plan or a later plan after the required evidence arrives.
  • Declaration/admission errors refuse before work starts. Once dispatch begins, an individual item's refusal or failed invocation does not stop other independent queued items. Every item gets its own manifest row; a ran row points at the existing transition and its outcomes.
  • onRow reports completion order, while final manifest rows remain in input order. A refused or never-reached item has no invocation to await. ran rows land after whenInvoked settles.
  • interrupt stops dispatching queued items and lets running invocations finish. It does not cancel a handler or undo an effect. Overlapping parallel with another parallel or run on the same walk is refused; this does not change legacy run behavior. A stop requested by an offer reader is checked again before invocation; work already inside the invocation is not cancelled. If every item was already dispatched, the interrupt remains armed for the next plan.
  • completed remains an invocation verdict: all invocations performed. Neither the concurrency limit nor the returned manifest waits for every effect to settle or makes an effect verified.

The laws, and why each one

  • An agent may not pre-plan a decision it does not own. A step whose decisionOwner the walking principal does not hold is refused at plan time — not at step three of a half-executed screen. The same step is admitted for the owner: the law is ownership, not fear of batching.
  • A carry needs a declared produces. Chaining is what makes plans worth having — but free references are a dataflow language by the back door. The gate is the declaration: a carried value comes from a declared output, never from whatever a handler happened to return.
  • Batched is not blind. The plan was made from what was true at step zero, and step one can invalidate step two — so every step re-reads its offers when its turn comes. A guard that stopped holding is a refused row carrying the runtime's own teaching sentence.
  • One turn, one walk, many plans. A refused step means the caller replans under the same walk id; walk.record() then holds the route actually taken — which is not the route anybody planned, and comparing the two is where the interesting failures live.

Admission failures throw before execution. Reached invocation refusals and failures are recorded in the manifest, with transition refs for steps that ran, so partial execution is not hidden behind a single success/failure summary.

Breaking the walk

The human has a door, and the reason travels. A batch a person can watch is a batch a person must be able to stop — and a break without a reason is a guard failure wearing a trench coat: the model replans blind, and probably replans the same thing.

const walk = beginWalk(runtime, 'agent');
const running = walk.run(steps, {
  onRow: (row) => renderStatus(row),   // the FE's live loop — and where the
});                                     // stop control belongs, beside it

// The person presses stop:
walk.interrupt({ by: 'user', reason: 'wrong array — I meant NORTHWIND-01' });

const manifest = await running;
manifest.interrupted;
// → { by: 'user', reason: 'wrong array — I meant NORTHWIND-01', beforeStep: 1 }

The semantics that keep it sound:

  • Step-boundary only. The current invocation finishes; its effect may still await authoritative observation. The walk stops future steps, without cancelling that invocation or rolling back a side effect.
  • interrupted is a manifest field, not a row status. Un-run rows stay never-reached, but the manifest says who stopped it and why — because "the plan was wrong" and "the person knows something the plan didn't" must be treated completely differently by whoever replans.
  • The walk survives. A break ends the plan, not the walk: the model reads the reason and replans under the same walk id. The break is just the most human way a plan ends.
  • The intent stands. An interrupt armed before a run is consumed at step zero — pressing stop between plans is still pressing stop.

On this page