Build

Runbooks inside a fan-out

Six engine behaviours a decision chart meets the first time it runs one branch per subject. A branch is seeded with only item and index, a decider reports itself path-prefixed by name, and four of the six fail without an error — the run finishes, the rowset looks complete, and the answer is wrong.

A runbook that judges one thing is a chart. A runbook that judges every thing in an estate is a chart inside addParallelForEach — one isolated branch per subject, each with its own memory, its own commit log and its own decider pass. That is the shape runbookAsTool was built for, and it is the shape that costs the first author a day.

Six engine behaviours meet you there. Exactly one announces itself — writing to a read-only input key throws. The other five are quiet, and four of those five are quiet about a wrong answer rather than a thin record: the run completes, the rowset has rows in it, the counters agree with each other, and nothing anywhere signals a problem. Those four are why this page exists.

#The behaviourHow it tells you
1A branch is seeded with only item and indexSilent — the branch judges against a value nothing recorded
2A decider reports itself prefixed by NAMESilent — verdict_meanings comes back undefined
3Declared branch descriptions never reach a generated branchSilent — meanings are missing, not wrong
4A failed branch keeps its slot with undefinedSilent — the rowset just gets shorter
5decide() filter rules read the scope flat, by nameSilent — every subject lands on the default
6Input keys are read-only for the whole runThrows, mid-stage

The worked example is examples/features/68-runbook-as-tool.ts — an inventory tool, a bounded fan-out, one decider per subject, and the rows collected back. Every fragment below is lifted from it. Run it with npx tsx examples/features/68-runbook-as-tool.ts.

1. A branch is seeded with only item and index

What happens. addParallelForEach mounts each branch as a subflow whose input mapper returns exactly two keys:

// footprintjs — src/lib/engine/handlers/ParallelForEachHandler.ts:224
inputMapper: () => ({ item, index }),

Nothing else crosses. A subflow's initial scope is only what its input mapper returned (SubflowInputMapper.getInitialScopeValues — "Always isolated"), and the branch runs on a fresh, isolated runtime. The parent's state is not visible, not copied, and not reachable.

The other channel that does cross a subflow boundary — ExecutionEnv — cannot carry a business value either. It is a deliberately closed type with three infrastructure fields (signal, timeoutMs, traceId), sealed so a parent and a child chart cannot couple through it.

Why it is that way. The comment on that line answers it: provenance beats a hidden closure. The item is not merely passed to the branch factory — it is written into the branch's own scope, which means it is in the branch's own commit log.

The wrong shape. Let the branch factory close over the run's parameters, so each branch judges against a value that only exists in a closure:

const now = Date.now();                       // captured by the closure
const branch = (item) => chartFor(item, now); // judged against it, invisibly

The chart runs. The verdicts are correct today. But the branch's commit log records only the facts, never the clock or the threshold they were compared with, so a replay six weeks later is read under whatever the constant says then — and nothing in the record contradicts it.

The right shape. Put the run's parameters inside the work item, and the constraint turns into the guarantee:

// the fan-out's items carry the run's parameters, one copy per branch
scope.work = subjects.map((subject) => ({
  subject,
  now_epoch: now,
  stale_after_days: STALE_AFTER_DAYS,
}));

Now every branch's commit log contains the exact threshold and the exact clock that branch judged against. A replay cannot be misread under today's constant, because the branch recorded its own.

maxBranches is required, not optional. An unbounded fan-out driven by whatever an estate happens to return is a resource attack with no default to forget. Cap the item list yourself as well, so the engine's truncation is never what bounds a run — and report the counts rather than trimming quietly.

2. Name the decider — never its id

What happens. verdicts: { decider } on a runbook names the decider whose decide() evidence the bridge harvests. Inside a generated branch, that name arrives path-prefixed:

per-subject~0/Protection posture

Three facts produce that string, and each is worth knowing on its own:

  • A generated branch's subflow path segment is <stageId>~<index>per-subject~0 (branchSegment.ts, and ~ is reserved from 9.14.0 so a hand-authored id can never collide with one).
  • When a chart is mounted under a path segment, the prefixer rewrites the node's name as well as its id — FlowchartTraverser.prefixNodeTree sets clone.name to <prefix>/<name>, not just clone.id.
  • The decision event — a FlowDecisionEvent — reports node.name, not node.id (DeciderHandlerFlowRecorderDispatcher, which builds { decider: deciderName, … }).

