footprintjs 9.0.0 → 9.2.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.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/dist/advanced.js +1 -1
  3. package/dist/esm/advanced.js +1 -1
  4. package/dist/esm/index.js +1 -1
  5. package/dist/esm/lib/decide/decide.js +12 -1
  6. package/dist/esm/lib/decide/evaluator.js +28 -2
  7. package/dist/esm/lib/engine/handlers/SubflowExecutor.js +14 -1
  8. package/dist/esm/lib/engine/narrative/FlowRecorderDispatcher.js +4 -4
  9. package/dist/esm/lib/engine/narrative/types.js +1 -1
  10. package/dist/esm/lib/memory/StageContext.js +201 -15
  11. package/dist/esm/lib/memory/TransactionBuffer.js +17 -7
  12. package/dist/esm/lib/memory/index.js +2 -1
  13. package/dist/esm/lib/memory/types.js +3 -2
  14. package/dist/esm/lib/recorder/CombinedRecorder.js +20 -10
  15. package/dist/esm/lib/runner/ExecutionRuntime.js +12 -1
  16. package/dist/esm/lib/runner/FlowChartExecutor.js +27 -4
  17. package/dist/esm/lib/scope/ScopeFacade.js +28 -1
  18. package/dist/esm/lib/scope/types.js +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/lib/decide/decide.js +12 -1
  21. package/dist/lib/decide/evaluator.js +28 -2
  22. package/dist/lib/engine/handlers/SubflowExecutor.js +14 -1
  23. package/dist/lib/engine/narrative/FlowRecorderDispatcher.js +4 -4
  24. package/dist/lib/engine/narrative/types.js +1 -1
  25. package/dist/lib/memory/StageContext.js +201 -15
  26. package/dist/lib/memory/TransactionBuffer.js +17 -7
  27. package/dist/lib/memory/index.js +4 -2
  28. package/dist/lib/memory/types.js +4 -1
  29. package/dist/lib/recorder/CombinedRecorder.js +20 -10
  30. package/dist/lib/runner/ExecutionRuntime.js +12 -1
  31. package/dist/lib/runner/FlowChartExecutor.js +27 -4
  32. package/dist/lib/scope/ScopeFacade.js +28 -1
  33. package/dist/lib/scope/types.js +1 -1
  34. package/dist/types/advanced.d.ts +1 -1
  35. package/dist/types/index.d.ts +10 -0
  36. package/dist/types/lib/decide/decide.d.ts +11 -0
  37. package/dist/types/lib/decide/evaluator.d.ts +17 -0
  38. package/dist/types/lib/engine/narrative/types.d.ts +11 -0
  39. package/dist/types/lib/memory/StageContext.d.ts +113 -5
  40. package/dist/types/lib/memory/TransactionBuffer.d.ts +16 -6
  41. package/dist/types/lib/memory/index.d.ts +2 -1
  42. package/dist/types/lib/memory/types.d.ts +45 -1
  43. package/dist/types/lib/recorder/CombinedRecorder.d.ts +15 -9
  44. package/dist/types/lib/runner/ExecutionRuntime.d.ts +10 -1
  45. package/dist/types/lib/runner/FlowChartExecutor.d.ts +25 -3
  46. package/dist/types/lib/scope/ScopeFacade.d.ts +24 -1
  47. package/dist/types/lib/scope/types.d.ts +11 -0
  48. package/package.json +1 -1
@@ -11,7 +11,7 @@ import { DiagnosticCollector } from './DiagnosticCollector.js';
11
11
  import { EventLog } from './EventLog.js';
12
12
  import { SharedMemory } from './SharedMemory.js';
13
13
  import { TransactionBuffer } from './TransactionBuffer.js';
