footprintjs 9.22.0 → 9.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -51,8 +51,8 @@ Entry points: `footprintjs` (public API) · `/recorders` (factories) · `/trace`
51
51
  - **Closed seams that look open**: ExecutionEnv (fixed type); decide() operators (evaluator.ts:19 module-private); METHOD_ROUTES; the executeNodeStep phase chain; emit dispatcher (rides scope channel).
52
52
 
53
53
  ## Change-impact map
54
- - **Trace verbs** (`set|merge|append|delete`, memory/types.ts:42) → FOUR verb-switch replicas in lockstep: applySmartMerge (utils.ts:254 — live commit AND EventLog.materialise), commitValueAt (commitLogUtils.ts:82), TransactionBuffer.toDeltaPayload/replayPathVerbs (:248-328); plus causalChain/findLastWriter readers.
55
- - **StageContext.commit / dials** → dial propagation is TRIPLICATED: ExecutionRuntime.use* (:131-161), createNext/createChild inheritance (StageContext.ts:619/640), SubflowExecutor duck-push (:151-173) — miss one and subflows silently run the default. Applies to all FOUR dials incl. writeProvenance (#P1).
54
+ - **Trace verbs** (`set|merge|append|delete`, memory/types.ts:42) → FOUR verb-switch replicas in lockstep: applySmartMerge (utils.ts:254 — live commit AND EventLog.materialise), commitValueAt (commitLogUtils.ts:82), TransactionBuffer.toDeltaPayload/replayPathVerbs (:248-328); plus causalChain/findLastWriter readers. NOT a fifth replica (9.22.1): `utils.ts · supersededByNextSet` — the ONE repeated-path skip (CONSECUTIVE same-path `set` rows: only the last is applied), asked by `applySmartMerge` before its switch and by `replayFamilyVerbs`; its commit-side twin is the last-materialised-path memo in `toChangeOnlyPayload`. Rows are never dropped from the log; a non-consecutive skip is WRONG (a descendant op in between coerces/shells the container) — pinned by test/lib/memory/scenario/repeated-path-byte-identity.test.ts. Since 9.23.0 the clone in that memo loop (and every `structuredClone` in `toDeltaPayload`) is THE clone — the patch trees hold references until commit; still not a fifth replica, no verb switch was touched.
55
+ - **StageContext.commit / dials** → dial propagation is TRIPLICATED: ExecutionRuntime.use* (:131-161), createNext/createChild inheritance (StageContext.ts:619/640), SubflowExecutor duck-push (:151-173) — miss one and subflows silently run the default. Applies to all FOUR dials incl. writeProvenance (#P1). `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.
56
56
  - **runtimeStageId format** (`#`/`/` delimiters) → string parsers everywhere: parseRuntimeStageId, ScopeFacade._getSubflowPath, subflowResults dual-keying, checkpoint lean-filter, narrative buffering, store keys. All are last-delimiter based, which is why a GENERATED branch segment (`<stageId>~<index>`, 9.14.0) needed ZERO parser changes — the marker is opaque to every one of them. Only 3 places may ever know `~`: branchSegment.ts, the builder's 9 refusals, the fan-out handler. A parser that special-cases it is a design smell.
57
57
  - **Subflow id prefixing** → DUPLICATED prefixer: builder _prefixNodeTree and traverser prefixNodeTree are byte-twins; change both + stageMap key composition + resume drilling. PINNED since 9.14.0 by test/lib/engine/branch-segment-prefixer-equivalence.test.ts (fails on drift, including drift that keeps the twins equal to each other).
58
58
  - **Checkpoint shape** → buildPauseCheckpoint (FlowChartExecutor.ts:987) ↔ resume() validation (:690) ↔ checkpointSanitize ↔ SubflowExecutor resume-seed (skip-inputMapper, :69-72).
@@ -83,9 +83,9 @@ Seven mechanisms, no state rollback anywhere: **M1** TransactionBuffer staging +
83
83
  - `getSnapshot().recorders` = ONE row per recorder **id**, across both channels and both delivery tiers (collectRecorderSnapshots, FlowChartExecutor.ts) — 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).
84
84
 
85
85
  ## Landmines
86
- 1. TypedScope's set trap JSON-round-trips the ASSIGNED VALUE of every object write (`reactive/structuralWrite.ts` · `unwrapProxy`, called from createTypedScope's top-level/nested set traps and the array + element commit paths) — Date→string, Map→{}, undefined drops; `$setValue` bypasses it, so the two write paths store different bytes. Scope is the assigned value only: the surrounding state reaches the buffer by reference and is detached with `structuredClone`, which keeps Dates and Maps (9.22.0).
86
+ 1. TypedScope's set trap JSON-round-trips the ASSIGNED VALUE of every object write (`reactive/structuralWrite.ts` · `unwrapProxy`, called from createTypedScope's top-level/nested set traps and, since 9.23.0, from the ARRAY TRAPS themselves — `arrayTraps.ts` index set, `MUTATING_METHODS` arguments, element-proxy leaf) — Date→string, Map→{}, undefined drops; `$setValue` bypasses it, so the two write paths store different bytes. Scope is the assigned value only, and since 9.23.0 that is true for ELEMENT writes too: the array commit callbacks in `createTypedScope` used to round-trip the WHOLE rebuilt array (stringifying every untouched sibling, O(N) per element write); now the rebuilt array is committed as it is and untouched siblings pass through by reference (pinned: test/lib/reactive/scenario/assigned-value-only.test.ts). A whole-array ASSIGNMENT (`s.arr = [...]`) still round-trips every element — the array IS the assigned value. The shape work the round-trip did for free is explicit now: `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, which keeps Dates and Maps (9.22.0 / 9.23.0).
87
87
  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.
88
- 3. TransactionBuffer.set stores the RAW reference in workingCopy but a CLONE in overwritePatch (`TransactionBuffer` · `set`) post-write mutation changes what the stage reads back and the net-change filter, while committing stale bytes. NOT reachable through the scope proxy since 9.22.0 (every proxy write builds a new value with `setInPath` and never mutates one it read); it still bites `$setValue(k, o)` followed by `o.x = 1` on the caller's own object. **Second bite — a raw element held past its stage:** 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).
88
+ 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, 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 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:** 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).
89
89
  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). `memory/utils.ts` · `equalPairs` has typed arms (Date by instant, Map/Set by members) so a `$setValue` of a new Date/Set is a change, not a no-op. 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).
90
90
 
91
91
  ## Pointers
@@ -66,8 +66,20 @@ export declare class StageContext {
66
66
  next?: StageContext;
67
67
  children?: StageContext[];
68
68
  debug: DiagnosticCollector;
69
- /** Tracks user-level writes (pre-namespace) for the memory view and onCommit. */
69
+ /** Tracks user-level writes (pre-namespace) for the memory view and onCommit
70
+ * — in their RETAINED form (cloned / summarised / redacted). Filled from
71
+ * {@link _pendingWrites} by {@link materialiseWrites} at commit. */
70
72
  private _stageWrites;
73
+ /**
74
+ * The writes of the CURRENT execution, held by reference with the verdict
75
+ * they were staged under (9.23.0). A key written k times holds its LAST
76
+ * value; the retained form (the `writeTracking` clone or summary, the
77
+ * redaction placeholder or field scrub) is taken ONCE per key at commit —
78
+ * see {@link materialiseWrites}. Same law as the transaction buffer's
79
+ * patch trees: the copy the record needs is paid at the boundary, not at
80
+ * every write. Lazily allocated; a stage that never writes pays nothing.
81
+ */
82
+ private _pendingWrites?;
71
83
  /** Tracks user-level reads (pre-namespace) for the memory view. */
72
84
  private _stageReads;
73
85
  /**
@@ -103,8 +115,10 @@ export declare class StageContext {
103
115
  * How tracked writes are recorded into `_stageWrites` (#13c-A) — the
104
116
  * sibling of {@link readTracking}, with the same propagation pattern
105
117
  * (inherited via {@link createNext}/{@link createChild}, pushed into
106
- * subflow root contexts by `SubflowExecutor`). Governs the per-write
107
- * `structuredClone` in {@link setObject}/{@link updateObject}. Affects the
118
+ * subflow root contexts by `SubflowExecutor`). Governs the retained form
119
+ * {@link materialiseWrites} takes at commit for the writes of
120
+ * {@link setObject}/{@link updateObject} (a clone under `'full'`, taken
121
+ * once per key — 9.23.0). Affects the
108
122
  * snapshot's `stageWrites` payload AND the commit observer's mutations
109
123
  * payload (which is a spread of `_stageWrites`) — but NOT the write
110
124
  * itself: the transaction buffer, the commit log, and shared state are
@@ -223,20 +237,37 @@ export declare class StageContext {
223
237
  /** Returns the active write-provenance policy (used for subflow propagation). */
224
238
  getWriteProvenance(): WriteProvenanceMode;
225
239
  /**
226
- * Record a tracked user-level write into `_stageWrites`, policy-gated
227
- * (#13c-A) — the single bookkeeping path for {@link setObject} and
228
- * {@link updateObject}.
240
+ * Record a tracked user-level write, policy-gated (#13c-A) — the single
241
+ * bookkeeping path for {@link setObject} and {@link updateObject}. Holds
242
+ * the REFERENCE and the verdict (9.23.0); the retained form is taken at
243
+ * commit by {@link materialiseWrites}, once per key.
229
244
  *
230
245
  * Redaction takes precedence over the dial in EVERY mode: a redacted
231
246
  * write stores the `'[REDACTED]'` placeholder under `'full'` AND
232
247
  * `'summary'` (a summary marker would leak the value's preview/size),
233
248
  * and stores nothing under `'off'` (entry skipped entirely — nothing to
234
249
  * leak). A field-level verdict scrubs a clone BEFORE the dial sees it, so
235
- * a summary preview can never show the secret either. The staged write
236
- * itself is unaffected redaction of the committed payload is handled by
237
- * the transaction buffer's `redactedPaths`.
250
+ * a summary preview can never show the secret either. The verdict is the
251
+ * one `stageWrite` asked the rule for BEFORE staging, carried to commit
252
+ * with the value. The staged write itself is unaffected — redaction of
253
+ * the committed payload is handled by the transaction buffer's
254
+ * `redactedPaths`.
238
255
  */
239
256
  private trackWrite;
257
+ /**
258
+ * `_stageWrites` with every pending write folded in as its retained form
259
+ * — the record a reader sees. Consumes nothing: a mid-run snapshot reads
260
+ * the writes so far, and commit still materialises the final values.
261
+ * Returns `_stageWrites` itself when nothing is pending.
262
+ */
263
+ private retainedWrites;
264
+ /**
265
+ * Take the retained form of every pending write — ONCE per key, from the
266
+ * value as it stands at commit (9.23.0) — and release the references. The
267
+ * first thing `commit()` does, so the borrowed-mutation guard and the
268
+ * commit observer see the finished record.
269
+ */
270
+ private materialiseWrites;
240
271
  /**
241
272
  * The form of a value the engine RETAINS (reads and writes retention):
242
273
  * the placeholder beats every dial; a field-level scrub happens before the
@@ -471,10 +502,11 @@ export declare class StageContext {
471
502
  * - `stateView` — the first-touch anchor; the next attempt re-anchors on
472
503
  * committed state as it stands NOW (a sibling fork branch
473
504
  * may legitimately have committed in between);
474
- * - `_stageWrites` / `_stageReads` — the snapshot payload; without the
475
- * reset the execution tree would report writes that were
476
- * discarded, which is the exact lie this feature exists
477
- * to prevent;
505
+ * - `_stageWrites` / `_pendingWrites` / `_stageReads` — the snapshot
506
+ * payload; without the reset the execution tree would
507
+ * report writes that were discarded, which is the exact
508
+ * lie this feature exists to prevent (the pending map
509
+ * holds bare references — dropping it un-clones nothing);
478
510
  * - `_provenanceReads` — the per-write read prefix (#P1); a discarded
479
511
  * attempt's reads must never appear in the next attempt's
480
512
  * `TraceEntry.readKeys`, or a backward slice would follow