The bridge therefore matches on the last /-segment — the same last-delimiter reading every upstream path parser uses, which is exactly what keeps the generated-branch marker opaque to all of them:

// agentfootprint — src/core/runbook/verdicts.ts:180-184
const lastSegment = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
// …
onDecision(event: FlowDecisionEvent): void {
  if (!identity.spellings.has(lastSegment(event.decider))) return;

The wrong shape. addDeciderFunction takes the display name first and the stage id third, so the id is the value nearest to hand:

.addDeciderFunction('Protection posture', decider, 'posture', description)
// …
verdicts: { decider: 'posture' },   // ← the ID. Matches nothing.

Nothing throws. The recorder simply never fires its body, verdict_meanings comes back undefined, and the answer ships without the sentences that explain its own verdicts.

The right shape. Use the stage NAME, and pin it in one place so the chart and the declaration cannot drift:

export const TRIAGE_DECIDER = 'Protection posture';
// in the chart:      .addDeciderFunction(TRIAGE_DECIDER, decider, 'posture', description)
// in the runbook:    verdicts: { decider: TRIAGE_DECIDER }

Nesting deeper costs nothing. A fan-out inside a fan-out reports per-cluster~0/per-subject~1/Protection posture, and the last segment is still the name.

3. Branch descriptions are invisible; the decide() labels are the meanings

What happens. verdict_meanings has two sources. The first is a static walk of the chart's own structure, reading each declared branch's description:

// agentfootprint — src/core/runbook/verdicts.ts:133-157 (resolveDecider)
walk(chart.root as NodeView);
for (const subflow of Object.values(chart.subflows ?? {})) {
  walk(subflow.root as NodeView);
}

That walk runs on the chart the procedure factory returned, before the run starts. A generated branch's chart does not exist yet: branch(item, index) is called during traversal, once per item. So for a decider inside a fan-out, the static half finds nothing — by construction, not by oversight.

The second source is the harvest: the labels observed on the decision evidence as decide() fires, collected during the traversal. It covers every rule the evidence lists, matched or not — a rule that was evaluated has spoken its label, whichever branch won — plus the default branch's own label when the call declared one. That is the half that works inside a generated branch, and for a fan-out runbook it is the only half.

The wrong shape. Put the meaning where a reader would naturally put it — on the branch:

.addFunctionBranch('unprotected', 'Unprotected — stale backup', land('unprotected'))
// …and leave the rule bare:
{ when: { age_days: { gt: 7 } }, then: 'unprotected' }

The description is good documentation and reaches nobody. verdict_meanings comes back missing that row.

The right shape. The meaning belongs on the rule, so a rule change and its meaning change on the same day:

const STALE_AFTER_DAYS = 7;// The DEFAULT branch is chosen by NO rule — it fires exactly when every rule// failed — so no rule label can name it, and here the decider lives inside a// GENERATED fan-out branch, where build-time structure cannot see the branch// descriptions either. Declared as `{ branch, label }`, the default's meaning// rides the same decide() evidence as the rule labels, and `verdict_meanings`// can finally explain every verdict the rowset shows.const POSTURE_DEFAULT = {  branch: 'protected',  label: `no rule fired — last backup within the ${STALE_AFTER_DAYS}-day threshold`,} as const;const POSTURE_RULES: DecideRule<Record<string, unknown>>[] = [  {    when: { age_known: { eq: false } },    then: 'declined',    label: 'the age signal is unreadable — no classification',  },  {    when: { age_days: { gt: STALE_AFTER_DAYS } },    then: 'unprotected',    label: `last backup older than the ${STALE_AFTER_DAYS}-day threshold`,  },];

Keep the branch descriptions too — they are what a person reading the chart sees, and they are what the static walk reads for a decider that is statically declared. They are simply not the mechanism here.

The default branch needs a label of its own. decide() picks the default when no rule matched, so no rules[].label describes it — and inside a generated branch there is no declared description either. It was the one verdict the rowset could show and verdict_meanings could not explain, which this page used to teach as a gap to work around. It is now declared where the default itself is declared (footprintjs ≥ 9.16.1, agentfootprint ≥ 9.82.0):

// before — the default is a branch id and nothing else
decide(scope, POSTURE_RULES, 'protected');

// after — the branch id, plus what falling back to it MEANS
decide(scope, POSTURE_RULES, {
  branch: 'protected',
  label: `no rule fired — last backup within the ${STALE_AFTER_DAYS}-day threshold`,
});

The label rides DecisionEvidence.defaultLabel and is harvested exactly like a rule label — on every decision, including the runs where a rule won, so a published meanings map does not gain and lose a key with the day's data. The bare string still works: declare nothing and verdict_meanings is simply silent about that branch, because the bridge never invents a sentence from a branch id. The old workarounds — giving the clean case a rule of its own so it has a label, or carrying the sentence in the row — are no longer the answer.

Never write an empty filter as a catch-all. {} deliberately never matches: a rule that asserts nothing must not win a branch on vacuous truth, because routing on it would fabricate decision evidence. Catch-alls are defaultBranch.

4. A failed branch keeps its slot

What happens. Branch results are assembled in ITEMS order, never completion order — and a branch that failed still occupies its position:

// footprintjs — src/lib/engine/handlers/ParallelForEachHandler.ts:249-254
if (entry === undefined || entry.isError) {
  // Best-effort mode: a failed branch keeps its SLOT (order is items
  // order) with an undefined value. failFast mode never reaches here —
  // the error already rejected the whole stage.
  ordered.push(undefined);
  continue;
}

So the array written to into always has exactly one entry per branch. Some of them may be undefined, and that undefined is the only record that a branch died. In failFast: true mode the question does not arise — the error rejects the whole stage. In the default best-effort mode, it is the whole question.

Why it is that way. Position is identity. Slot i is item i, so a result can always be joined back to the input that produced it. Compacting the array on the way out would make that join silently wrong for every element after the first failure.

The wrong shape. The natural line, and the one that erases the failure:

scope.verdicts = results.map((r) => r?.row).filter(Boolean);   // ← the fact is gone

Nothing throws. The rowset is simply shorter — and a shorter rowset is indistinguishable from a smaller estate. The tool answers confidently about an estate it only partly reached, and the reader has no way to tell.

The right shape. Filtering the blanks out of the rowset is fine — a blank row is not a verdict. What may not vanish is the count. Keep the two numbers, let them disagree, and when they do, say so in the chart's reserved coverage key, which the bridge folds into the answer's ledger:

.addFunction(  'Collect',  (scope: Record<string, unknown>) => {    // A branch that FAILED keeps its slot with an `undefined` value, so    // this array's length is always the branch count. Dropping the    // blanks is fine; losing the COUNT is not — the two numbers are    // kept and allowed to disagree.    const slots = (scope.subject_results as ({ row?: unknown } | undefined)[]) ?? [];    const rows = slots.map((slot) => slot?.row).filter((row) => row !== undefined);    const subjectsTotal = (scope.subjects as Subject[]).length;    scope.verdicts = rows;    if (rows.length < subjectsTotal) {      // RESERVED key — the chart's own limits, folded into the answer's      // ledger by the bridge. A gap nobody reported is a confident      // partial answer, which is the one thing this envelope refuses.      scope.coverage = {        not_checked: [          {            what: `${              subjectsTotal - rows.length            } of ${subjectsTotal} subject(s), which reached no verdict`,            why: 'their branch failed, and a failed branch keeps its slot rather than its answer',          },        ],      };    }    scope.report = {      stale_after_days: STALE_AFTER_DAYS,      subjects_total: subjectsTotal,      subjects_assessed: rows.length,    };  },  'collect',)

The two counts disagreeing is not a bug to hide; it is the finding. A gap nobody reported is a confident partial answer, and that is the one thing an evidence envelope exists to refuse.

5. decide() reads facts flat, by name

What happens. A filter rule's when is a WhereFilter, and the comment above the type is the whole law:

// footprintjs — src/lib/decide/types.ts:31-35
// -- WhereFilter (flat keys only, no nested v1) ------------------------------

export type WhereFilter<T extends object = Record<string, unknown>> = {
  [K in keyof T]?: FilterOps<T[K]>;
};

Each key is read off the scope by that exact name — getValueFn binds $getValue/getValue, and the evaluator does const actual = getValueFn(key). There is no path syntax. 'facts.paused' is not a path; it is a key that does not exist.

The wrong shape. Write the derived facts as one nested bag, because that is how the function that derives them returns:

scope.facts = postureFacts(subject, now);              // one nested object
// …
{ when: { paused: { eq: true } }, then: 'paused-while-failing' }

getValueFn('paused') returns undefined. Every operator on undefined evaluates false. The rule does not match. The next rule does not match. Every subject falls through to defaultBranch, the run completes, the rowset is full, rows_total equals rows_shown, and the whole estate reports as healthy.

This is the worst failure in this list, and it is worth saying why: the other five either throw or leave something visibly missing. This one produces a complete, well-formed, internally consistent answer that is uniformly wrong. Nothing throws, nothing warns, no counter disagrees with another counter, and the verdicts are plausible — "everything is healthy" usually is.

The tell. Read the decision evidence. A filter rule records the key, the operator, the threshold and the actual value for every condition it evaluated — so a rule that should have matched shows its actualSummary as undefined, which reads as "there was nothing there", not "it did not qualify". (This is also the reason to prefer filter rules over function rules: a function rule records only which keys it read, so age_days > 7 leaves no trace of the 7.)

The right shape. Derive the facts in one place and flatten them onto the branch scope, one key per fact:

const subjectChart = (subject: Subject, index: number) => {  const land = (verdict: string) => (s: Record<string, unknown>) => {    s.row = { subject: subject.subject, verdict, age: subject.lastBackupDays };  };  return flowChart<Record<string, unknown>>(    `Read ${subject.subject}`,    (s) => {      s.age_known = subject.lastBackupDays !== null;      s.age_days = subject.lastBackupDays ?? -1;    },    `subject-${index}`,  )    .addDeciderFunction(      'Protection posture',      (s: Record<string, unknown>) => decide(s, POSTURE_RULES, POSTURE_DEFAULT),      'posture',      'Three declared outcomes, first match wins; anything unmatched is protected.',    )    .addFunctionBranch('declined', 'Declined — signal unreadable', land('declined'))    .addFunctionBranch('unprotected', 'Unprotected — stale backup', land('unprotected'))    .addFunctionBranch('protected', 'Protected — inside threshold', land('protected'))    .end()    .build();};

6. Input keys are read-only for the whole run

What happens. Any write to a key that is an own property of the run's input throws:

// footprintjs — src/lib/scope/protection/readonlyInput.ts:17-27
export function assertNotReadonly(readOnlyValues: unknown, key: string, operation: 'write' | 'delete'): void {
  if (readOnlyValues && typeof readOnlyValues === 'object' &&
      Object.prototype.hasOwnProperty.call(readOnlyValues, key)) {
    if (operation === 'delete') {
      throw new Error(`Cannot delete readonly input key "${key}" — input values are immutable`);
    }
    throw new Error(`Cannot write to readonly input key "${key}" — use getArgs() to read input values`);
  }
}

ScopeFacade calls it on every set, every object write and every delete. For a runbook, the input is the tool's own argumentsrunbookAsTool runs the chart as executor.run({ input: args, env }). So every property of your tool's argument schema is a name your chart may not write for the life of the run.

Why it is that way. What was handed in and what the chart decided are two different facts, and a record in which the second has overwritten the first cannot answer "what was actually asked for?". Freezing the input is what makes $getArgs() trustworthy at any point in the run.

The wrong shape. One name for both:

// args declare: { include?: 'flagged' | 'all', now_epoch?: number }
scope.include = args?.include === 'all' ? 'all' : 'flagged';  // ← throws

This is the one behaviour on this page that announces itself. It throws mid-stage — but note that a stage's writes still commit before the error propagates, so the record shows a stage that half-ran.

The right shape. Two names, because they are two things — what was handed in, and what the chart recorded itself using:

interface TriageState {
  /** `include` is an INPUT key, so a state key of that name is a refused
   *  write. Read the input with `$getArgs()`; record the resolution here. */
  include_mode: 'flagged' | 'all';
  /** Same reason `now_epoch` is not reused: the clock handed in and the
   *  clock this run judged against are two facts, not one. */
  run_epoch: number;
}

The report your chart writes can still call it include — that is the reader's vocabulary, and it has never been the engine's.

Inside a generated branch, the input is { item, index }. A subflow's mapped input becomes its readonly context as well as its seed state (SubflowExecutor sets readOnlyContext: mappedInput and seeds the store from the same object). So in a fan-out branch, item and index are readable as state and locked as input: a branch stage may read scope.item freely, and may never write item or index. Give the branch's own working keys different names.

Where to look next

  • Runbook as tool — the envelope this page's chart plugs into: coverage, provenance, rule_version, the recorded walk
  • Chart as tool — the older, plainer wrapper, when you do not want the evidence spine
  • ToolsdefineTool, ctx.tools, and the composedOf drift gate that proves your procedure's ingredients exist
  • Semantic tool results — the caveats that travel with numbers, one layer below a verdict

On this page