footprint · diff

git:20261003.a63ef01 to git:20261004.7c3947c

1 added, 1 removed. Audit A to A.

---
name: footprint
description: Use when building flowchart pipelines with footprintjs — stage functions, decider branches, selectors, subflows, loops, narrative traces, recorders, redaction, contracts, and LLM-ready output. Also use when someone asks how footprint.js works or wants to understand the library.
---
# footprint.js — The Flowchart Pattern for Backend Code
footprint.js structures backend logic as a graph of named functions with transactional state. Every run records a commit log of what each stage wrote. Turn the narrative on (`executor.enableNarrative()`) and the same run also produces a plain-English trace of what happened and why, collected inline while the stages execute.
**Core principle:** All data collection happens during the single DFS traversal pass — never post-process or walk the tree again.
```bash
npm install footprintjs
```
> Every TypeScript block in this file is type-checked (strict) and run against the built package (footprintjs 9.32.0); the `// …` lines under a `console.log` are that run's real output.
---
## Quick Start
```typescript
import { flowChart, FlowChartExecutor, narrative } from 'footprintjs';
interface OrderState {
orderId: string;
amount: number;
paymentStatus: string;
}
const chart = flowChart<OrderState>('ReceiveOrder', (scope) => {
scope.orderId = 'ORD-123';
scope.amount = 49.99;
}, 'receive-order', { description: 'Receive and validate the incoming order' })
.addFunction('ProcessPayment', (scope) => {
const amount = scope.amount;
scope.paymentStatus = amount < 100 ? 'approved' : 'review';
}, 'process-payment', 'Charge customer and record payment status')
.build();
const executor = new FlowChartExecutor(chart);
executor.enableNarrative(); // the narrative is OFF until you ask for it
await executor.run();
// getNarrativeEntries() returns CombinedNarrativeEntry[] — objects, not strings
for (const entry of executor.getNarrativeEntries()) {
console.log(' '.repeat(entry.depth) + entry.text);
}
// Stage 1: The process began: Receive and validate the incoming order.
// Step 1: Write orderId = "ORD-123"
// Step 2: Write amount = 49.99
// Stage 2: Next step: Charge customer and record payment status.
// Step 1: Read amount = 49.99
// Step 2: Write paymentStatus = "approved"
// One-shot alternative: attach a recorder to the BUILT CHART, read the entries back from the recorder
const trace = narrative();
const result = await chart.recorder(trace).run(); // RunResult — there is no executor in this form
console.log(Object.keys(result), trace.getEntries().length);
// [ 'state', 'output', 'executionTree', 'commitLog' ] 6
```
- The narrative is off until `executor.enableNarrative()` (attaching a FlowRecorder switches it on too). Without it, `getNarrativeEntries()` returns `[]`.
- `getNarrativeEntries()` returns **entries** (`type`, `text`, `depth`, `stageName`, …), not strings. `' '.repeat(e.depth) + e.text`, joined with `\n`, is the text to feed an LLM for grounded explanations; `.map((e) => e.text)` gives plain lines.
- `.recorder()` and `.redact()` live on the built **chart** (`chart.recorder(narrative()).run()` returns a `RunResult`: `{ state, output, executionTree, commitLog }`). `FlowChartExecutor` has no `recorder()` — it has `enableNarrative()` and the `attach*Recorder` methods.
---
## FlowChartBuilder API
Always chain from `flowChart<T>()` (recommended) or `flowChart()`.
### Linear Stages
```typescript
import { flowChart } from 'footprintjs';
interface MyState {
valueA: string;
valueB: number;
valueC: boolean;
}
const chart = flowChart<MyState>('StageA', (scope) => { scope.valueA = 'a'; }, 'stage-a', { description: 'Description of A' })
.addFunction('StageB', (scope) => { scope.valueB = 2; }, 'stage-b', 'Description of B')
.addFunction('StageC', (scope) => { scope.valueC = true; }, 'stage-c', 'Description of C')
.build();
```
**Parameters:** the first stage is `flowChart(name, fn, id, { description? })`; every later stage is `.addFunction(name, fn, id, description?)`.
- `name` — human-readable label (used in narrative)
- `fn` — the stage function, `(scope) => void | Promise<void>`
- `id` — stable identifier (used for branching, visualization, loop targets)
- `description` — optional; the stage's narrative line uses it in place of the name ("Next step: Charge customer…"), and it feeds the generated OpenAPI / MCP tool descriptions
### Stage Function Signature (TypedScope)
With `flowChart<T>()`, stage functions receive a `TypedScope<T>` proxy. All reads and writes use typed property access:
```typescript
import { flowChart, FlowChartExecutor } from 'footprintjs';
interface LoanState {
creditTier: string;
amount: number;
customer: { name: string; address: { zip: string } };
tags: string[];
approved?: boolean;
}
const chart = flowChart<LoanState>('Assess', (scope) => {
// Typed writes (tracked — appear in the narrative)
scope.creditTier = 'A';
scope.amount = 50000;
scope.customer = { name: 'Ana', address: { zip: '10001' } };
scope.tags = [];
// Deep write — recorded as an update of `customer`
scope.customer.address.zip = '90210';
// Array methods work (copy-on-write)
scope.tags.push('vip');
// Optional fields are plain writes
scope.approved = true;
// $-prefixed escape hatches for everything that is not state
scope.$debug('checkpoint', { step: 1 }); // stage `logs` + emit event `log.debug.checkpoint`
scope.$metric('latency', 42); // stage `metrics` + emit event `metric.latency`
const { requestId } = scope.$getArgs<{ requestId: string }>(); // frozen input
const { traceId } = scope.$getEnv(); // frozen env
console.log(requestId, traceId);
scope.$break('done early'); // stop the run here (the reason is optional)
}, 'assess').build();
await new FlowChartExecutor(chart).run({
input: { requestId: 'r-1' },
env: { traceId: 'req-123' },
});
// r-1 req-123
```
**Three access tiers:**
- **Typed properties** (`scope.amount = 50000`) — mutable shared state, tracked in the narrative
- **`$getArgs()`** — frozen business input from `run({ input })`, NOT tracked
- **`$getEnv()`** — frozen infrastructure context from `run({ env })`, NOT tracked. Returns `ExecutionEnv { signal?, timeoutMs?, traceId? }` (a fixed type), inherited by subflows
The keys of `run({ input })` are read-only for the whole run: a stage that writes (or deletes) a state key with the same name throws. Give input and state different names (`input: { name }` → `scope.applicantName`):
```typescript
import { flowChart, FlowChartExecutor } from 'footprintjs';
const chart = flowChart<{ requestId: string }>('Clash', (scope) => {
scope.requestId = 'r-2'; // same name as an input key
}, 'clash').build();
await new FlowChartExecutor(chart).run({ input: { requestId: 'r-1' } }).catch((e: Error) => console.log(e.message));
// Cannot write to readonly input key "requestId" — use getArgs() to read input values
```
### Decider Branches with decide() (Single-Choice Conditional)
Use `decide()` for structured decision evidence capture. It records which values led to the decision, in the narrative and on the `onDecision` event.
```typescript
import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
interface RiskState {
creditScore: number;
dti: number;
}
function buildChart(creditScore: number, dti: number) {
return flowChart<RiskState>('Intake', (scope) => {
scope.creditScore = creditScore;
scope.dti = dti;
}, 'intake')
.addDeciderFunction('AssessRisk', (scope) => {
// decide() captures filter evidence automatically
return decide(scope, [
{ when: { creditScore: { gt: 700 }, dti: { lt: 0.43 } }, then: 'low-risk', label: 'Good credit' },
{ when: (s) => s.creditScore > 600, then: 'medium-risk', label: 'Marginal credit' },
], 'high-risk');
}, 'assess-risk', 'Evaluate risk and route accordingly')
.addFunctionBranch('high-risk', 'RejectApplication', () => {}, 'Reject due to high risk')
.addFunctionBranch('medium-risk', 'ManualReview', () => {}, 'Send to manual review')
.addFunctionBranch('low-risk', 'ApproveApplication', () => {}, 'Approve the application')
.setDefault('high-risk') // fallback when the decider returns an id that matches no branch
.end()
.build();
}
for (const [creditScore, dti] of [[750, 0.35], [650, 0.5], [500, 0.9]]) {
const executor = new FlowChartExecutor(buildChart(creditScore, dti));
executor.enableNarrative();
await executor.run();
console.log(executor.getNarrativeEntries().find((e) => e.type === 'condition')?.text);
}
// [Condition]: It evaluated Rule 0 "Good credit": creditScore 750 gt 700 ✓, dti 0.35 lt 0.43 ✓, and chose ApproveApplication.
// [Condition]: It examined "Marginal credit": creditScore=650, and chose ManualReview.
// [Condition]: No rules matched, fell back to default: RejectApplication.
```
`decide(scope, rules, defaultBranch)` returns `{ branch, evidence }`. The engine runs the child whose branch id equals `branch`; the evidence rides the narrative line and the `onDecision` event (`event.evidence`). A decider may also just return a branch-id string.
The `when` of a rule takes two formats:
- **Filter format:** `{ creditScore: { gt: 700 } }` — declarative; the evidence records each key, operator, threshold and actual value
- **Function format:** `(s) => s.creditScore > 600` — arbitrary logic with an optional `label`; the evidence records which keys it read
`.setDefault(id)` names the fallback branch for an id that matches no child. Without it, that run throws.
### Selector Branches with select() (Multi-Choice Fan-Out)
Use `select()` for structured multi-choice evidence capture. Every rule that matches picks its branch (not first-match), and the picked branches run in parallel:
```typescript
import { flowChart, FlowChartExecutor, select } from 'footprintjs';
interface CheckState {
needsCredit: boolean;
needsIdentity: boolean;
}
const chart = flowChart<CheckState>('Intake', (scope) => {
scope.needsCredit = true;
scope.needsIdentity = true;
}, 'intake')
.addSelectorFunction('SelectChecks', (scope) => {
return select(scope, [
{ when: { needsCredit: { eq: true } }, then: 'credit-check', label: 'Credit required' },
{ when: { needsIdentity: { eq: true } }, then: 'identity-check', label: 'Identity required' },
]);
}, 'select-checks')
.addFunctionBranch('credit-check', 'CreditCheck', () => {})
.addFunctionBranch('identity-check', 'IdentityCheck', () => {})
.end()
.build();
const executor = new FlowChartExecutor(chart);
executor.enableNarrative();
await executor.run();
console.log(executor.getNarrativeEntries().find((e) => e.type === 'fork')?.text);
// [Parallel]: Forking into 2 parallel paths: CreditCheck, IdentityCheck.
```
`select()` returns `{ branches, evidence }` (the `onSelected` event carries the evidence). A selector may also return a plain array of branch ids.
### Parallel Execution (Fork)
```typescript
import { flowChart, FlowChartExecutor } from 'footprintjs';
interface CheckState {
a?: number;
b?: number;
total?: number;
}
const chart = flowChart<CheckState>('Start', () => {}, 'start')
.addListOfFunction([
{ id: 'check-a', name: 'CheckA', fn: (scope) => { scope.a = 1; } },
{ id: 'check-b', name: 'CheckB', fn: (scope) => { scope.b = 2; } },
], { failFast: true }) // reject on the first failing child
.addFunction('Join', (scope) => {
// each child's writes sit under runs/<childId>/, not at the top level
scope.total = (scope.$read('runs.check-a.a') as number) + (scope.$read('runs.check-b.b') as number);
}, 'join')
.build();
const executor = new FlowChartExecutor(chart);
await executor.run();
console.log(JSON.stringify(executor.getSnapshot().sharedState));
// {"runs":{"check-a":{"a":1},"check-b":{"b":2}},"total":3}
```
- `failFast: true` rejects the run on the first failing child. Omit it and the other children, and the stages after the fork, still run (the failed child is contained).
- A child's typed writes land under `runs/<childId>/…` in shared state. The stage after the fork reads them with `scope.$read('runs.check-a.a')`; `scope.a` is `undefined` there. The same holds when a selector picks several branches together.
- When the fork is the last stage, `run()` resolves `{ 'check-a': { id, result, isError }, … }` — the children's return values.
- To get a child's output into the parent as plain keys, make the child a subflow with an `outputMapper` (`addSubFlowChart`, see below).
### Subflows (Nested Flowcharts)
```typescript
import { flowChart, FlowChartExecutor, ManifestFlowRecorder } from 'footprintjs';
interface CreditState {
ssn: string;
score?: number;
}
interface MainState {
ssn: string;
creditScore?: number;
route?: string;
}
// A reusable sub-pipeline — it runs in its OWN isolated state
const creditSubflow = flowChart<CreditState>('PullReport', (scope) => { scope.score = scope.ssn.length * 70; }, 'pull-report')
.addFunction('ScoreReport', (scope) => { scope.score = (scope.score ?? 0) + 1; }, 'score-report')
.build();
const chart = flowChart<MainState>('Intake', (scope) => { scope.ssn = '123-45'; }, 'intake')
// Mount as linear continuation
.addSubFlowChartNext('credit-sub', creditSubflow, 'CreditCheck', {
inputMapper: (parentScope: MainState) => ({ ssn: parentScope.ssn }),
outputMapper: (subOut: CreditState) => ({ creditScore: subOut.score }),
})
// Mount as decider branch
.addDeciderFunction('Route', () => 'detailed', 'route')
.addSubFlowChartBranch('detailed', creditSubflow, 'DetailedCheck', {
inputMapper: (parentScope: MainState) => ({ ssn: parentScope.ssn }),
outputMapper: (subOut: CreditState) => ({ route: `detailed:${subOut.score}` }),
})
.addFunctionBranch('simple', 'SimpleCheck', (scope) => { scope.route = 'simple'; })
.end()
.build();
const executor = new FlowChartExecutor(chart);
executor.attachFlowRecorder(new ManifestFlowRecorder()); // getSubflowManifest() reads from it
await executor.run();
console.log(JSON.stringify(executor.getSnapshot().sharedState));
// {"ssn":"123-45","creditScore":421,"route":"detailed:421"}
console.log(JSON.stringify(executor.getSubflowManifest().map((m) => m.subflowId)));
// ["credit-sub","detailed"]
```
`inputMapper` returns the subflow's input: readable as `scope.key` and `$getArgs()`, but read-only — a subflow stage that writes one of those keys throws, as with `run({ input })`. Without an `inputMapper` the subflow starts empty and sees none of the parent's keys. `outputMapper(subOut, parentScope)` returns the keys to merge back into the parent.
### Loops
`.loopTo(id)` is an unconditional back-edge. Exit through a decider branch — `.addFunctionBranch(…).loopTo(id)` makes the loop edge come from that branch — or with `scope.$break()`:
```typescript
import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
interface RetryState {
attempts: number;
paymentResult?: string;
}
const chart = flowChart<RetryState>('Start', (scope) => { scope.attempts = 0; }, 'start')
.addFunction('Charge', (scope) => { scope.attempts = scope.attempts + 1; }, 'charge')
.addDeciderFunction('Check', (scope) => decide(scope, [
{ when: { attempts: { lt: 3 } }, then: 'again', label: 'fewer than 3 attempts' },
], 'done'), 'check')
.addFunctionBranch('again', 'TryAgain', () => {})
.loopTo('charge') // back-edge FROM the 'again' branch to the stage whose id is 'charge'
.addFunctionBranch('done', 'Done', (scope) => { scope.paymentResult = 'paid'; })
.end()
.build();
const executor = new FlowChartExecutor(chart);
await executor.run();
console.log(JSON.stringify(executor.getSnapshot().sharedState));
// {"attempts":3,"paymentResult":"paid"}
// A stage followed directly by .loopTo() has no exit of its own: returning does NOT leave the loop
const spin = (exit: boolean) => flowChart<RetryState>('Start', (scope) => { scope.attempts = 0; }, 'start')
.addFunction('Retry', (scope) => {
scope.attempts = scope.attempts + 1;
if (exit && scope.attempts >= 3) scope.$break('enough attempts'); // $break() ends the run
}, 'retry')
.loopTo('retry')
.build();
const withBreak = new FlowChartExecutor(spin(true));
await withBreak.run();
console.log(JSON.stringify(withBreak.getSnapshot().sharedState));
// {"attempts":3}
try {
await new FlowChartExecutor(spin(false)).run({ maxIterations: 6 }); // the guard; default 1000 per node
} catch (error) {
console.log((error as Error).message);
}
// Maximum loop iterations (6) exceeded for node 'retry'. Set maxIterations to increase the limit.
```
### Configuration
`builder.contract({ input, output, mapper })` declares the chart's I/O contract — see Contracts & OpenAPI below.
### Output
```typescript
import { flowChart } from 'footprintjs';
const builder = flowChart('Receive', () => {}, 'receive')
.addFunction('Assess', () => {}, 'assess');
const chart = builder.build(); // for the executor — also has toMermaid() / toOpenAPI() / toMCPTool()
const spec = builder.toSpec(); // JSON-safe structure for visualization — on the BUILDER, not the built chart
const mermaid = builder.toMermaid(); // Mermaid diagram string (chart.toMermaid() returns the same)
console.log(Object.keys(spec), chart.toMermaid() === mermaid);
// [ 'name', 'id', 'type', 'next' ] true
console.log(mermaid);
// flowchart TD
// receive["Receive"]
// receive --> assess
// assess["Assess"]
```
---
## FlowChartExecutor API
```typescript
import { flowChart, FlowChartExecutor } from 'footprintjs';
interface AppState {
applicantName: string;
income: number;
riskTier?: string;
}
const chart = flowChart<AppState>('ReceiveApplication', (scope) => {
const args = scope.$getArgs<{ name: string; annualIncome: number }>(); // input keys differ from state keys
scope.applicantName = args.name;
scope.income = args.annualIncome;
}, 'receive-application')
.addFunction('AssessRisk', (scope) => { scope.riskTier = scope.income < 50000 ? 'high' : 'low'; }, 'assess-risk')
.build();
const executor = new FlowChartExecutor(chart);
executor.enableNarrative(); // before run()
// Run with input and optional execution environment
await executor.run({
input: { name: 'Bob', annualIncome: 42000 },
env: { traceId: 'req-123', timeoutMs: 5000 },
});
// Narrative text: render the indent from entry.depth (see Quick Start)
const entries = executor.getNarrativeEntries(); // CombinedNarrativeEntry[]
// Structured entries (for programmatic access)
console.log(JSON.stringify(entries[0]));
// {"type":"stage","text":"Stage 1: The process began with ReceiveApplication.","depth":0,"stageName":"ReceiveApplication","stageId":"receive-application","runtimeStageId":"receive-application#0"}
console.log(JSON.stringify(entries[1]));
// {"type":"step","text":"Step 1: Write applicantName = \"Bob\"","depth":1,"stageName":"ReceiveApplication","stageId":"receive-application","runtimeStageId":"receive-application#0","stepNumber":1,"key":"applicantName","rawValue":"Bob"}
// Full memory snapshot
const snapshot = executor.getSnapshot();
console.log(JSON.stringify(Object.keys(snapshot)));
// ["sharedState","executionTree","initialState","commitLog","commitValues","writeProvenance","runId"]
console.log(JSON.stringify(snapshot.sharedState));
// {"applicantName":"Bob","income":42000,"riskTier":"high"}
// Flow-only narrative: drop the read/write steps
console.log(JSON.stringify(entries.filter((e) => e.type !== 'step').map((e) => e.text)));
// ["Stage 1: The process began with ReceiveApplication.","Stage 2: Next, it moved on to AssessRisk."]
```
- `getSnapshot()` also carries `subflowResults` (only when a subflow ran — a plain object keyed by subflow id and by mount `runtimeStageId`, not a Map), `recorders` (one row per attached recorder that implements `toSnapshot()`) and `observerStats` (only when a recorder was attached with `{ delivery: 'deferred' }`). `sharedState` is the LIVE state: treat it as read-only.
- `executor.run()` validates `input` against a declared contract but does not apply the contract's `mapper` — that is `chart.run()` → `result.output`.
---
## Recorder System — Collect During Traversal
**The core innovation.** Three observer channels fire during the single DFS pass (and a build-time `StructureRecorder` on the builder):
- **Scope recorders** — data operations (reads, writes, commits)
- **Flow recorders** — control flow (stages, decisions, forks, loops, subflows)
- **Emit recorders** — your own `scope.$emit(name, payload)` events, plus what `$debug` / `$metric` emit
`executor.attachCombinedRecorder(r)` attaches ONE object that implements hooks from several channels; the library routes it by method shape.
### Scope Recorders (data operations)
Fire during typed property access (reads/writes). Attach via `executor.attachScopeRecorder()` before `run()`:
```typescript
import { flowChart, FlowChartExecutor, MetricRecorder, DebugRecorder } from 'footprintjs';
const chart = flowChart<{ amount: number; status?: string }>('Receive', (scope) => { scope.amount = 49.99; }, 'receive')
.addFunction('Charge', (scope) => { scope.status = scope.amount < 100 ? 'approved' : 'review'; }, 'charge')
.build();
const executor = new FlowChartExecutor(chart);
// Built-in recorders (the factories metrics() / debug() in 'footprintjs/recorders' build the same ones)
const metrics = new MetricRecorder();
const debug = new DebugRecorder({ verbosity: 'verbose' }); // an options object; or 'minimal'
// Attach to executor (one-liner, no custom scopeFactory needed)
executor.attachScopeRecorder(metrics);
executor.attachScopeRecorder(debug);
await executor.run();
// After execution
const { totalReads, totalWrites, totalCommits, stageMetrics } = metrics.getMetrics();
console.log(totalReads, totalWrites, totalCommits, [...stageMetrics.keys()]);
// 1 2 2 [ 'Receive', 'Charge' ]
console.log(JSON.stringify(debug.getEntries().slice(1, 3).map(({ type, stageName, data }) => ({ type, stageName, data }))));
// [{"type":"write","stageName":"Receive","data":{"key":"amount","value":49.99,"operation":"set","pipelineId":""}},{"type":"stageEnd","stageName":"Receive","data":{"pipelineId":""}}]
```
### FlowRecorders (control flow events)
Attached to the executor, fire after each stage/decision/fork. Attach before `run()`; read the result off the recorder:
```typescript
import { flowChart, FlowChartExecutor, decide, MilestoneNarrativeFlowRecorder } from 'footprintjs';
const chart = flowChart<{ count: number }>('Start', (scope) => { scope.count = 0; }, 'start')
.addFunction('Work', (scope) => { scope.count = scope.count + 1; }, 'work')
.addDeciderFunction('Check', (scope) => decide(scope, [
{ when: { count: { lt: 8 } }, then: 'again', label: 'under 8' },
], 'done'), 'check')
.addFunctionBranch('again', 'Again', () => {})
.loopTo('work')
.addFunctionBranch('done', 'Done', () => {})
.end()
.build();
const executor = new FlowChartExecutor(chart);
const milestones = new MilestoneNarrativeFlowRecorder(3); // loops: every 3rd pass
executor.attachFlowRecorder(milestones);
await executor.run();
console.log(JSON.stringify(milestones.getSentences().filter((line) => line.includes('pass'))));
// ["On pass 1 through Work.","On pass 3 through Work.","On pass 6 through Work."]
```
9 built-in strategies, all `FlowRecorder`s. Eight render the control-flow narrative and you read them back with `.getSentences()` — `NarrativeFlowRecorder` and seven subclasses that differ in how they treat loop iterations; `ManifestFlowRecorder` collects subflows instead:
| Class | What it does |
|---|---|
| `NarrativeFlowRecorder` | every control-flow event as a sentence — the base the next seven extend |
| `SilentNarrativeFlowRecorder` | no per-iteration sentences — one summary ("Looped 7 times through Work.") |
| `AdaptiveNarrativeFlowRecorder(threshold, sampleRate)` | full detail up to `threshold`, then every `sampleRate`-th pass |
| `WindowedNarrativeFlowRecorder(head, tail)` | the first `head` and last `tail` passes; the middle becomes one line |
| `RLENarrativeFlowRecorder` | run-length encodes repeated loops ("Looped through Work 7 times (passes 1–7).") |
| `MilestoneNarrativeFlowRecorder(interval)` | every `interval`-th pass (the first is always kept) |
| `ProgressiveNarrativeFlowRecorder(base)` | exponentially sparser: passes 1, 2, 4, 8, … |
| `SeparateNarrativeFlowRecorder` | loop sentences leave the main list; read them with `.getLoopSentences()` |
| `ManifestFlowRecorder` | collects the subflows that were entered — `.getManifest()`, `.getSpec(id)`; `executor.getSubflowManifest()` reads it |
### Custom FlowRecorder
```typescript
import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
import type { FlowRecorder, FlowStageEvent, FlowDecisionEvent } from 'footprintjs';
const chart = flowChart<{ score: number }>('Seed', (scope) => { scope.score = 750; }, 'seed')
.addDeciderFunction('Route', (scope) => decide(scope, [
{ when: { score: { gt: 700 } }, then: 'top', label: 'High score' },
], 'rest'), 'route')
.addFunctionBranch('top', 'Top', () => {})
.addFunctionBranch('rest', 'Rest', () => {})
.end()
.build();
const myRecorder: FlowRecorder = {
id: 'my-recorder',
onStageExecuted(event: FlowStageEvent) {
console.log(`Executed: ${event.stageName}`);
},
onDecision(event: FlowDecisionEvent) {
console.log(`Decision at ${event.decider}: chose ${event.chosen}`);
// event.evidence available when using decide()
if (event.evidence) {
console.log(`Evidence: ${JSON.stringify(event.evidence.rules.map((rule) => rule.label))}`);
}
},
clear() {
// Reset state before each run
},
};
const executor = new FlowChartExecutor(chart);
executor.attachFlowRecorder(myRecorder);
await executor.run();
// Executed: Seed
// Decision at Route: chose Top
// Evidence: ["High score"]
// Executed: Route
// Executed: Top
```
`event.decider` and `event.chosen` are the decider's and the chosen branch's **names** (`Route`, `Top`); the branch ids are in `event.evidence` (`evidence.chosen`).
### CombinedNarrativeRecorder (what powers the narrative)
It implements the scope, flow AND emit hooks in one object. It buffers a stage's reads and writes and flushes them when that stage's flow event arrives (`onStageExecuted` for a linear stage; `onDecision` / `onSelected` / `onFork` / `onSubflowEntry` for the other kinds), so the entries come out merged, in order, in a single pass.
**You don't create it.** `executor.enableNarrative()` installs it, and the `narrative()` factory (from `footprintjs` or `footprintjs/recorders`) returns one for `chart.recorder(narrative())`. It is not exported as a class.
---
## Redaction (PII Protection)
```typescript
import { flowChart, FlowChartExecutor, DebugRecorder } from 'footprintjs';
interface FormState {
ssn: string;
password: string;
applicant: { name: string; ssn: string; address: { zip: string; city: string } };
}
const chart = flowChart<FormState>('Collect', (scope) => {
scope.ssn = '123-45-6789';
scope.password = 'hunter2';
scope.applicant = { name: 'Bob', ssn: '999-99-9999', address: { zip: '90210', city: 'LA' } };
}, 'collect').build();
const executor = new FlowChartExecutor(chart);
executor.enableNarrative();
const debug = new DebugRecorder({ verbosity: 'verbose' });
executor.attachScopeRecorder(debug);
executor.setRedactionPolicy({ // before run()
keys: ['ssn', 'creditCardNumber'], // exact key names
patterns: [/password/i, /^secret.*/], // regex patterns
fields: { applicant: ['ssn', 'address.zip'] }, // nested field paths
});
await executor.run();
// Narrative shows the placeholder
console.log(JSON.stringify(executor.getNarrativeEntries().filter((e) => e.type === 'step').map((e) => e.text)));
// ["Step 1: Write ssn = \"[REDACTED]\"","Step 2: Write password = \"[REDACTED]\"","Step 3: Write applicant = {name, ssn, address}"]
// Recorders receive scrubbed values, nested fields included
console.log(JSON.stringify(debug.getEntries().filter((e) => e.type === 'write').map((e) => e.data)[2]));
// {"key":"applicant","value":{"name":"Bob","ssn":"[REDACTED]","address":{"zip":"[REDACTED]","city":"LA"}},"operation":"set","pipelineId":""}
// Compliance report: names only — never contains actual values
console.log(JSON.stringify(executor.getRedactionReport()));
// {"redactedKeys":["ssn","password"],"fieldRedactions":{"applicant":["ssn","address.zip"]},"patterns":["password","^secret.*"]}
// A scrubbed copy of the state — the default getSnapshot() is the LIVE state, with the real values
console.log(JSON.stringify(executor.getSnapshot({ redact: true }).sharedState));
// {"ssn":"REDACTED","password":"REDACTED","applicant":{"name":"Bob","ssn":"REDACTED","address":{"zip":"REDACTED","city":"LA"}}}
```
- The policy covers everything retained or served (commit log, narrative, recorder events, `getSnapshot({ redact: true })`); it never touches the live state the run computes on.
+ The policy protects named state values in the commit log, mirror, tracked reads/writes and corresponding recorder/narrative payloads. Root run input/output events are masked before dispatch using their top-level keys and explicit nested `fields` paths; scalars/root arrays pass through unchanged, and fork child-ID envelopes need explicit paths (for example `fields: { branchA: ['result.secret'] }`). Execution inputs, returned results, live state and checkpoints stay real. This is not a content scanner or whole-snapshot safe export: diagnostic bags/text, pause payloads and custom recorder data/metadata require separate review. See `docs/guides/scope.md` → "The one law" for the bounded contract. Capture once at the runtime boundary; recorders and readers must not reconstruct or re-scrub a run afterward.
---
## Contracts & OpenAPI
```typescript
import { flowChart } from 'footprintjs';
import { z } from 'zod';
interface LoanState {
decision?: string;
reason?: string;
}
const chart = flowChart<LoanState>('ProcessLoan', (scope) => {
const { applicantName, income } = scope.$getArgs<{ applicantName: string; income: number }>();
scope.reason = `${applicantName} earns ${income}`;
}, 'process-loan', { description: 'Receive the loan application' })
.addFunction('Assess', (scope) => { scope.decision = 'approved'; }, 'assess', 'Decide on the application')
.contract({
input: z.object({
applicantName: z.string(),
income: z.number(),
}),
output: z.object({
decision: z.enum(['approved', 'rejected']),
reason: z.string(),
}),
mapper: (state) => ({
decision: state.decision,
reason: state.reason,
}),
})
.build();
const openApiSpec = chart.toOpenAPI({
title: 'Loan Underwriting API',
version: '1.0.0',
});
console.log(JSON.stringify(Object.keys((openApiSpec as { paths: object }).paths)));
// ["/process-loan"]
console.log(JSON.stringify(chart.toMCPTool().description)); // built from the stage descriptions
// "FlowChart: ProcessLoan\nSteps:\n1. ProcessLoan — Receive the loan application\n2. Assess — Decide on the application"
// chart.run() validates input and returns the contract's mapped output
const result = await chart.run({ input: { applicantName: 'Bob', income: 42000 } });
console.log(JSON.stringify(result.output));
// {"decision":"approved","reason":"Bob earns 42000"}
// Invalid input throws InputValidationError — from chart.run() and from executor.run() alike
await chart.run({ input: { applicantName: 5 } }).catch((e: Error) => console.log(e.name));
// InputValidationError
```
`input` / `output` accept a Zod schema (anything with `.safeParse` / `.parse`) or a JSON Schema object; either is validated.
---
## Reading a finished run (`footprintjs/trace`)
The run's commit log answers questions after the fact — these read the log the run already recorded, they never re-walk the tree:
```typescript
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { stateAt, timeTravel, HONESTY_CODES } from 'footprintjs/trace';
const chart = flowChart<{ a: number; b?: number }>('One', (scope) => { scope.a = 1; }, 'one')
.addFunction('Two', (scope) => { scope.b = scope.a + 1; }, 'two')
.build();
const executor = new FlowChartExecutor(chart);
await executor.run();
const snapshot = executor.getSnapshot();
const folded = stateAt(snapshot, 0); // fold the commit log through commit 0
console.log(folded.state, folded.basis);
// { a: 1 } initial+log
console.log(HONESTY_CODES[folded.basis]); // every honesty code → the one sentence that says what it means
// The fold started from the fold base that travelled with the log (initialState) and replayed the commit log onto it, so nothing seeded before the run is missing; rows it could not read are listed apart, in skipped.
const cursor = timeTravel(snapshot); // a reader's cursor over the finished log (never a second live run)
console.log(cursor.stops.map((stop) => `${stop.kind} ${stop.runtimeStageId}`));
// [ 'start ', 'commit one#0', 'commit two#1', 'end ' ]
```
`causalChain` and `sliceForKey` answer "what caused this value?" (backward slices); the READMEs in the repo are `src/lib/slice/README.md` and `src/lib/time-travel/README.md`. A reader that cannot answer says why with a code from `HONESTY_CODES` instead of guessing.
---
## Event Ordering (Critical for Understanding)
When a stage executes, ScopeRecorder events fire in this order:
```
1. onStageStart — stage begins
2. onRead — each typed property read (DURING execution, before the commit)
3. onWrite — each typed property write (DURING execution, before the commit)
4. onStageEnd — the stage function returned
5. onCommit — the transaction buffer flushes to shared memory
```
Then the FlowRecorder events fire (after the commit). What fires depends on the stage kind:
```
linear stage onStageExecuted → onNext
decider onDecision → onStageExecuted
selector onSelected → onStageExecuted → onFork
fork parent onStageExecuted → onFork → onStageExecuted (stageType 'fork')
subflow mount onNext → onSubflowEntry → onStageExecuted (stageType 'subflow-mount')
→ the subflow's own stages → onSubflowExit
```
`CombinedNarrativeRecorder` buffers a stage's reads and writes and flushes them when that stage's flow event arrives (`onStageExecuted` for a linear stage, `onDecision` / `onSelected` / `onFork` / `onSubflowEntry` for the others). **This ordering is what makes inline collection work.** Scope events buffer during execution, flow events trigger the flush.
---
## Anti-Patterns to Avoid
1. **Never post-process the tree.** Don't walk the spec after execution to collect data. Use recorders (or the `footprintjs/trace` queries over the recorded log).
2. **Don't use `$getValue()`/`$setValue()` for keys you know.** Use typed property access (`scope.amount = 50000`); those two are escape hatches for dynamic keys. A plain `scope.getValue(...)` does not exist on a TypedScope.
3. **Don't give a state key the name of a `$` method** (`scope.$break = 1` throws "conflicts with a reserved TypedScope method"). The reserved names are `SCOPE_METHOD_NAMES` in `footprintjs/advanced` (`$getArgs`, `$getEnv`, `$break`, `$debug`, `$metric`, `$emit`, `$log`, `$read`, …); avoid `$`-prefixed state keys altogether.
4. **Don't write a state key that is also an input key.** `run({ input: { requestId } })` makes `requestId` read-only for the run (a subflow's `inputMapper` keys are read-only inside that subflow) — name the state key differently.
5. **`CombinedNarrativeBuilder` is gone** (removed in v1.0). The narrative comes from `CombinedNarrativeRecorder`, which you get from `executor.enableNarrative()` or the `narrative()` factory — never construct it yourself.
6. **Don't extract a shared base class** for scope and flow recorders. The built-ins compose a store (`KeyedStore` / `SequenceStore`, exported from `footprintjs/trace`) as a field instead of inheriting.
7. **Don't call `$getArgs()` for tracked data.** `$getArgs()` returns frozen readonly input. Use typed scope properties for state that should appear in the narrative.
8. **Don't put infrastructure data in `$getArgs()`.** Use `$getEnv()` via `run({ env })` for signals, timeouts, and trace IDs.
9. **Don't hand-roll a recorder to get the narrative.** `executor.enableNarrative()` (or `chart.recorder(narrative())`) is the whole setup — and it is off until you call it.
10. **Don't end a loop with `return`.** `.loopTo(id)` is unconditional: exit through a decider branch or `scope.$break()`.
11. **Don't read a parallel child's write as `scope.key` after the fork.** It sits under `runs/<childId>/` (`scope.$read('runs.<childId>.key')`), or use a subflow child with an `outputMapper`.
---
## Package doors and library layout (for contributors)
`package.json` `exports` has six doors — import from the one that owns the symbol:
| Import | What it is for |
|---|---|
| `footprintjs` | The main door: `flowChart`, `FlowChartExecutor`, `decide` / `select`, `narrative`, the built-in recorder classes, `interrupt`, and the public types |
| `footprintjs/recorders` | Recorder factories — `narrative()`, `metrics()`, `debug()`, `manifest()`, `adaptive()`, `milestone()`, `windowed()` — and `CompositeRecorder` |
| `footprintjs/trace` | Reading a finished run: commit-log queries (`causalChain`, `sliceForKey`, `stateAt`, `timeTravel`, `commitStops`), the recorder stores (`KeyedStore`, `SequenceStore`), `HONESTY_CODES` |
| `footprintjs/advanced` | Engine internals: `SharedMemory`, `StageContext`, `FlowchartTraverser`, scope providers, `SCOPE_METHOD_NAMES` |
| `footprintjs/detach` | Fire-and-forget child charts and their drivers |
| `footprintjs/zod` | Opt-in zod bridge (`defineScopeFromZod`, …) — the core never imports zod |
This file keeps no copy of the module map or the dependency rules, so it cannot drift from them: the module map is in `CLAUDE.md` ("Module map", shipped with the package) and the layer table — a file imports only its own layer or below — is `scripts/layering.config.cjs`, enforced by `npm run check:layering`.