CLAUDE.md · git:20261016.0fc9bc8 · 2026-10-16 · sha256 30daeab362c93b18
CLAUDE.md git:20261016.0fc9bc8A
Immutable. This exact content never changes and is served at /api/v1/blob/30daeab362c93b18.
<!-- 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`. 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 |
| 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
- `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
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.