14
- import type { FlowControlType, StageSnapshot } from './types.js';
14
+ import type { FlowControlType, ReadTrackingMode, StageSnapshot } from './types.js';
15
15
  export declare class StageContext {
16
16
  private sharedMemory;
17
17
  /**
@@ -28,6 +28,13 @@ export declare class StageContext {
28
28
  */
29
29
  private redactedSharedMemory?;
30
30
  private buffer?;
31
+ /**
32
+ * Committed-state view captured at this stage's FIRST touch (first read OR
33
+ * first write) — held by REFERENCE, never cloned. See
34
+ * {@link firstTouchState} for the algorithm and the immutability invariant
35
+ * that makes a bare reference safe.
36
+ */
37
+ private stateView?;
31
38
  private eventLog?;
32
39
  stageName: string;
33
40
  /** Unique stage identifier from the builder (matches spec node id). */
@@ -50,6 +57,17 @@ export declare class StageContext {
50
57
  private _stageWrites;
51
58
  /** Tracks user-level reads (pre-namespace) for the memory view. */
52
59
  private _stageReads;
60
+ /**
61
+ * How tracked reads are recorded into `_stageReads` (#14). Default `'full'`
62
+ * preserves the historical per-read `structuredClone`. Inherited by every
63
+ * context created via {@link createNext} / {@link createChild} (same
64
+ * propagation pattern as the redacted mirror), and pushed into subflow
65
+ * root contexts by `SubflowExecutor`. Affects ONLY the snapshot's
66
+ * `stageReads` payload — `ScopeRecorder.onRead` (and therefore narrative)
67
+ * is dispatched at the scope tier and never cloned, so it is identical in
68
+ * every mode.
69
+ */
70
+ private readTracking;
53
71
  /** Observer called after commit() — used by ScopeFacade to fire ScopeRecorder.onCommit. */
54
72
  private _commitObserver?;
55
73
  constructor(runId: string, name: string, stageId: string, sharedMemory: SharedMemory, branchId?: string, eventLog?: EventLog, isDecider?: boolean);
@@ -66,9 +84,69 @@ export declare class StageContext {
66
84
  useRedactedMirror(mirror: SharedMemory): void;
67
85
  /** Returns the redacted mirror if installed, else undefined. */
68
86
  getRedactedSharedMemory(): SharedMemory | undefined;
69
- /** Lazily creates the transaction buffer on the stage's FIRST state access —
70
- * reads included, so read-only stages currently pay the clone cost too.
71
- * (Truly lazy-on-write is backlog Phase-3 #13.) */
87
+ /**
88
+ * Set the read-tracking policy for this context (#14). Called at the root
89
+ * by `ExecutionRuntime.useReadTracking()` (plumbed from
90
+ * `FlowChartExecutor`); descendants inherit via `createNext`/`createChild`,
91
+ * and `SubflowExecutor` pushes the parent context's mode into each subflow
92
+ * root so nested charts inherit too.
93
+ */
94
+ useReadTracking(mode: ReadTrackingMode): void;
95
+ /** Returns the active read-tracking policy (used for subflow propagation). */
96
+ getReadTracking(): ReadTrackingMode;
97
+ /**
98
+ * ── The first-touch state view (#13) ────────────────────────────────────
99
+ *
100
+ * WHAT: returns the committed shared state as it was at this stage's FIRST
101
+ * touch (first read or first write), capturing the reference on first call.
102
+ * Serves two consumers: reads before the first write ({@link readState})
103
+ * and the transaction buffer's diff base ({@link getTransactionBuffer}).
104
+ *
105
+ * WHY A BARE REFERENCE IS SAFE — the invariant this rests on: committed
106
+ * state is immutable-after-swap. `SharedMemory.applyPatch` routes through
107
+ * `applySmartMerge`, which `structuredClone`s the current state, mutates
108
+ * only the clone, and swaps `SharedMemory.context` to it — the object a
109
+ * stage captured here is never edited afterwards. (`SharedMemory.setValue`/
110
+ * `updateValue` DO mutate in place, but have no callers during traversal;
111
+ * every runtime write reaches state through a stage commit's `applyPatch`.)
112
+ * Holding the reference therefore gives this stage a stable snapshot at
113
+ * zero cost — no clone, which is the entire point of #13.
114
+ *
115
+ * WHY FIRST TOUCH, not first write: the pre-#13 eager engine cloned the
116
+ * state into the buffer at the stage's first ACCESS, anchoring both its
117
+ * snapshot reads and its commit baseline (the net-change diff base) there.
118
+ * #13's first cut anchored the lazy buffer at first WRITE — observably
119
+ * different when something else commits in the gap between this stage's
120
+ * first read and its first write. That gap is REACHABLE: fork siblings are
121
+ * namespace-isolated for run-scoped keys (each child writes under
122
+ * `runs/<childId>/`), but ROOT-level keys are shared — written via
123
+ * `setGlobal` from consumer scope code and, critically, by
124
+ * `SubflowInputMapper`'s output mapping (`parentContext.setGlobal`), which
125
+ * is exactly what runs when a subflow is a fork branch. A sibling's
126
+ * root-key commit landing in the gap would shift this stage's diff base,
127
+ * making its CommitBundle record a phantom change (or swallow a real one)
128
+ * relative to the eager engine. Anchoring the view at first touch restores
129
+ * the EXACT eager semantics — sequential AND parallel — at zero clone cost.
130
+ *
131
+ * Read visibility is two-tier, matching eager byte-for-byte: keys present
132
+ * in the view at first touch read repeatably from it; keys ABSENT from it
133
+ * fall back to LIVE state (the eager engine's exact fallback — a
134
+ * mid-flight sibling root-key write was always visible to reads, and
135
+ * stays visible; only the DIFF BASE is pinned).
136
+ */
137
+ private firstTouchState;
138
+ /** Lazily creates the transaction buffer on the stage's FIRST WRITE (#13).
139
+ *
140
+ * Reads NEVER construct it: read-your-writes only matters once a staged
141
+ * write exists, so before that {@link getValue}/{@link getValueDirect}
142
+ * serve from the first-touch state view and {@link commit} records an
143
+ * empty bundle — all with ZERO `structuredClone`s of the shared state.
144
+ *
145
+ * The buffer's base is the FIRST-TOUCH view, NOT the live state at write
146
+ * time: under parallel forks a sibling may have committed between this
147
+ * stage's first read and this write, and the net-change diff base must
148
+ * stay anchored at first touch to match the eager engine — see
149
+ * {@link firstTouchState}. */
72
150
  getTransactionBuffer(): TransactionBuffer;
73
151
  /** Builds an absolute path inside the shared memory (run namespace). */
74
152
  private withNamespace;
@@ -82,7 +160,27 @@ export declare class StageContext {
82
160
  updateGlobalContext(key: string, value: unknown): void;
83
161
  appendToArray(path: string[], key: string, items: unknown[], description?: string): void;
84
162
  mergeObject(path: string[], key: string, obj: Record<string, unknown>, description?: string): void;
85
- getValue(path: string[], key?: string, description?: string): any;
163
+ /** Buffer-aware read, mirroring the eager engine's read order byte-for-byte:
164
+ *
165
+ * 1. staged writes + first-touch snapshot — `buffer.get` over its
166
+ * workingCopy when the buffer exists, else `nativeGet` over the
167
+ * zero-clone state view (the buffer's base IS that view, so the two
168
+ * tiers agree on content);
169
+ * 2. LIVE state via `sharedMemory.getValue` for keys absent from the
170
+ * snapshot — including its run→global namespace fallback. The eager
171
+ * engine had this exact live fallback for snapshot-missing keys;
172
+ * byte-identity over purity.
173
+ *
174
+ * Reads never construct the buffer (#13): a stage that never writes
175
+ * performs zero clones of the shared state. */
176
+ private readState;
177
+ /**
178
+ * Tracked read. The returned value is BORROWED — see the contract on
179
+ * `ScopeFacade.getValue`. Read-tracking cost is policy-gated (#14):
180
+ * `'full'` clones the value into `_stageReads` (historical default),
181
+ * `'summary'` records a cheap marker, `'off'` records nothing.
182
+ */
183
+ getValue(path: string[], key?: string, description?: string): unknown;
86
184
  /** Read state without tracking in _stageReads or paying structuredClone cost.
87
185
  * Used by ScopeFacade.getValueSilent() for array proxy internal operations. */
88
186
  getValueDirect(path: string[], key?: string): unknown;
@@ -97,6 +195,16 @@ export declare class StageContext {
97
195
  operation: 'set' | 'update' | 'delete';
98
196
  }>) => void): void;
99
197
  commit(): void;
198
+ /**
199
+ * Create (or return) this context's linked successor.
200
+ *
201
+ * MEMOIZED: the first call creates `this.next`; every later call returns
202
+ * that SAME context and IGNORES its arguments. In normal traversal each
203
+ * context advances exactly once, so the memo never bites — but a caller
204
+ * expecting a fresh context for different `stageName`/`stageId` args gets
205
+ * the old one silently. Dev mode (`enableDevMode()`) warns on that
206
+ * mismatch (backlog B4).
207
+ */
100
208
  createNext(path: string, stageName: string, stageId: string, isDecider?: boolean): StageContext;
101
209
  createChild(runId: string, branchId: string, stageName: string, stageId: string, isDecider?: boolean): StageContext;
102
210
  createDecider(path: string, stageName: string, stageId: string): StageContext;
@@ -1,11 +1,21 @@
1
1
  /**
2
- * TransactionBuffer — Transactional write buffer for stage mutations
2
+ * TransactionBuffer — Per-stage STAGING buffer for state mutations
3
3
  *
4
- * Collects writes during execution and commits them atomically.
5
- * Like a database transaction buffer:
6
- * - Changes staged here before being committed to SharedMemory
7
- * - Enables read-after-write consistency within a stage
8
- * - Records operation trace for deterministic replay
4
+ * What it IS: a staging buffer with read-your-writes and net-change commits.
5
+ * - Changes are staged here during stage execution and flushed to
6
+ * SharedMemory in ONE batch per stage (`commit()`) other stages and
7
+ * parallel siblings never observe a stage's half-finished writes.
8
+ * - Read-after-write consistency within a stage — a stage sees its own
9
+ * staged writes immediately.
10
+ * - `commit()` records the stage's NET change (see {@link commit}), plus an
11
+ * operation trace for deterministic replay.
12
+ *
13
+ * What it is NOT: a rollback mechanism. Despite the name, there is no
14
+ * abort/rollback path — when a stage THROWS, the engine still commits
15
+ * everything staged so far before re-throwing (commit-on-error in
16
+ * `FlowchartTraverser`). That is deliberate: the audit trail must record
17
+ * what the failing stage changed. Do not rely on "stage failed → its
18
+ * writes vanished".
9
19
  */
10
20
  import type { MemoryPatch } from './types.js';
11
21
  export declare class TransactionBuffer {
@@ -9,5 +9,6 @@ export { EventLog } from './EventLog.js';
9
9
  export { SharedMemory } from './SharedMemory.js';
10
10
  export { StageContext } from './StageContext.js';
11
11
  export { TransactionBuffer } from './TransactionBuffer.js';
12
- export type { CommitBundle, FlowControlType, FlowMessage, MemoryPatch, ScopeFactory, StageSnapshot, TraceEntry, } from './types.js';
12
+ export type { CommitBundle, FlowControlType, FlowMessage, MemoryPatch, ReadSummaryMarker, ReadTrackingMode, ScopeFactory, StageSnapshot, TraceEntry, } from './types.js';
13
+ export { READ_PREVIEW_LENGTH } from './types.js';
13
14
  export { applySmartMerge, deepSmartMerge, DELIM, getNestedValue, getRunAndGlobalPaths, normalisePath, redactPatch, setNestedValue, updateNestedValue, updateValue, } from './utils.js';
@@ -45,6 +45,47 @@ export interface FlowMessage {
45
45
  iteration?: number;
46
46
  timestamp?: number;
47
47
  }
48
+ /**
49
+ * Policy for how tracked reads are recorded into `StageSnapshot.stageReads`.
50
+ *
51
+ * - `'full'` (default) — every tracked read `structuredClone`s the value into
52
+ * the stage's read view. Byte-identical to the historical behavior; this is
53
+ * what snapshot consumers (lens, agentfootprint) see today.
54
+ * - `'summary'` — reads record a cheap {@link ReadSummaryMarker} (type + size
55
+ * proxy + short preview) instead of the cloned value. O(1)-ish per read —
56
+ * no value clone, no serialization of large objects.
57
+ * - `'off'` — reads are not recorded at all; `stageReads` is absent from the
58
+ * snapshot. Zero per-read cost. Values are still readable, and the
59
+ * `ScopeRecorder.onRead` event still fires (it passes the live reference and
60
+ * never cloned) — so narrative output is identical in every mode. The policy
61
+ * scopes ONLY the snapshot's `stageReads` payload.
62
+ *
63
+ * Set via `new FlowChartExecutor(chart, { readTracking })` or
64
+ * `executor.setReadTracking(mode)` (before `run()`).
65
+ */
66
+ export type ReadTrackingMode = 'full' | 'summary' | 'off';
67
+ /**
68
+ * Marker recorded in `StageSnapshot.stageReads` under `readTracking: 'summary'`.
69
+ *
70
+ * Honest cost note: `size` is a cheap proxy (string length / array length /
71
+ * object key count), NOT a serialized byte count — computing real byte size
72
+ * would require an O(value) serialization, which is exactly the cost the
73
+ * summary mode removes. `preview` is only produced for primitives and strings
74
+ * (first {@link READ_PREVIEW_LENGTH} characters); objects and arrays carry no
75
+ * preview for the same reason.
76
+ */
77
+ export interface ReadSummaryMarker {
78
+ /** Discriminant — lets snapshot consumers detect marker entries. */
79
+ __readSummary: true;
80
+ /** `typeof` result, refined to 'array' / 'null' for objects. */
81
+ type: 'string' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'function' | 'object' | 'array' | 'null';
82
+ /** Size proxy: string length, array length, or object key count. */
83
+ size?: number;
84
+ /** First {@link READ_PREVIEW_LENGTH} chars — primitives and strings only. */
85
+ preview?: string;
86
+ }
87
+ /** Max characters captured in {@link ReadSummaryMarker.preview}. */
88
+ export declare const READ_PREVIEW_LENGTH = 80;
48
89
  /** Serialisable representation of a stage's state (for debugging / visualisation). */
49
90
  export type StageSnapshot = {
50
91
  id: string;
@@ -59,7 +100,10 @@ export type StageSnapshot = {
59
100
  isFork?: boolean;
60
101
  /** User-level writes made by this stage (pre-namespace keys → values). */
61
102
  stageWrites?: Record<string, unknown>;
62
- /** User-level reads made by this stage (pre-namespace keys → values at read time). */
103
+ /** User-level reads made by this stage (pre-namespace keys → values at read
104
+ * time). Shape depends on {@link ReadTrackingMode}: cloned values under
105
+ * `'full'` (default), {@link ReadSummaryMarker}s under `'summary'`, absent
106
+ * under `'off'`. */
63
107
  stageReads?: Record<string, unknown>;
64
108
  logs: Record<string, unknown>;
65
109
  errors: Record<string, unknown>;
@@ -61,7 +61,8 @@ import type { EmitRecorder } from './EmitRecorder.js';
61
61
  * Method names that appear on BOTH `ScopeRecorder` and `FlowRecorder` but with
62
62
  * different event payload types. For these, a `CombinedRecorder` declares
63
63
  * ONE handler that receives the union of both payloads — consumers
64
- * discriminate on `traversalContext`, which only control-flow events carry.
64
+ * discriminate with the exported `isFlowEvent()` helper (explicit `channel`
65
+ * discriminant, with a legacy pipelineId-absence fallback).
65
66
  */
66
67
  type SharedLifecycleOverlap = 'onError' | 'onPause' | 'onResume';
67
68
  /** Lifecycle hooks (not event-specific) that both interfaces share identically. */
@@ -81,8 +82,8 @@ type SharedLifecycle = 'id' | 'clear' | 'toSnapshot';
81
82
  * Both `ScopeRecorder` and `FlowRecorder` declare these with DIFFERENT payload
82
83
  * shapes. In a combined recorder, each such handler is called by BOTH
83
84
  * channels with its own variant. The parameter type is a union — consumers
84
- * can either handle both variants uniformly, or discriminate (control-flow
85
- * variants carry a `traversalContext` field that data-flow variants lack).
85
+ * can either handle both variants uniformly, or discriminate with
86
+ * `isFlowEvent()` (explicit `channel` discriminant stamped by the engine).
86
87
  *
87
88
  * ## Forward compatibility
88
89
  *
@@ -110,12 +111,17 @@ export type CombinedRecorder = Partial<Omit<ScopeRecorder, SharedLifecycleOverla
110
111
  *
111
112
  * ## How it discriminates
112
113
  *
113
- * Scope-channel events extend `RecorderContext`, which carries `pipelineId`.
114
- * Flow-channel events do not carry `pipelineId` (they carry an optional
115
- * `traversalContext` instead, but that field is optional and cannot be
116
- * relied on as a positive signal). We detect the flow variant by the
117
- * ABSENCE of `pipelineId` this is schema-stable as long as scope events
118
- * continue to extend `RecorderContext`.
114
+ * 1. **Explicit `channel` field first** (backlog B3): every engine-dispatched
115
+ * shared-method event is stamped `channel: 'flow'` (control-flow) or
116
+ * `channel: 'scope'` (data-flow) at construction. This is the positive,
117
+ * schema-robust signal it survives wrappers that add/strip fields.
118
+ * 2. **Legacy fallback** for unstamped events (consumer-fabricated tests,
119
+ * persisted traces from <9.2): scope-channel events extend
120
+ * `RecorderContext`, which carries `pipelineId`; flow-channel events do
121
+ * not. The flow variant is detected by the ABSENCE of `pipelineId` —
122
+ * schema-stable as long as scope events continue to extend
123
+ * `RecorderContext`, but fragile against field-stripping wrappers, which
124
+ * is why the explicit discriminant now exists.
119
125
  *
120
126
  * @example
121
127
  * ```ts
@@ -12,7 +12,7 @@
12
12
  import { EventLog } from '../memory/EventLog.js';
13
13
  import { SharedMemory } from '../memory/SharedMemory.js';
14
14
  import { StageContext } from '../memory/StageContext.js';
15
- import type { CommitBundle, StageSnapshot } from '../memory/types.js';
15
+ import type { CommitBundle, ReadTrackingMode, StageSnapshot } from '../memory/types.js';
16
16
  /** Snapshot of a single recorder's collected data. */
17
17
  export interface RecorderSnapshot {
18
18
  id: string;
@@ -63,6 +63,15 @@ export declare class ExecutionRuntime {
63
63
  * allocation, no functional difference in the snapshot.
64
64
  */
65
65
  enableRedactedMirror(): void;
66
+ /**
67
+ * Set the read-tracking policy (#14) on the root stage context. Descendant
68
+ * contexts inherit via `createNext`/`createChild`; subflow root contexts
69
+ * inherit from their parent-mount context via `SubflowExecutor`. Called by
70
+ * `FlowChartExecutor.createTraverser()` when the executor's policy is not
71
+ * the default `'full'` — including the resume path, where it is applied to
72
+ * the freshly-created continuation root.
73
+ */
74
+ useReadTracking(mode: ReadTrackingMode): void;
66
75
  /** Preserve the current rootStageContext for snapshots before changing it for resume. */
67
76
  preserveSnapshotRoot(): void;
68
77
  getPipelines(): string[];
@@ -22,6 +22,7 @@ import type { CombinedNarrativeEntry } from '../engine/narrative/narrativeTypes.
22
22
  import type { ManifestEntry } from '../engine/narrative/recorders/ManifestFlowRecorder.js';
23
23
  import type { FlowRecorder } from '../engine/narrative/types.js';
24
24
  import { type ExecutorResult, type RunOptions, type ScopeFactory, type SerializedPipelineStructure, type StageNode, type StreamHandlers, type SubflowResult } from '../engine/types.js';
25
+ import type { ReadTrackingMode } from '../memory/types.js';
25
26
  import type { FlowchartCheckpoint } from '../pause/types.js';
26
27
  import type { CombinedRecorder } from '../recorder/CombinedRecorder.js';
27
28
  import type { EmitRecorder } from '../recorder/EmitRecorder.js';
@@ -63,6 +64,19 @@ export interface FlowChartExecutorOptions<TScope = any> {
63
64
  initialContext?: unknown;
64
65
  /** Read-only input accessible via `scope.getArgs()` — never tracked or written. */
65
66
  readOnlyContext?: unknown;
67
+ /**
68
+ * Policy for `StageSnapshot.stageReads` (#14). Default `'full'` — every
69
+ * tracked read `structuredClone`s the value into the stage's read view
70
+ * (the historical behavior; what lens/agentfootprint snapshots show).
71
+ * `'summary'` records a cheap type/size/preview marker per read; `'off'`
72
+ * records nothing — zero per-read clone cost (reads of large values become
73
+ * ~free). Narrative and `ScopeRecorder.onRead` are identical in every mode.
74
+ * Caveat: under `'off'` a stage's snapshot is indistinguishable from one
75
+ * that read nothing — auditing consumers that need "did it read?" without
76
+ * the value cost should prefer `'summary'`.
77
+ * Equivalent to calling `executor.setReadTracking(mode)` before `run()`.
78
+ */
79
+ readTracking?: ReadTrackingMode;
66
80
  /**
67
81
  * Custom error classifier for throttling detection. Return `true` if the
68
82
  * error represents a rate-limit or backpressure condition (the executor will
@@ -142,6 +156,13 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
142
156
  * Must be called before run().
143
157
  */
144
158
  setRedactionPolicy(policy: RedactionPolicy): void;
159
+ /**
160
+ * Set the read-tracking policy for `StageSnapshot.stageReads` (#14).
161
+ * Must be called before run(). Equivalent to the `readTracking`
162
+ * constructor option — see {@link FlowChartExecutorOptions.readTracking}
163
+ * for the mode semantics ('full' default / 'summary' / 'off').
164
+ */
165
+ setReadTracking(mode: ReadTrackingMode): void;
145
166
  /**
146
167
  * Returns a compliance-friendly report of all redaction activity from the
147
168
  * most recent run. Never includes actual values.
@@ -243,9 +264,10 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
243
264
  * on clone failure we sanitize the diagnostic bags (non-cloneable values
244
265
  * become '[non-serializable: …]' markers — the live engine bags are never
245
266
  * touched) and retry. If the retry STILL fails, the violation is in
246
- * consumer-owned data (realistically `pauseData` — a function in shared
247
- * state already rejects at stage entry when TransactionBuffer clones the
248
- * context) and we throw a DESCRIPTIVE contract error naming the offending
267
+ * consumer-owned data (realistically `pauseData` — a function can never
268
+ * reach shared state in the first place: TransactionBuffer clones every
269
+ * written value at write time, so the offending write already rejected)
270
+ * and we throw a DESCRIPTIVE contract error naming the offending
249
271
  * checkpoint field(s). A naked DataCloneError never escapes.
250
272
  *
251
273
  * Subflow scope capture (`subflowStates`) survives ONLY on the signal — the
@@ -134,7 +134,30 @@ export declare class ScopeFacade {
134
134
  * the existing getValue() contract where user code always receives raw data. */
135
135
  getValueSilent(key?: string): unknown;
136
136
  getInitialValueFor(key: string): any;
137
- getValue(key?: string): any;
137
+ /**
138
+ * Tracked read of shared state.
139
+ *
140
+ * **Read values are BORROWED — do not mutate them.** Since the lazy buffer
141
+ * (#13), reads before the stage's first write return references INTO
142
+ * COMMITTED SHARED STATE, and reads after a write return references into
143
+ * the stage's private transaction-buffer working copy (the eager engine
144
+ * returned references into that working copy for ALL reads). Mutating a
145
+ * returned value in place would corrupt state without a commit record —
146
+ * write changes back via `setValue`/`updateValue` instead. TypedScope
147
+ * consumers are safe automatically: the proxy routes every mutation
148
+ * through `setValue`/`updateValue`/copy-on-write array ops.
149
+ *
150
+ * There is deliberately NO dev-mode freeze guard here: deep-freezing a
151
+ * buffer-served read would freeze the stage's own working copy and make a
152
+ * legitimate read-then-deep-write throw, and freezing a committed-state
153
+ * read mutates an object shared with every other consumer of the live
154
+ * state. See `src/lib/memory/README.md` ("Read values are borrowed").
155
+ *
156
+ * Recorder note: the `onRead` event below passes the SAME live reference
157
+ * (no clone) unless field-level redaction scrubs a copy — recorders must
158
+ * treat event values as read-only too.
159
+ */
160
+ getValue(key?: string): unknown;
138
161
  setValue(key: string, value: unknown, shouldRedact?: boolean, description?: string): void;
139
162
  updateValue(key: string, value: unknown, description?: string): void;
140
163
  deleteValue(key: string, description?: string): void;
@@ -38,15 +38,26 @@ export interface ErrorEvent extends RecorderContext {
38
38
  error: Error;
39
39
  operation: 'read' | 'write' | 'commit';
40
40
  key?: string;
41
+ /**
42
+ * Explicit channel discriminant — `'scope'` on every engine-dispatched
43
+ * event. `isFlowEvent()` checks it first (backlog B3); optional so
44
+ * consumer-fabricated events (tests, replays) remain type-valid and fall
45
+ * back to the legacy pipelineId-presence heuristic.
46
+ */
47
+ channel?: 'scope';
41
48
  }
42
49
  export interface StageEvent extends RecorderContext {
43
50
  duration?: number;
44
51
  }
45
52
  export interface PauseEvent extends RecorderContext {
46
53
  pauseData?: unknown;
54
+ /** Explicit channel discriminant — see {@link ErrorEvent.channel}. */
55
+ channel?: 'scope';
47
56
  }
48
57
  export interface ResumeEvent extends RecorderContext {
49
58
  hasInput: boolean;
59
+ /** Explicit channel discriminant — see {@link ErrorEvent.channel}. */
60
+ channel?: 'scope';
50
61
  }
51
62
  /**
52
63
  * Declarative redaction configuration — define once, applied everywhere.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "footprintjs",
3
- "version": "9.0.0",
3
+ "version": "9.2.0",
4
4
  "description": "Explainable backend flows — automatic causal traces, decision evidence, and MCP tool generation for AI agents",
5
5
  "license": "MIT",
6
6
  "author": "Sanjay Krishna Anbalagan",