CLAUDE.md · git:20261104.4230fe9 · 2026-11-04 · sha256 ba475d40afcf38a5
CLAUDE.md git:20261104.4230fe9A
Immutable. This exact content never changes and is served at /api/v1/blob/ba475d40afcf38a5.
<!-- analyzed-at: 22953d9 @ 2026-07-02 | model: fable-5 -->
# footprintjs — feature-work map
Self-explaining flowchart engine: a fluent builder emits a static chart; one DFS pass executes it while observer channels + a commit log capture everything ("collect during traversal, never post-process"). This file maps seams and blast radius; for API description read the code/README — **trust the code** where any doc disagrees.
## Module map
Entry points: `footprintjs` (engine API), `/recorders`, `/trace` (engine recorders, stores and structure walkers), `/advanced` (engine internals), `/detach`, `/zod`, `/sinks` (10.1, NODE ONLY — `fileSink`, the one door that imports `node:fs`). The separate `foottrace` package owns records: root for readers/types/IDs, `/write` for writer primitives, `/paths` for path rules and nested diagnostic writes. No record declaration is re-exported by FootPrint 10. See `docs/design/2026-10-trace-extraction.md` for the extraction design and migration map.
| src/lib/ | one job |
|---|---|
| capture/ | engine retention policies, bounded payload summarization, hook invocation and dev-mode helpers. Value-kind classification and record freezing now belong to foottrace. |
| memory/ | engine composition and policy: StageContext composes `RecordFrame` from `foottrace/write`; ExecutionRuntime owns run state and observer wiring; run addresses, redaction verdicts, frame types and diagnostics remain here. DiagnosticCollector uses `foottrace/paths`; replay/write rules are not copied here. |
| schema/ | duck-typed validation (`.safeParse/.parse` ⇒ 'parseable') |
| builder/ | fluent DSL → FlowChartSpec → StageNode graph; build-time StructureRecorder channel. NOT standalone: imports runner's makeRunnable (FlowChartBuilder.ts:18) |
| builder/retryLoop.ts | `addRetryLoop` (10.1, P3b) composes ordinary attempt/reconcile/decide/wait/done/stop nodes over the shared `retryRules` table. One operation key survives passes and pause/resume; native physical calls stay unchanged and `retryLoop` metadata links logical passes/decisions. The helper declares conservative total-pass budget metadata, preserved through subflow prefixing; see src/lib/builder/README.md. |
| budget/ | the static time budget (10.1, P5): `timeBudget(chart)` walks the BUILT graph once (explicit stack, each chain memoised per start, capped at `MAX_WALK` visits, saturating sums) — stage = attempts × timeoutMs + waits, sequence = sum, decider/selector/fork = own stage + slowest child, `parallelForEach` = one branch; a FUNCTION backoff is never called (counted at `RetryPolicy.maxBackoffMs`, which `attempt/policy.ts · cappedWaitMs` makes the runner enforce; else unbounded `'backoff'`); loops NEVER iterated (unbounded unless `assume.loops` or an explicit helper's declared pass bound). Read by the builder (`.budget(ms)` refuses at `build()` when `knownMs > budgetMs`) and the runner (`chart.timeBudget()`, the run-start warning) — its own module because a runner → builder edge closes the builder ⇄ runner cycle; see src/lib/budget/README.md |
| scope/ | ScopeFacade per stage (tracked get/set, $emit, frozen args/env); built-in ScopeRecorders in scope/recorders/ |
| reactive/ | TypedScope deep Proxy over the facade — four write laws + the honest boundary (what `find`/`for…of` hands back is raw and WARNED ABOUT in dev mode, not intercepted; a proxy held past its stage IS refused — `ScopeFacade` · `assertLive`); handles.ts = the registry of every proxy the factories built → the value behind it, asked wherever an app value enters the record (the set traps since 9.24.0 — no JSON round-trip any more; fan-out `resolveItems`, subflow in/out mappers, `$setValue`/`$update`/`$batchArray` since 9.23.3, after a fan-out over object items seeded the handle and lost every child); see src/lib/reactive/README.md |
| decide/ | decide()/select() evidence capture |
| filter/ | `evaluator.ts · evaluateFilter` — the ONE filter-rule law, shared by decide() and the attempt rule table; its own leaf module since P2 (10.1), because in decide/ it made attempt/ reach scope/ and `scope.$call` closed an attempt ⇄ decide ⇄ scope module cycle |
| attempt/ | the ONE retry loop and its record (10.1, docs/design/2026-10-time-and-failure-evidence.md): `runner.ts · runAttempts` runs a policy and makes one frozen `AttemptRecord` per try (timing, outcome, category, the retry-or-stop decision as `DecisionEvidence`) + an `AttemptSummary`; `decision.ts · retryRules` = the one rule table as `decide()` rules (first match, labels ARE the reasons) + `decideAttempt`, decide()'s evaluation without a scope (filter rows from `filter/evaluator`; a property test pins equal evidence); `category.ts · errorCategory` = the one classifier (seven fixed values); `errors.ts`, `anySignal.ts`; `backoff.ts · exponentialBackoff` / `statedWaitFirst` build the function `RetryPolicy.backoffMs` takes (exponential ceiling, `'none'`/`'equal'`/`'full'` jitter from an injectable `random`, a stated wait first — the cap stays `maxBackoffMs`, so the budget still counts it). L5 beside decide/, but `types.ts` (with `decide/types.ts`) is an L0 leaf because `StageSnapshot` (L4) names it. `RetryPolicy` is declared here, re-exported by engine/types.ts |
| timers/ | the ONE long-timer law, a neutral L0 leaf folder (like `ids/`): `longTimeout.ts · setLongTimeout(fn, ms, { unref? })` — `Infinity` arms no timer; a delay past 2^31 − 1 ms is waited in chunks, never 1 ms; anything not a number > 0 is due at once. Every caller-supplied wait in `src/` arms it (the runner's caps, waits and deadline, the observer queue's `drain` at L1, `flushAllDetached`, `flushRecord`, the file sink, the indexed reader, the `attempts()` recorder, capture history, the `setTimeout` detach driver). `test/architecture/timer-law.test.ts` refuses, outside it: a raw `setTimeout` / `setInterval` whose delay is not a literal a host timer holds (or a constant on its named allow-list), any indirect reach of one (alias, `globalThis['setTimeout']`, `.call`, destructuring, `Reflect.apply`), a `timers` / `timers/promises` import, `scheduler.wait`, and `AbortSignal.timeout` past 2^31 − 1 or non-literal; see src/lib/timers/README.md |
| recorder/ | engine stores (KeyedStore/SequenceStore/BoundaryStateStore), CombinedRecorder routing, and built-in Topology/InOut/ControlDep/Quality recorders. `CommitRangeIndex` is owned and exported by foottrace. `attemptSeries.ts` = the ONE predicate every built-in recorder uses for attempt rows (P1c, 10.1: a series that RETRIED, GAVE UP or TIMED OUT; a first-time success adds no row anywhere, so no-retry runs stay byte-identical) — it lives here, not in attempt/, because a recorder → attempt runtime edge closes the attempt⇄decide⇄recorder⇄scope module cycle. Quality/Debug see `onAttempt` only through `attachCombinedRecorder` |
| pause/ | PauseSignal / FlowchartCheckpoint types + `interrupt(scope, payload)` (throws InterruptSignal; converted to PauseSignal at the StageRunner boundary, `pausedBy:'interrupt'`) |
| engine/ | FlowchartTraverser + handlers/; narrative/ = the whole FlowRecorder channel (dispatcher + 9 strategies), not just text; `lib/ids/branchSegment.ts` (moved to the `ids/` leaf in 9.37.0) = the ONE owner of the generated-segment grammar (`<stageId>~<index>`, marker `~` RESERVED); `executeStage` (:720) = the ONE funnel every stage fn passes through — and therefore the ONE owner of the declarative `retry` loop; `handlers/ResumeEntry.ts` = the ONE owner of a resume's ONE-SHOT re-entry (M2, 9.28.0: start + one hop per subflow on the pause path, each level's dispatcher continuation read from the chart, waiting sibling pauses queued at their fan-out) |
| runner/ | FlowChartExecutor wiring — composes `attach.ts` (RunObservers) · `resume.ts` · `checkpoint.ts` · `snapshot.ts` · `options.ts` (F9, 9.41.0); (runtime, recorders, pause/resume, snapshot, DeferredObserverTier). RunContext.ts is a fluent run builder, NOT the threaded per-run context |
| observer-queue/ | pure deferred-delivery pipeline (engine imports IT, never reverse) |
| sinks/ | (10.1, P6) `fileSink` / `readRecordFile` — the crash-safe record on a Node host: one JSON line per entry, each written asynchronously at its own offset, only the path given (O_NOFOLLOW, 0o600; hard links / foreign / shared-writable refused), every resource bounded. The ONLY `node:` importer in src/ (layering.test pins it and that only `src/sinks.ts` reaches it). The run's side is `runner/recordSink.ts · RunRecord` |
| contract/, detach/ | I/O schema normalization (OpenAPI generation belongs to runner/RunnableChart); fire-and-forget child charts (drivers passed explicitly) |
**The fence:** engine files import only their own layer or below, using `scripts/layering.config.cjs` as the single table for lint, the checker and architecture tests. Named engine exceptions remain explicit and live-checked. The record is no longer a local L0–L3 subset: it lives in the standalone foottrace repository, whose closed source graph cannot import this engine, Node APIs or other packages. All engine access to the record uses named imports from `foottrace`, `foottrace/write` or `foottrace/paths`, never package internals. Export tests reject any FootPrint door handing out a declaration owned by foottrace. See the extraction plan for the E3/E6 published-door gate.
## Core state & flow
Component lifecycle wiring: `runner/NativeRunLifecycle.ts` owns weak component-port
registration; retain the lifecycle strongly. A component slot enables `componentSetup`.
`recorder/ComponentRunControl.ts` selects inline component recorders; `FinalizationBook.ts`
joins cleanup after observation. Cleanup bounds its own time; failFast siblings can
finish after parent rejection, and a pause remains usable after counted cleanup failures.
- `SharedMemory` (`foottrace/write`) is the copy-on-write live heap. StageContext decides the address with the engine's `runAddress` rule and composes a `RecordFrame`. Record encoding, staging, commit and replay laws are implemented only in foottrace; the engine supplies policy and commit identity.
- `StageContext` (`memory/StageContext.ts`) owns per-stage run context. `stageWrite` decides redaction and identity inheritance, passes the resulting scrub bytes to `RecordFrame.write`, then records the engine's write marks. Commit delegates to the record frame, delivers engine hooks, and releases stage-local tracking. `runId` is readonly; the engine decides the record address once.
- `CommitBundle` (`foottrace`) / `EventLog` (`foottrace/write`) retain the existing bundle/index and full/delta encoding law. Neither is a FootPrint export.
- `TraversalContext` (engine/narrative/types.ts:172) — THE per-event context (runId, runtimeStageId, parentRuntimeStageId, subflowPath, loopIteration, and `correlation` when the env gave one — P4, `attempt/correlation.ts · resolveCorrelation`, one frozen object per leg, also on scope/emit events and `getSnapshot().correlation`); stamped on every FlowRecorder event.
- `runtimeStageId` = `[subflowPath/]stageId#executionIndex`; counter shared by reference into subflow traversers → globally unique; NOT reset on resume.
- Subflows get a fresh isolated ExecutionRuntime (SubflowExecutor.ts:119); inputMapper seeds, outputMapper merges back (arrays CONCAT unless `arrayMerge: Replace`).
**Retry (9.15.0; on the attempt runner since 10.1):** `StageNode.retry` is a POLICY on a stage, not a kind — no new node type, no `computeNodeType` edit. `FlowchartTraverser.executeStage` is the one call site, so linear/streaming/pausable/decider/selector/fork-child/subflow-inner all get it from one implementation; the LOOP is `attempt/runner.ts · runAttempts`, handed the engine's half (one attempt = `StageRunner · run`, `between` = `discardStaged`, `serve` = `servedStageError`, the frame's `recordAttempt` / `endAttempts`, `onAttempt` / `onStageRetry`). `executeStage` is deliberately NOT async: a policy stage runs on the runner's promise (one await per attempt, the 9.15 shape) and a no-policy (or `attempts: 1`) stage on `runStageOnce`, the pre-10.1 async frame — so microtask order, hence parallel siblings' settle order, never moved. Every attempt is an `AttemptRecord` on `StageContext · recordAttempt` → `StageSnapshot.attempts` (+ `attemptSummary`), NOT cleared by `discardStaged`, NEVER on the commit bundle (Q1: logs byte-identical); a declined stop (`retryOn → false`) is now a record (`reason: 'declined'`). A failed non-final attempt calls `StageContext.discardStaged()` (`RecordFrame · discard`, which drops the buffer, the first-touch base and readKeys, plus _stageWrites/_stageReads + _untrackedSources); the final attempt is today's law untouched. PauseSignal and abort are NEVER retried. One runtimeStageId + one CommitBundle per stage regardless of attempts; retries do not consume `maxIterations`.
**In-stage calls (10.1, P2):** `scope.$call(target, fn, policy?)` (four-file checklist: `ScopeFacade · call`, `ReactiveTarget.call`, `ScopeMethods.$call`, `METHOD_ROUTES.$call`, `SCOPE_METHOD_NAMES`) runs on the same runner via `attempt/call.ts · runCall` (kind `'call'`, `target` on every record, no policy = `{ attempts: 1 }` — still ONE record). The facade is the host: signal = its stage attempt's, deadline = `runner.ts · attemptEndsAt` (start + cap; the cap's reason is branded a time limit, `markTimeLimit`, so a cut call is `timeout`), series = `callSeries` (`<rsid>@<n>.<k>`, k from `StageContext · nextCallOrdinal`). Records land through `StageContext · reportCallAttempt` (bounded, `MAX_CALL_RECORDS` = 1000, the rest counted in `attemptsDropped`) → the reporter `executeStage` installs on every stage (`useAttemptReporter` → `onAttempt` with the stage's traversalContext/correlation). A call lives within its stage attempt: a record arriving after that attempt ended or committed is refused (`lateWrites`, channel `'attempt'`, `call:<target>`) and the call stops; a call attempt CUT by the stage attempt's own abort is admitted. `AttemptContext.observed(report)` (`attempt/observed.ts`) records kind `'observed'` (named fields only, read once; decision = the rule table under the default policy). `attempt/name.ts` = the one name rule (1–128, no Cc/Cf/Cs/Zl/Zp). Standalone `attempt()` = `attempt/standalone.ts` (records to `onAttempt` only, its own `armDeadline`).
**Time budget (10.1, P1b):** `armRun` (`runner/deadline.ts`) composes `RunOptions.signal`, `env.signal` and the deadline (`deadline` / `timeoutMs` / `env.deadline`, earliest wins) into ONE signal and stamps `env.deadline` + `env.signal` (only when a deadline exists or the caller gave an env — an env-less signal-only run stays byte-identical). At the deadline the signal aborts with a BRANDED reason (`attempt/deadline.ts`): an attempt it ends is `timeout`, never `aborted`. `executeStage` runs a stage on the runner when its policy retries, declares `timeoutMs`, or the run has a deadline (so a lone stage records `attempts`, `of: 1`). The runner hands `StageRunner` an `AttemptContext` (`scope.$attempt()`; StageRunner races ITS signal) and calls `onAbandon(n)` synchronously when the attempt ends without an answer → `StageContext · endAttempt` fences that attempt's facade: later writes/emits/reads are refused, writes/emits recorded in `StageSnapshot.lateWrites`. Under a deadline `backoffMs` is asked before the discard (rule 6 needs the wait).
**Declared tags (9.21.0):** `StageNode.tags?: readonly string[]` is a NAME on a stage, declared at build time — POLICY-like (the retry precedent): no node type, no `computeNodeType` edit, no type-union site, and the one prefixer (`engine/graph/prefixNodeTree.ts`, 9.37.0) spreads `{ ...node }` so it rides subflow prefixing untouched. ONE landing site `FlowChartBuilder.ts · applyTags` (twin of `applyRetryPolicy`) serves the `.tag(...names)` cursor modifier and every `options.tags` site; refuses empty/non-string/`~`/duplicate/double-declare at build. Stamp: `FlowchartTraverser.executeNodeStep` sets `context.tags = node.tags` beside `runtimeStageId` (unconditionally — a re-used context must not keep a prior node's names); `memory/recordCommit.ts · recordCommit` (C1, 9.44.3; was `StageContext · bundleFor`) stamps `tags` beside `untrackedSources` on BOTH commit paths and `StageContext · commit` RELEASES them, so a double-commit path (fork child fan-out repeat, mount exit) records it once — absent when empty (`CommitBundle.tags?`; pinned byte-identical to 9.20.0 by test/lib/engine/scenario/declared-tags-byte-identity.test.ts). NOT inherited by createNext/createChild; the one synthetic node that could lose it is `FlowChartExecutor.resume`'s stand-in for the paused stage — built by `runner/resume.ts · standInFor` from the paused node ITSELF (fn swapped), so it keeps the tags on both re-entries; it runs ONCE — a later visit of the paused id runs the chart's real node, which carries its own tags. The spec carries `tags` as-is (Map advertises the vocabulary; `RuntimeStructureManager.stageNodeToStructure` copies it for run-time-resolved nodes). Reader: foottrace's `tagStops` = `filterStops(commitStops(...), any-of over log[stop.commitIdx].tags, meta = the array)`, exported from `foottrace`. Never a runtime `$tag()` — a value would ride past every redaction point. Design: docs/design/2026-09-declared-tags.md.
**Event order (code-verified; older docs had 3/4 swapped):** onStageStart → onRead/onWrite (live, PRE-commit) → **onStageEnd (StageRunner.ts:81) →** [a retry policy: `onAttempt` per attempt, then `onStageRetry` for each one retried — both DURING the stage] **→ onCommit (`StageContext · commit`, after `RecordFrame · commit`)** → FlowRecorder onDecision/onFork/onSelected/onSubflowEntry → onStageExecuted (uniform, with stageType). Error path: commit STILL happens (FlowchartTraverser.ts:1088) before onError+rethrow — a failing stage's writes land. For ANY thrown value (null, null-prototype, hostile Proxy) catch blocks describe it with `errors/errorInfo · thrownText` (never throws), onError fires, and run() rejects with the ORIGINAL value; a failed commit (e.g. an uncloneable write) also fires onError (`handlers/commitStage.ts`, 9.40.0). `interrupt()` rides the ERROR path's shape, not the pausable one: the stage function threw, so onStageEnd does NOT fire (the `addPausableFunction` pause DOES fire it — there the function returned normally).
## Extension points
- **New stage kind**: boolean flag on `StageNode` (engine/graph/StageNode.ts:39 — kinds are flags, not an enum) + builder method on FlowChartBuilder (pattern: addPausableFunction :1264; fire structure events endpoint-before-edge) + phase in the hard-coded chain `executeNodeStep` (FlowchartTraverser.ts:801 — no handler registry; new handler class takes HandlerDeps, engine/types.ts:344, instantiated in traverser ctor :402-413). Must also edit 5 type-union sites: engine/types.ts:477, builder/types.ts:50, builder/types.ts:104, engine/types.ts:427, and computeNodeType's flag→type mapping (RuntimeStructureManager.ts:19-21 — the only one the compiler won't flag; miss it and the new kind silently serializes as 'stage'). Zero-engine alternative: pure sugar over addFunction (pattern: addDetachAndForget :1343). WORKED EXAMPLE (9.14.0 addParallelForEach): flag `isDynamicParallel` + ParallelForEachHandler + Phase 0b (BEFORE Phase 1 VALIDATE — the node has no fn/children/decider by design); it added NO new node type — the spec carries a BOOLEAN and computeNodeType returns 'fork' (precedent: isPausable/isLazy/isStreaming are flags on a 'stage'), which is the cheaper choice whenever the new kind is a variant of an existing shape.
- **Decider/selector**: addDeciderFunction :1433 → DeciderList (:53); addSelectorFunction :1478 → SelectorFnList (:428); runtime match on `child.branchId ?? child.id` (DeciderHandler.ts:133). Evidence via decide()/select() (decide/decide.ts:169/218). `decide()`'s 3rd param is `DefaultBranch = string | { branch, label? }` (9.16.0) — the default is chosen by NO rule, so `rules[].label` can never name it; the object form puts that label on `DecisionEvidence.defaultLabel`, spread into BOTH evidence exits (matched AND fallthrough) so a harvest doesn't gain/lose the meaning with the data. Bare string ⇒ key absent, byte-identical to pre-9.16. `select()` has no default and is deliberately untouched.
- **Recorder**: channels = ScopeRecorder (scope/types.ts:137), FlowRecorder (engine/narrative/types.ts:425), EmitRecorder (recorder/EmitRecorder.ts:121 — attachEmitRecorder just delegates to the scope channel), CombinedRecorder (recorder/CombinedRecorder.ts:98, routed by method-shape), StructureRecorder (builder/structure/StructureRecorder.ts:347, build-time; its hooks are NOT in `HOOKS` (build-time, not a run channel) — the `StructureRecorderDispatcher` fires each by name, so a new structure hook = interface + `fire*` + the builder's `_fire*` helper; 9.24.0 `onStageTagged` is the worked example: names that land AFTER `onStageAdded` through the `.tag()` door go out as their own event, `StructureStageAddedEvent.tags` carries the ones that landed before). Attach via executor.attach*Recorder — idempotent by id. **One registry since 9.36.0 (F6): `recorder/hooks.ts · HOOKS`** — every hook declared once (channels, payload, executor-made); `hooksOn(channel)` drives channel routing and the deferred tier's taps, `CompositeRecorder`'s fan-out is generated from `HOOK_NAMES`, and one `fire` isolates every recorder error (scope → `onError`, flow → dev warning; R10: a throwing `onResume` no longer rejects `resume()`). A hook on an interface but not in `HOOKS` does not compile (`test/architecture/hook-registry-compile.test.ts`). Detection accepts class-prototype methods (blocks only Object.prototype — CombinedRecorder.ts:266). New built-in = compose a store as a field (no base classes) + factory in src/recorders.ts. `toSnapshot()` bundles are REBUILT field by field into `RecorderSnapshot` (`recorder/snapshot.ts · copyBundle`, called by `runner/snapshot.ts · collectRecorderSnapshots` — so a recorder can't spoof its own `id`); anything not on that copy list is dropped silently — the free-form slot is `meta` (facts about the bundle: which projection, what it omits — what a consumer BRANCHES on; prose goes in `description`). A new bundle field means editing the copy list AND the three `toSnapshot?()` signature sites (scope/types.ts:159, engine/narrative/types.ts:462, recorder/CombinedRecorder.ts:121).
- **Scope $-method (4-file checklist)**: ScopeFacade capability + ReactiveTarget (reactive/types.ts:20); ScopeMethods type (:74); METHOD_ROUTES (createTypedScope.ts:46); SCOPE_METHOD_NAMES (reactive/types.ts:262) — miss #4 and the name becomes a state key.
- **Contract**: builder.contract() (:1056) → makeRunnable toOpenAPI/toMCPTool (runner/RunnableChart.ts:83/153). BYO schema is duck-typed (schema/detect.ts:24).
- **Scope provider**: ProviderResolver via registerScopeResolver (exported from /advanced), or a custom scopeFactory. Runtime ownership is explicit in `scope/runtime.ts`: `registerScopeRuntime(scope, { target, handlesAssignments, setBreak? })` maps the user object to its engine port without probing its fields. ScopeFacade, TypedScope, attachScopeMethods and canonical Zod factories auto-register. Other executor/decide/select scopes must register; no duck-typed fallback. One facade per stage owns recording, redaction, argument ownership and commit sealing; adapters use its path-aware accessors instead of writing directly to StageContext.
- **Detach driver**: `detach/types.ts · DetachDriver`; no driver registry — passed explicitly. The three batching adapters delegate to `detach/drivers/batch.ts · createBatchSchedule`, the one owner of queued work, dispatch, settlement and handle-registry cleanup. A scheduling failure returns a failed handle, never a stuck queue or a parent exception. `validate()` is explicit caller preflight, not an automatic entry-point hook; do not use it instead of containing failures in the driver. No implicit fallback or retry.
- **Observability dial** (pattern): capture/ primitive + FlowChartExecutorOptions option + snapshot discriminant — FOUR dials now: readTracking/writeTracking/commitValues/writeProvenance (#P1: `'reads-prefix'` stamps `TraceEntry.readKeys` = keys tracked-read before each write; consumed by causalChain `edgeAttribution: 'per-write'` and sliceForKey's default). Propagation is the same 6-site pattern for all four.
- **Second worked example (10.1 `onAttempt`)** — the same chain: `FlowAttemptEvent` + `FlowRecorder.onAttempt?` + `IControlFlowNarrative.onAttempt` → `FlowRecorderDispatcher · onAttempt` → `NullControlFlowNarrativeGenerator` no-op → `HOOKS.onAttempt` (placed before `onStageRetry`; `hooks.test.ts` pins the list and the 25/28 counts) → `NarrativeFlowRecorder · onAttempt` (`formatting/sentences.ts · attemptSentence`) → `CombinedNarrativeRecorder` buffers it (`bufferOp`) as `CombinedNarrativeEntry.type: 'attempt'`, rendered by `NarrativeFormatter.renderAttempt` (string / null / undefined) → barrels. Two narrative laws it added: the attempt line REPLACES the retry line (`onStageRetry` still fires, the built-ins no longer narrate it; `NarrativeFormatter.renderRetry` and `CombinedNarrativeRecorder · onStageRetry` were removed in 10.1), and a FAILED stage's buffered attempt lines are flushed beneath its `[Error]` line (`flushAttempts` — its reads/writes still are not, so a no-policy failure narrates byte-identically).
- **Worked example of a new FLOW hook (9.15.0 `onStageRetry`)**: `FlowStageRetryEvent` + `FlowRecorder.onStageRetry?` + `IControlFlowNarrative.onStageRetry` (engine/narrative/types.ts) → `FlowRecorderDispatcher` impl → `NullControlFlowNarrativeGenerator` no-op (interface is REQUIRED there) → **its `HOOKS` entry (recorder/hooks.ts)** → `NarrativeFlowRecorder` sentence (the 8 strategies extend it, so they inherit free) → `CombinedNarrativeRecorder` + `CombinedNarrativeEntry.type` + `NarrativeFormatter.renderRetry` (removed in 10.1 — the attempt line replaced it) → barrels (the composite fan-out is generated). Retry is the only flow event that fires DURING a stage, so the narrative BUFFERS it via `bufferOp` (the `onEmit` precedent) — pushing it straight to the store would place the line before its own stage header.
- **Closed seams that look open**: ExecutionEnv (fixed type); decide() operators (`filter/evaluator.ts · OPERATOR_HANDLERS`, module-private); METHOD_ROUTES; the executeNodeStep phase chain; emit dispatcher (rides scope channel).
## Change-impact map
- **Record sink** (10.1, P6) → foottrace's `RecordSink` port; the engine composes. `runner/recordSink.ts · RunRecord` (one per run; a same-executor resume keeps it) wraps the app's adapter: it SERVES before any byte leaves (the `opened` base through `RedactionRule · retainState`, `opened`/`closed` meta through `retainBoundary`; commits are the log's scrubbed bundles; attempt notes carry the served `AttemptRecord`), counts entries/losses (`getSnapshot().sink`), and under `onFailure: 'fail'` aborts the run's signal (`armRun`'s `stop`). `ExecutionRuntime`'s 6th ctor arg (`RuntimeRecord`) opens the root log at `{ logRunId: runId, drillPath: [] }`; `recordFor(mount)` hands a mount `[...parent drillPath, mountRuntimeStageId]` (`SubflowExecutor` closes it ok/failed/paused). `StageContext · noteAttempt` notes each kept attempt as it ENDS. `FlowChartExecutor · settleRecorded` closes the root log and flushes — a run WITHOUT a sink never enters it (no extra await, byte-identical). Same-executor resume continues the same root log (`resume` note, second `closed`; `RunRecord · beginLeg` resets the `'fail'` stop per leg); fresh-executor resume opens a new one (`resumedFrom`). KNOWN DIVERGENCE: with a sink a mount in a same-executor resumed leg is addressed under the ROOT log's `logRunId`; a plain log binds the current leg's runId (pinned in record-sink-mounts.test.ts; unify in a later packet). `fileSink` writes asynchronously (positional writes handed to the I/O pool at accept time, no O_APPEND; a NUL hole marks a torn write) with four bounds: maxEntryBytes, maxQueuedBytes (drop-and-count), maxBytes (then full), flushTimeoutMs; one sink per file per process.
- **Nested diagnostic writes** → `DiagnosticCollector` uses the public `foottrace/paths` nested writers. They refuse denied selector segments, create own properties without inherited setters, and keep diagnostic concatenation/shallow-merge semantics separate from record union-merge semantics. Path rules have one implementation in foottrace; the engine does not copy them.
- **Trace verbs** (`set|merge|append|delete`, `TraceEntry.verb`) → the record law lives only in foottrace. Its writer, replay, keyed fold, provenance and unknown-verb refusal share that implementation. Use the public record doors; do not add a verb switch to the engine. The canonical record contract and record-only differential controls moved with the package; FootPrint retains the engine byte-identity and admission witnesses.
- **Write address** → ONE constant, `runAddress.ts · RUN_NAMESPACE` (an L4 leaf, C4), read by `runAddress` / `useAddressOf` and by `RedactionRule · verdictOfRead` / `retainState`; the frame's `RecordFrame` holds the address (C3). `SharedMemory` and the nested writers take the address as data and refuse a non-array; `TransactionBuffer` takes it too (C2, 9.45.0). `test/architecture/write-address.test.ts` refuses the namespace below L4, as a literal or a property — no exceptions since C4.
- **StageContext.commit / dials** → ONE owner since 9.35.0 (F5): `memory/runPolicy.ts` — a frozen `RunPolicy` (the four dials + the `RedactionRule` + the mirror flag) built ONCE by `FlowChartExecutor · createTraverser` (run AND resume), handed to `ExecutionRuntime`, held BY REFERENCE by every `StageContext` (`inheritRun` in createNext/createChild) and by its `RecordFrame` as its encoding (`useEncoding`, through `StageContext · adoptPolicy`, the one place a frame's policy changes) and passed by `SubflowExecutor · executeSubflow` to the nested runtime BEFORE the seed. A fifth dial = the policy type + its consumer. The old per-dial `use*`/`get*` and `enableRedactedMirror` were removed in 9.36.0 (no compat shims — owner ruling). `commit()`'s FIRST act (9.23.0) is `materialiseWrites` — the `writeTracking` retained form is taken there, once per key, from `_pendingWrites` (bare references + the write-time verdict); anything that reads `_stageWrites` goes through `retainedWrites()` (a non-consuming fold, so a mid-run snapshot is honest), and `discardStaged` drops the pending map with the rest.
- **runtimeStageId format** (`#`/`/` delimiters) → foottrace owns build/parse/read and the brands `RuntimeStageId`/`ExecutionIndex`/`CommitIdx`, all on its root door. The engine's `ids/reservedIds.ts` owns `refuseReservedId`, and `ids/branchSegment.ts` owns the generated `~` grammar. Every engine reader uses the public codec; no local parsing copy. R5: every ID door (22 at build, plus the run-time dynamic-StageNode door) refuses `#` and `/`; stage IDs may contain `~`. `TraversalContext` has one constructor (`engine/traversalContext.ts`), with depth read from the runtime ID on every stamp.
- **Subflow id prefixing** → ONE prefixer since 9.37.0: `engine/graph/prefixNodeTree.ts`, used by the builder AND the traverser (eager, lazy and `parallelForEach` mounts — lazy/branch roots were unprefixed before 9.37.0, so branch ids collided); change it + stageMap key composition + resume drilling; the builder's eager mounts all go through `FlowChartBuilder · _mountSubflow` (F10). test/lib/engine/branch-segment-prefixer-equivalence.test.ts stays as a guard.
- **Checkpoint shape** (format 2 since 9.44.0, LEAN: only what resume reads + the pause's record — no `executionTree`/`subflowResults`; size independent of run length, pinned by test/lib/pause/checkpoint-size.test.ts; 9.39–9.43 refuse format 2, so upgrade readers before writers) → `runner/checkpoint.ts · buildPauseCheckpoint` ↔ `pause/record.ts · decodeCheckpoint` (one upcaster drops v0/v1 fields) ↔ `runner/resume.ts · planResume` (validation via `pause/record.ts · decodeCheckpoint`) ↔ `ResumeEntry.plan` (the path's mounts + captures + dispatcher continuations, the `pendingPauses` queue; refused up front when unwalkable, ambiguous, or a pending record is not a sibling) ↔ `SubflowExecutor · executeSubflow` resume hop (seed from the capture in place of the mapper's values — the mapper still runs, for the args — FIRST entry only) ↔ `ChildrenExecutor · raisePauses` (fills `pendingPauses`). 9.28.0 adds ONE optional field, `pendingPauses` (present only when parallel siblings wait); the redaction law adds `redactionMarks` (names only, present only when the run marked something); every stored checkpoint enters through `pause/record.ts · decodeCheckpoint` (`checkpointVersion: 1`, one upcaster from unversioned v0 which drops the legacy `continuationStageId`, one validator, one message set; 9.39.0). Pinned: a 9.27.0 checkpoint resumes, test/lib/pause/resume-real-chart-9.27.0-checkpoints.test.ts.
- **Committed-state writers** → copy-on-write staging and commit belong to foottrace. The engine composes `RecordFrame` rather than editing its internals or the shared heap. Engine tests retain the committed-generation, borrowed-reference and counted-cost witnesses; record-only mutation controls live with the record implementation.
- **Redaction** (9.19.0; the law restored to owner ruling (a)) has ONE owner of the DECISION, `memory/redaction.ts` (L4 since C4): `RedactionRule` (verdict per user-level path: `clear | whole | fields`) and, per staged write, `decideWrite` → `inheritByIdentity` → (the record writes) → `markStagedWrite` (the marks, only once the write staged — a failed `$update` leaves no mark in `redactionMarks` or the report); the BYTES are the record's (`RecordFrame · write` registers `scrubOf(verdict)`, foottrace's `memory/scrub.ts · scrubPatch` writes the log's placeholder at commit; no record file imports `redaction.ts`), carried on the run's `RunPolicy` with the four dials (F5). THE LAW: a policy covers EVERYTHING the library retains or serves — log (both encodings), mirror, stageReads/stageWrites, every recorder event inline and deferred (and the rows built from them), narrative, snapshots, diagnostics, pause payloads, boundary records, logger lines — and NEVER the live heap or the resume checkpoint. The two true exceptions are the caller's own values: the `run()` rejection and the live fork result. It selects by NAME, never by content: state by path (`verdict`); a record handed out whole (root input/output — `FlowchartTraverser.execute`; subflow seed/exit — `SubflowExecutor`; pause payload — `engine/handlers/servedPause.ts`; thrown value — `retainStageError`) by its keys at every depth plus declared `fields` under a key of that name (`retainBoundary` — one decision per PATH via `servedByPath`: copy-on-write per path, the visited set only the current path's ancestor stack, a cycle edge landing on the served copy; a path rule is exact under shared references, a key-NAME rule masks a shared node everywhere); a diagnostic entry as the record `{ [name]: value }` (`retainDiagnostic`; `policy.diagnostics` ADDS diagnostic-only selectors, the only ones that reach flow-message text); a subflow mapper's copy by TAINT (`MapperTaint`, asked by `SubflowInputMapper` for both mappers; the `parallelForEach` items selector counts selected reads via `StageContext · selectedReads` and marks `item`/`into`): a value passed under its own name keeps that name's verdict, an object by reference (or the whole handed record, as fields) keeps its source's, anything computed after a selected read is marked whole (conservative); never value equality across names. A mark is a NAME, run-wide, until the key is deleted — and a pause carries the marks (names only) to the resumed run (`FlowchartCheckpoint.redactionMarks`, checked by `pause/record.ts`). A thrown value is walked with its `cause`/`errors`; an object a mapper passes on by reference is scanned for the selected values it read (planted → fields). Name-only patterns (`needsPath` false — no `.`, lookaround, or escape/class that can match `.`) are decided per object, so the walk shares decisions per (object, targets) — linear on a DAG or a linked history; only a path-dependent pattern walks paths, and past 1,000,000 visits it serves the unvisited remainder as the placeholder (one dev-mode warning), never fails the run. Inside a stage, an OBJECT read under a selected name and written under another keeps its rule (`redaction.ts · inheritByIdentity`, by identity — decided before the write); a primitive or a new object copied across names is selected by its own name only. `StageContext.stageWrite` is the ONE write funnel (facade writes, subflow seed, outputMapper merge-back, resume re-seed) and `getValue` retention asks the same rule; the facade asks it for read/write recorder payloads. An inert rule never enumerates a payload. `emitPatterns` is the one separate scrub (`$emit` payloads, by event name). Two placeholders, historical, one per owner: log/mirror `'REDACTED'` (`placeholders.ts · LOG_PLACEHOLDER`, a record byte), scope-tier `'[REDACTED]'` (`redaction.ts · SCOPE_PLACEHOLDER`). See `docs/guides/scope.md` → "The one law". Capture the retained form at runtime; no second tree walk or recorder-local policy.
- **Diagnostic retention** → `DiagnosticCollector` asks the current run rule before merging/storing: a named entry maps to the state key of its name (the run's keys/patterns/marks/fields, at every depth of the value), then the opt-in `RedactionPolicy.diagnostics` selectors (same path algorithm, rooted at the channel). Its internal retained-write bridge supplies legacy facade emits. Existing writers stay void. Only flow-message description/rationale are payloads (no name — diagnostic selectors only); keys/topology/timing stay present. Dynamic capture uses a per-visit engine flag, not a diagnostic bag. A redacted `logs.deciderRationale` also reaches decision explanations. Checkpoints keep retained diagnostic bags but preserve operational state; no retroactive scrub. See the scope guide's diagnostic contract.
- **Traverser phases** → node-shape reads must use eff* overlay accessors (:646-703) or dynamic StageNode returns break; tail continuations must return ContinuationHops or stacks regrow; decider flat-dispatch must keep InvokerStamp or pause loses its invoker.
## End-to-end trace (seed → decider → loop branch → finish)
build: flowChart() → FlowChartBuilder.start → addDeciderFunction → branch {loopTo} plants stub `next={id,isLoopRef:true}` (:346) → build() → makeRunnable.
run: executor.run (FlowChartExecutor.ts:1455): re-entrancy guard → fresh runId → createTraverser (scope factory composed by `runner/attach.ts · RunObservers · composeScopeFactory`: TypedScope + recorders + redaction) → new ExecutionRuntime (SharedMemory+EventLog+root StageContext) → traverser.execute (:501) fires onRunStart → trampoline executeNode (:738; flat hops, recursion only for forks/subflows/decider-with-next, depth cap 500 counts NESTING per call path — `FlowchartTraverser · nestingDepthOf`, siblings share a level; a reached cap fires onError and rejects run() with `TraversalDepthError`, ChildrenExecutor rethrows it in either mode, 9.40.0).
per stage: executeNodeStep stamps runtimeStageId (:818) → StageRunner.run: scopeFactory → onStageStart → stage fn (writes stage into TransactionBuffer via StageContext.setObject; onWrite fires live) → onStageEnd → traverser calls context.commit() (:1094) → onCommit → onStageExecuted (narrative flushes buffered ops here).
decider: DeciderHandler.prepareDispatch (:89) — runs stage, **commits BEFORE branch resolution** (:124), matches branchId, fires onDecision(+evidence) → onStageExecuted('decider') → flat hop with InvokerStamp.
loop: Phase 6 sees isLoopRef → ContinuationResolver.resolveTarget (:107): id-map lookup, iteration guard (max 1000), onLoop → hop; zero stack, state carries forward (never rewound). A decider WITH its own next runs its branch as a BRANCH FRAME (9.40.0): a `loop`-flagged hop leaves the frame only when an ENCLOSING driver already ran the target — the decider follows it flat and skips its next, which runs once after the non-looping pass; any other jump stays in the frame. A selector's own loopTo resolves the same way and fires onLoop.
end: onRunEnd (throw → onRunFailed; pause → neither) → deferred terminalFlush → getSnapshot() = {sharedState (LIVE view; dev-mode frozen clone), initialState (the fold base), commitLog (detached+frozen; a bundle or base holding what freezing cannot seal — a Date, Map, buffer … — is served as a copy of its open paths, `runner/snapshot.ts · servedSnapshot` → `foottrace · serveRecord`, 9.44.2), executionTree, subflowResults (dual-keyed; `globalContext` = the subflow's LIVE heap, or under `redact: true` its own nested mirror — 9.20.0, `servedSubflowResults`), recorders}.
## Backtracking
**Native terminal failure (#109):** `errors/terminalFailure.ts` is an L0 leaf owning `TerminalFailureContext` and
the private `NativeFailureState`. `StageContext` holds one slot; successors and exclusive decider branches share
it, parallel children and subflows isolate it. A private per-frame identity is stamped before any recorder event;
failure capture never reads the mutable event traversal context. Native attempts provide the exact final category through an internal
callback whose private category is fixed before serving/observers; ordinary stage-error paths classify before
commit observers. `commitStageWrites` captures the exact escaping commit error even during error/pause handling,
without changing that path's existing diagnostics or events. Only the explicitly selected
escaping child/subflow rejection transfers ownership. `onRunFailed.failure` and `executor.getFailureContext()` serve
a frozen category plus optional native run/runtime-stage identity, never payload or raw exceptions. Unknown setup,
cancellation, retention, checkpoint or sink failures borrow no stage. Each leg resets after the re-entrancy guard;
settlement seals the slot and releases private raw identity. No record/history traversal or stored schema change.
Seven mechanisms, no state rollback anywhere: **M1** TransactionBuffer staging + net-change commit — commit-on-error by design, explicitly NOT rollback (TransactionBuffer.ts:13-18; error path commits then rethrows). **M2** Pause/Resume checkpointing — the only resume-from-prior-point; TWO raise shapes, ONE checkpoint: `addPausableFunction` (resume runs `resumeFn`) and `interrupt(scope, payload)` (resume RE-RUNS THE STAGE FUNCTION FROM ITS TOP — stages are atomic; the answer returns out of the `interrupt()` call via a WeakMap keyed by the scope), discriminated by `checkpoint.pausedBy`; checkpoint is one detached structuredClone; resume rebuilds the cursor from pausedStageId+subflowPath and re-enters ONCE (9.28.0, `ResumeEntry`): the paused stage's stand-in (its own node, fn swapped — so a dispatcher that interrupted still dispatches) is only where the resumed traversal STARTS — never in a node map — and each subflow on the path takes its capture + entry point on its FIRST entry (a 2+-deep pause re-enters each outer subflow AT the next mount; no pre-mount stage re-runs); every level's entry carries its DISPATCHER's continuation, read from the chart (a branch/fork-child mount hands back to its parent's `next`/join at the parent's level); parallel siblings that paused together wait in `checkpoint.pendingPauses` and are asked in turn, the join after the last; the rest of the run walks the REAL chart (a loop back to the paused stage reaches it and it pauses again; later entries run the inputMapper). Not rebuilt (pinned in test/lib/pause/resume-known-limitations.test.ts): lazy-subflow / parallelForEach-branch pauses (refused), a parallel-branch STAGE's namespace on resume, fan-out error containment for the resumed child. **M3** commit-log replay (EventLog.materialise, commitValueAt — required under delta mode). **M4** loopTo re-entry — forward execution over accumulated state, bounded by maxIterations + dynamicNextHops. **M5** causalChain backward slicing (read-only analysis; honesty flags for untracked reads; `edgeAttribution: 'per-write'` refines edges via `TraceEntry.readKeys` when the writeProvenance dial recorded them — worklist, subset-of-ceiling safe). **M6 (query layer)** foottrace's slice/ — variable-first triage: sliceForKey + append-fold element provenance + the ONLY safe serializations (sliceToJSON/formatSlice — never JSON.stringify a slice root). **M7 (read-time cursor)** foottrace's time-travel/ — `timeTravel(snapshot)` walks stops over the finished log with `stateAt()` folding M3's replay at each one (base = `RuntimeSnapshot.initialState`, which now travels with the log); it is a READER, never a second live cursor, and `drill(mount)` opens a separate cursor over a subflow's own isolated log. 9.18.0: `timeTravel([paused, resumed])` chains a cross-executor M2 resume into ONE axis — the resumed leg's `initialState` (= the state at the pause) restarts the fold, commit indices stay run-local (`Stop.sourceIdx` names the leg), and the chain is refused unless the never-reset execution counter is monotonic across legs. Deep dive + step tables: [.claude/rules/backtracking.md](.claude/rules/backtracking.md).
## Invariants (assumed, not stated)
- **The admitted record (9.30.0)** — a commit's bundle, replayed onto the state its stage began from, gives back the stage's read-your-writes view (its working copy, by `deepEqual`, as a record can hold it — through `structuredClone`, so an `Error`'s dropped own fields never count) at every path it touched and at every container and array slot its writes made on the way there, below the stage's address (`runs/<id>` is where a namespaced stage writes, not a value it reads). VERIFIED for every commit that staged a `merge` or a nested op (foottrace's `TransactionBuffer · admit` → `admission.ts · lossyFamilies`: one `dryFold`, no clone, families of TOUCHED paths compared at their roots); a family that does not fold back is re-encoded as `set` rows of the read-back at its last touch (`emitReadBack`). BY CONSTRUCTION for a stage of `set`/`delete` only, directly under its address (no fold). Every commit that already folded back keeps its bytes. NOT covered: an in-place edit of a value the stage read but never wrote at or above (M2 — still lost), and readers (`commitValueAt`) that disagree with the fold. Pinned by test/lib/memory/property/record-equals-read-back.property.test.ts (independent oracle) and, end to end on both engines, by the witness in property/copy-on-write-fixture.ts.
- Committed state immutable-after-swap — a generation is NEVER edited, and since 9.29.0 generations SHARE every untouched subtree (copy-on-write). First-touch views AND the transaction buffer's net-change diff base hold bare references on that guarantee — an in-place mutation of a committed container corrupts every generation that shares it and blinds the diff. Every writer copies before it writes (foottrace's `pathOps · ownSpine` / `ownedRootOf`); pinned by the generations property in test/lib/memory/scenario/copy-on-write-commit.test.ts and the counted guard in test/lib/memory/boundary/commit-cost-independent-of-state.test.ts.
- Reads are borrowed live references (recorders get them un-cloned) — never mutate.
- The commit log is never the reader's to edit. Foottrace owns `freezeRecord` (`foottrace/write`) and `serveRecord` (`foottrace`), including copy-on-serve handling for values freezing cannot seal. `runner/snapshot.ts · servedSnapshot` serves `getSnapshot().commitLog` / `.initialState` through that shared law. The engine's internal snapshot (`ExecutionRuntime · getSnapshot`, via `EventLog.recorded`, once per subflow mount) is not a served public record. Known open surfaces are unchanged by extraction: the advanced runtime snapshot, subflow results, execution-tree reads/writes/diagnostics/messages, recorder rows, checkpoints and narrative entries are not all covered by this served-record law. Pinned by `test/lib/memory/scenario/served-record-doors.test.ts` and its property companion.
- executionIndex globally monotonic per run; continues across resume. The FIRST CommitBundle per runtimeStageId is the stage's own: no `phase`, carries its tags. Only a later bundle of the same execution is a continuation, stamped by the WRITER (`phase: 'exit'` = a mount's exit after its merge-back; `phase: 'repeat'` = a fork child's fan-out settle), so a mount with no merge-back (lazy, `parallelForEach` branch, no `outputMapper`) is one bundle. Readers group by runtimeStageId and read `phase`, never the log's shape (9.39.0, F8); a pre-9.39.0 log is inferred only by `inferLegacyPhases` (on `foottrace`), only without a tree; tree-less, a `subflowResults` entry marks a mount. Empty commits are deliberate cursor stops.
- runtimeStageId stamped BEFORE the stage runs — the entire event-correlation model rests on scope+flow events sharing it.
- One executor = one run at a time (_isExecuting guard). State values must survive structuredClone.
- A resume re-enters ONCE (`ResumeEntry`, 9.28.0): the stand-in carries the paused stage's id but is only a START node — every graph lookup (node map, loop targets, subflow roots) must go through the real chart, never through it; a pause-path capture seeds its subflow's first entry only; what runs after an entry is read from the chart, never from the checkpoint. A paused-and-resumed run equals the never-paused run — same trace, same final state — for linear, decider-branch, selector-branch and fork-child placements at every depth and with parallel siblings pausing together (fast-check property, test/lib/pause/resume-real-chart.property.test.ts). Qualified: the RECORD differs by design (the stand-in is one more execution of the paused stage; the loop budget is per leg), and the three pinned limitations above hold.
- Loop-ref stubs deliberately VIOLATE node-id uniqueness — every graph search must skip `isLoopRef` first.
- runId regenerates per run() AND per resume(); recorders detect new runs via traversalContext.runId (engine never resets them).
- Recorder errors never abort traversal (isolated by `recorder/hooks.ts · fire`; every recorder `ErrorEvent.error` is a `StructuredErrorInfo` built once by `recorderFailureEvent`, inline and deferred alike; `errorInfo` lives in the L0 leaf `src/lib/errors/`).
- Deferred-observer capture default at the executor tier is **'clone'** (DeferredObserverTier.ts:167), not 'summary'.
- `getSnapshot().recorders` = ONE row per recorder **id**, across both channels and both delivery tiers (`runner/snapshot.ts · collectRecorderSnapshots`) — a shared-name hook (onError/onPause/onResume) puts one recorder on two lists by design, and rows must survive structuredClone (the narrative row drops `rawValue`, a live reference).
## Landmines
1. **NOT a landmine any more (9.24.0), recorded because it was one for eight releases:** TypedScope's set trap used to JSON-round-trip the ASSIGNED VALUE of every object write (Date→string, Map→{}, own undefined dropped) while `$setValue` did not — two write paths, two byte shapes. The round-trip existed only to keep the scope's own Proxies out of the buffer; `reactive/handles.ts` recognises them now (O(1)), so `writeTraps.ts · sinkSetTrap`, `arrayTraps.ts` (index set, `MUTATING_METHODS` arguments, element-proxy leaf) and `createTypedScope · assignStateKey` hand the value to the sink AS ASSIGNED and the buffer's `structuredClone` at commit is the one detaching step — both doors store the same bytes (pinned: test/lib/reactive/scenario/assigned-value-only.test.ts, a fast-check property over `fc.jsonValue()`). Consequences to keep in mind: (a) a value nothing can clone — a function, a foreign Proxy such as a framework's reactive object — now fails the stage at COMMIT through the trap too (it always did through `$setValue`; the trap used to JSON-copy it silently, dropping the function); (b) `s.k = o; o.x = 1` commits `x: 1` — what the stage reads back — the same law 9.23.0 gave `$setValue`, and since 9.30.0 (owner ruling R1, the admitted record) so does `$update`: an in-place edit of a merged value within its stage is recorded; (c) the shape work the round-trip did for free stays explicit: `delete arr[i]`, a write past the end and `length` growth spell holes as `null` (`arrayTraps` · `fillHoles`). The surrounding state reaches the buffer by reference and is detached with `structuredClone` at commit (9.22.0 / 9.23.0).
2. Fork "parent breaks when ALL children broke" is implemented (ChildrenExecutor.ts:53) but **unwired** — every live call site passes parentBreakFlag=undefined; fork breaks do NOT propagate in real runs.
3. **First bite CLOSED (9.23.0 — clone once at commit):** `TransactionBuffer` · `set` now stores the REFERENCE in `overwritePatch` too (it always did in `workingCopy`) and the record's copy is taken ONCE per surviving path when the payload leaves the buffer (`toChangeOnlyPayload` / `toDeltaPayload`); `StageContext` · `trackWrite` holds the reference + verdict in `_pendingWrites` and `materialiseWrites` takes the retained form once per key at `commit()`. So `$setValue(k, o); o.x = 1` commits `x: 1` — what the stage read back — instead of a stale write-time snapshot (pinned: test/lib/memory/scenario/clone-once-at-commit.test.ts). The law "the record never aliases a caller's object" holds at the commit boundary; a mutation AFTER commit reaches nothing. Two things to keep in mind: (a) the engine's OWN nested ops (`set a` then `merge a.b` — the buffer driven directly; the proxy writes root keys) must not edit through a shared container, because only `workingCopy` receives a nested merge's result — `detachHeldAncestors` clones the held ancestor in `overwritePatch` first (the design page missed this; the `set-merge-interleaved` 9.22.0 reference pins it); (b) an UNCLONEABLE value (function, foreign Proxy) now fails at COMMIT, not at the write — the run still fails loudly with the `DataCloneError`, the stage cannot catch it, and none of that stage's writes land. The scope's OWN proxies are not uncloneable values at the boundaries the library owns (9.23.3, `reactive/handles.ts`): a handle handed to the fan-out selector, a subflow mapper or `$setValue` is swapped for the value behind it first — a handle buried inside a hand-built value elsewhere (an `interrupt` payload, a `$emit` payload) still fails at the clone. The element-write loop's other O(N)-per-write cost, the array commit callbacks' whole-array JSON round-trip, went in the same release (landmine 1, second moved behaviour); what remains per element write is the array proxy's own shallow copy-on-write (`arrayTraps` · `replaceInElement` / the traps' `[...getCurrent()]`, ~9 µs at N = 10k) — bench 1k 5.7 ms, 10k 104 ms. **Second bite — a raw element held past its stage:** (within ONE stage, 9.29.0: a raw read BEFORE the stage's first write is the committed object itself — edited in place and written back it records NO change, because the diff base is that object (M7; dev mode warns, `StageContext · warnOnCommittedMutation`); a raw read AFTER the first write is the stage's private copy, `TransactionBuffer · privatise` — option D, 9.28.0's behaviour; one the working copy cannot answer (a key the stage deleted) is LIVE state, as on 9.28.0, with the diff base detached there first, `TransactionBuffer · detachBase`, so a write-back is recorded as it was.) A value obtained through `find`/`filter`/`for…of`/`$getValue`/`$read`/`$toRaw` in stage A and mutated in stage B is the committed object itself, so the mutation edits committed state with no row, and the dev-mode guard (`StageContext` · `warnOnBorrowedMutation`) sees it only if B also READS that key. Cannot be trapped; documented in src/lib/reactive/README.md ("A handle is bound to its stage"). A PROXY held that long is refused at the write (`ScopeFacade` · `assertLive`, sealed by the commit observer — the refusal lives on the facade, not `stageWrite`, because the frame's post-commit re-use is what the engine's double-commit paths need).
4. **NOT a landmine any more (9.22.0), recorded because it was one:** an indexed array read (`arr[i]`) used to hand back the RAW element, and a nested array write committed with the `merge` verb whose array arm is a set UNION. Both are fixed at the root — `reactive/arrayTraps.ts` wraps elements, and a nested array commits as a `set` of its ROOT KEY (`createTypedScope` · nested array commit callback). Consequences to keep in mind: an array ASSIGNMENT now REPLACES at every depth (`$update` is still the append), and a nested array write appears in `bundle.overwrite`/verb `set`, not `bundle.updates`/verb `merge`. What the proxy still cannot see — an element reached via `find`/`filter`/`for…of`/`forEach` — is REPORTED loudly instead: `StageContext` · `warnOnBorrowedMutation` (a dev-mode WARNING at the frame's first commit, under `readTracking: 'full'` only — a report after the fact, not a refusal). Foottrace's `memory/equality.ts` · `equalPairs` has one arm per value KIND (`capture/valueKinds.ts · kindOf`, the one classifier — every kind `structuredClone` keeps since 9.44.2; before it, a replaced RegExp/Error/boxed primitive/buffer/view/Blob was a no-op) so a `$setValue` of a new Date/Set/RegExp is a change, not a no-op; an opaque value (Blob) compares by identity in the replacement check and as equal between two COPIES of one record (`OpaqueRule`: the writer rule, element provenance, the borrowed-mutation guard, admission). Every proxy (nested/terminal/element) READS LIVE through `reactive/liveView.ts` — a held handle sees its own writes; it answers from the captured object only when the path is gone. See src/lib/reactive/README.md. **Named, not fixed (out of contract):** a cyclic self-reference reached through a terminal proxy commits array/element writes to the WRONG place (row recorded, fold agrees, value lands beside the cycle edge); a top-level key containing U+001F (the path separator) throws at the seed; an expando on an array (`arr.foo = 1`) is dropped by the array proxy's set trap (indices and `length` only).
## Pointers
- [.claude/rules/backtracking.md](.claude/rules/backtracking.md) — the 5 mechanisms with step tables + pseudocode
- [docs/guides/](docs/guides/) (error-handling, observers-deferred) · [examples/](examples/) — mandatory integration tests (Convention 2); 7 test types per feature (Convention 3)
- Build/test: `npm run build` (CJS+ESM+types), `npm test`
## Attempt metrics (10.1, P7)
`recorder/AttemptRecorder.ts` owns the opt-in `attempts()` flow recorder (`footprintjs/recorders`). It composes
`KeyedStore` and collects named scalar rows at onAttempt/onDecision/run lifecycle time, never by walking a tree.
`attemptMetrics.ts` aggregates exact retained facts and nearest-rank latency; `attemptMetricRows.ts` is the single
live/store row admission boundary; `attemptOTel.ts` projects typed histogram/sum JSON. Keys, inputs, outputs, error
text/raw causes and unknown envelope fields never enter metric rows. Physical IDs/outcomes stay physical; explicit
logical controls use recorded retryLoop decisions. Kinds remain visible when target names collide. Memory rows,
exemplars and all owned I/O are bounded; asynchronous losses/timeouts remain disclosed. A timed-out I/O retains its
slot until actual settlement. Sum of durations is work time, not wall-clock budget; absent original budget stays
absent. Governing guide: docs/guides/attempt-metrics.md; source rules: src/lib/recorder/README.md.
P7 admission reads plain own data descriptors (including nested loops and array windows) before field access;
external getters/coercion hooks never run. Stored logical decisions join exact physical IDs/targets/loop identity.
`AttemptMetricRef` preserves the compact existing `AttemptRef`; target, run and OTLP views retain nonzero exemplar
capacity. Optional store `stats()` discloses known retention omissions, including reference-memory truncation.
Queued-sibling re-raise notifications use current-leg root context at `FlowchartTraverser.walkThenRaise`; they do
not pretend the sibling re-executed or overwrite its original pausedExecution link. P7 pause lifecycle updates are
idempotent across sibling hooks, exact run IDs isolate shared recorders, and a real failure can supersede a pause.
A shared recorder's auto-clear preserves already-emitted rows for active/paused ownership; completed projections
reset normally. Keep omission accounting while active ownership exists, never rebuild an apparently complete empty
run header after deleting its attempts. All retained row/identity maps remain bounded.
## Stage attention (C2)
`memory/StageAttention` owns attention bookkeeping; `StageContext` holds one helper and delegates at retained attempt/summary/error boundaries. Import shared `Attention` from Foottrace root without a FootPrint re-export. Use the official `buildAttemptId`/`parseAttemptId` codec, preserving owning execution and call identity; no `attempts:N` alias. Reset on a new execution and preserve across retries. Snapshot export only copies bounded marks (32 marks, 16 refs each), never scans attempts or classifies arbitrary diagnostic values. Missing proof stays `not-collected`; observed SDK suggestions are not actual retry decisions, and physical helper stops are not logical exhaustion. The engine must not inspect attached recorders or infer their quality. Quality rows remain recorder-owned and may be adapted at read time. No implicit outlier detector or new crash-safe attention format. Guide: `docs/guides/attention.md`; runnable example: `examples/runtime-features/attention/01-stage-attention.ts`.