foottrace 1.1.0 → 1.3.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 (110) hide show
  1. package/README.md +121 -2
  2. package/dist/cause.js +14 -0
  3. package/dist/esm/cause.d.ts +8 -0
  4. package/dist/esm/cause.js +5 -0
  5. package/dist/esm/index.d.ts +6 -0
  6. package/dist/esm/index.js +1 -0
  7. package/dist/esm/lib/cause/explain.d.ts +5 -0
  8. package/dist/esm/lib/cause/explain.js +114 -0
  9. package/dist/esm/lib/cause/queryMemo.d.ts +18 -0
  10. package/dist/esm/lib/cause/queryMemo.js +46 -0
  11. package/dist/esm/lib/cause/ranking.d.ts +6 -0
  12. package/dist/esm/lib/cause/ranking.js +10 -0
  13. package/dist/esm/lib/cause/recordAdapter.d.ts +9 -0
  14. package/dist/esm/lib/cause/recordAdapter.js +42 -0
  15. package/dist/esm/lib/cause/recordChildren.d.ts +4 -0
  16. package/dist/esm/lib/cause/recordChildren.js +78 -0
  17. package/dist/esm/lib/cause/recordControl.d.ts +13 -0
  18. package/dist/esm/lib/cause/recordControl.js +66 -0
  19. package/dist/esm/lib/cause/recordCoverage.d.ts +24 -0
  20. package/dist/esm/lib/cause/recordCoverage.js +87 -0
  21. package/dist/esm/lib/cause/recordIdentity.d.ts +9 -0
  22. package/dist/esm/lib/cause/recordIdentity.js +67 -0
  23. package/dist/esm/lib/cause/recordInspection.d.ts +4 -0
  24. package/dist/esm/lib/cause/recordInspection.js +86 -0
  25. package/dist/esm/lib/cause/recordLoading.d.ts +7 -0
  26. package/dist/esm/lib/cause/recordLoading.js +116 -0
  27. package/dist/esm/lib/cause/recordQuery.d.ts +9 -0
  28. package/dist/esm/lib/cause/recordQuery.js +22 -0
  29. package/dist/esm/lib/cause/recordScope.d.ts +8 -0
  30. package/dist/esm/lib/cause/recordScope.js +60 -0
  31. package/dist/esm/lib/cause/recordTypes.d.ts +67 -0
  32. package/dist/esm/lib/cause/recordTypes.js +1 -0
  33. package/dist/esm/lib/cause/recordValidation.d.ts +33 -0
  34. package/dist/esm/lib/cause/recordValidation.js +198 -0
  35. package/dist/esm/lib/cause/session.d.ts +30 -0
  36. package/dist/esm/lib/cause/session.js +164 -0
  37. package/dist/esm/lib/cause/storedRecordAdapter.d.ts +11 -0
  38. package/dist/esm/lib/cause/storedRecordAdapter.js +63 -0
  39. package/dist/esm/lib/cause/strategies.d.ts +3 -0
  40. package/dist/esm/lib/cause/strategies.js +21 -0
  41. package/dist/esm/lib/cause/types.d.ts +91 -0
  42. package/dist/esm/lib/cause/types.js +1 -0
  43. package/dist/esm/lib/cause/validation.d.ts +24 -0
  44. package/dist/esm/lib/cause/validation.js +117 -0
  45. package/dist/esm/lib/cause/walk.d.ts +12 -0
  46. package/dist/esm/lib/cause/walk.js +174 -0
  47. package/dist/esm/lib/evidence/admission.d.ts +31 -0
  48. package/dist/esm/lib/evidence/admission.js +72 -0
  49. package/dist/esm/lib/evidence/codes.d.ts +33 -0
  50. package/dist/esm/lib/evidence/codes.js +28 -0
  51. package/dist/esm/lib/evidence/requirementTypes.d.ts +46 -0
  52. package/dist/esm/lib/evidence/requirementTypes.js +1 -0
  53. package/dist/esm/lib/evidence/requirements.d.ts +24 -0
  54. package/dist/esm/lib/evidence/requirements.js +185 -0
  55. package/dist/esm/lib/memory/types.d.ts +4 -1
  56. package/dist/esm/lib/memory/types.js +1 -1
  57. package/dist/index.js +5 -1
  58. package/dist/lib/cause/explain.js +119 -0
  59. package/dist/lib/cause/queryMemo.js +52 -0
  60. package/dist/lib/cause/ranking.js +14 -0
  61. package/dist/lib/cause/recordAdapter.js +48 -0
  62. package/dist/lib/cause/recordChildren.js +82 -0
  63. package/dist/lib/cause/recordControl.js +74 -0
  64. package/dist/lib/cause/recordCoverage.js +99 -0
  65. package/dist/lib/cause/recordIdentity.js +73 -0
  66. package/dist/lib/cause/recordInspection.js +90 -0
  67. package/dist/lib/cause/recordLoading.js +121 -0
  68. package/dist/lib/cause/recordQuery.js +27 -0
  69. package/dist/lib/cause/recordScope.js +66 -0
  70. package/dist/lib/cause/recordTypes.js +2 -0
  71. package/dist/lib/cause/recordValidation.js +213 -0
  72. package/dist/lib/cause/session.js +168 -0
  73. package/dist/lib/cause/storedRecordAdapter.js +67 -0
  74. package/dist/lib/cause/strategies.js +26 -0
  75. package/dist/lib/cause/types.js +2 -0
  76. package/dist/lib/cause/validation.js +133 -0
  77. package/dist/lib/cause/walk.js +180 -0
  78. package/dist/lib/evidence/admission.js +80 -0
  79. package/dist/lib/evidence/codes.js +33 -0
  80. package/dist/lib/evidence/requirementTypes.js +2 -0
  81. package/dist/lib/evidence/requirements.js +190 -0
  82. package/dist/lib/memory/types.js +1 -1
  83. package/dist/types/cause.d.ts +8 -0
  84. package/dist/types/index.d.ts +6 -0
  85. package/dist/types/lib/cause/explain.d.ts +5 -0
  86. package/dist/types/lib/cause/queryMemo.d.ts +18 -0
  87. package/dist/types/lib/cause/ranking.d.ts +6 -0
  88. package/dist/types/lib/cause/recordAdapter.d.ts +9 -0
  89. package/dist/types/lib/cause/recordChildren.d.ts +4 -0
  90. package/dist/types/lib/cause/recordControl.d.ts +13 -0
  91. package/dist/types/lib/cause/recordCoverage.d.ts +24 -0
  92. package/dist/types/lib/cause/recordIdentity.d.ts +9 -0
  93. package/dist/types/lib/cause/recordInspection.d.ts +4 -0
  94. package/dist/types/lib/cause/recordLoading.d.ts +7 -0
  95. package/dist/types/lib/cause/recordQuery.d.ts +9 -0
  96. package/dist/types/lib/cause/recordScope.d.ts +8 -0
  97. package/dist/types/lib/cause/recordTypes.d.ts +67 -0
  98. package/dist/types/lib/cause/recordValidation.d.ts +33 -0
  99. package/dist/types/lib/cause/session.d.ts +30 -0
  100. package/dist/types/lib/cause/storedRecordAdapter.d.ts +11 -0
  101. package/dist/types/lib/cause/strategies.d.ts +3 -0
  102. package/dist/types/lib/cause/types.d.ts +91 -0
  103. package/dist/types/lib/cause/validation.d.ts +24 -0
  104. package/dist/types/lib/cause/walk.d.ts +12 -0
  105. package/dist/types/lib/evidence/admission.d.ts +31 -0
  106. package/dist/types/lib/evidence/codes.d.ts +33 -0
  107. package/dist/types/lib/evidence/requirementTypes.d.ts +46 -0
  108. package/dist/types/lib/evidence/requirements.d.ts +24 -0
  109. package/dist/types/lib/memory/types.d.ts +4 -1
  110. package/package.json +67 -13
package/README.md CHANGED
@@ -6,15 +6,16 @@ This package extracts FootPrint's established record layer. It does not introduc
6
6
 
7
7
  Foottrace owns the record layer used by footprintjs 10. Older stored records remain readable without installing their original engine.
8
8
 
9
- ## Three entry points
9
+ ## Four entry points
10
10
 
11
11
  | Import | Responsibility |
12
12
  | --- | --- |
13
13
  | `foottrace` | Record types, runtime and attempt IDs, replay, time-travel cursors, causal and variable slices, honesty codes, the stored-record reader |
14
14
  | `foottrace/write` | `SharedMemory`, `EventLog`, `RecordFrame`, encoding and write types, writer-owned freezing, the record sink port and `memorySink` |
15
15
  | `foottrace/paths` | Record-path encoding/decoding, nested access and safe diagnostic writes |
16
+ | `foottrace/cause` (ESM) | Optional bounded attention, cause and impact queries; in-memory and lazily loaded stored-record adapters |
16
17
 
17
- Every declaration has one public entry point. There are no public deep imports or `/testing` entry point. FootPrint's engine, observer channels, recorders and stores remain in `footprintjs`.
18
+ Every declaration has one public entry point. Producer-facing `Attention` and `AttentionKind` are type-only root exports; the cause algorithms live only on `/cause`. Existing root, `/write` and `/paths` imports retain their ESM and CommonJS doors. There are no public deep imports or `/testing` entry point. FootPrint's engine, observer channels, recorders and stores remain in `footprintjs`.
18
19
 
19
20
  ## Write and replay
20
21
 
@@ -42,6 +43,31 @@ Writers record during execution. Readers query what was recorded; they do not re
42
43
 
43
44
  See the [record contract](docs/guides/record-contract.md), [time-travel contract](src/lib/time-travel/README.md), and [slice contract](src/lib/slice/README.md).
44
45
 
46
+ ## Evidence requirements — 1.3.0
47
+
48
+ `RequirementResult` and `readRequirementResults(unknown)` on the root door carry
49
+ one producer-issued requirement result per finding/window, the claims it
50
+ `affects`, explicit unresolved `RequirementReason` codes, check/evidence/attempt
51
+ references and available action references. Domain details are namespaced IDs;
52
+ raw subjects, payloads, credentials and executable actions do not enter this
53
+ contract. The bounded reader rejects malformed or oversized batches atomically
54
+ and returns detached frozen records. It works through the root ESM and CommonJS
55
+ doors without loading the optional cause reader.
56
+
57
+ `RequirementReason` is the twelve-code domain-evidence subset of the single
58
+ `GapCode` registry. Result and check reasons refuse trace-walk codes such as
59
+ `cycle` and `node-limit`; generic cause gaps and typed admission errors retain
60
+ their broader vocabulary.
61
+
62
+ Structural admission never authenticates a producer or establishes that a
63
+ `satisfied` assertion is true. The host verifies current query, run, caller scope,
64
+ artifact provenance and domain validity before rendering each claim. An unresolved
65
+ requirement blocks only its affected conclusions; independently supported facts
66
+ can remain. Valid-time and learned-time references are separate, and missing
67
+ validity is unknown. Action references resolve through a host-owned closed
68
+ registry, with permissions checked again at execution. See the [contract and
69
+ limits](src/lib/evidence/README.md) and [reference-only example](examples/post-execution/time-travel/08-evidence-requirements.ts).
70
+
45
71
  ## A record that survives the run
46
72
 
47
73
  A log can also hand every entry to a sink **as it lands**, so a run that is killed still leaves its record behind. The sink sees four entries — `opened` (the base), `commit` (each bundle, after it is frozen), `note` (anything the writer wants kept beside the log, as it happens) and `closed` (how the log ended) — and `memorySink` is the reference adapter. Write your own for a file, a bucket or a table by implementing those four methods.
@@ -111,3 +137,96 @@ No engine is installed in this package's test environment. Engine integration an
111
137
  Record bytes are part of the versioning promise: refactors never re-pin; a named law fix requires a documented minor release; any other byte change requires a major. See the [fixture policy](test/fixtures/README.md).
112
138
 
113
139
  MIT license. Contact: sanjay@footprintjs.dev.
140
+
141
+
142
+ ## Cause and impact — 1.2.0
143
+
144
+ `foottrace/cause` reads recorded dependencies; it never reruns business code, calls a model, or accesses storage by itself. An application supplies a graph or a stored-record host. `explain(graph, nodeId)` walks the selected node's recorded data and control dependencies, identifies the earliest marked ancestors in that dependency order, and walks forward to report impact. Independent candidates remain `alternatives`; timestamps and ID order never break a causal tie. A missing or cut backward dependency withholds `rootCause`; missing evidence in either direction returns `confidence: 'incomplete'` with fixed gap codes. Confidence describes evidence coverage, not proof of a provider's internal failure.
145
+
146
+ **For precise per-write cause queries, enable `writeProvenance: 'reads-prefix'` when recording the flow.** Step-level data edges can add false ancestors; the record adapter marks that fallback `conservative`, so those queries return incomplete evidence and alternatives rather than a definite root. This setting cannot restore provenance in old records and does not guarantee a root: complete recorded read/control and attention coverage, one supported candidate and no backward gaps are still required. Other graph adapters can provide their own precise dependency evidence.
147
+
148
+ **Module compatibility:** `/cause` has an `import` export condition only. CommonJS consumers must use native `import('foottrace/cause')`; `require('foottrace/cause')` is not exported, even on Node versions that support requiring ESM. Preserve native `import()` in the compiled CommonJS loader: compilers that rewrite it to `require()` need a dedicated import-preserving bridge. Existing root, `/write` and `/paths` CommonJS imports remain supported.
149
+
150
+ ```ts
151
+ import type { Attention } from 'foottrace';
152
+ import { attentionList, explain, type CauseGraph } from 'foottrace/cause';
153
+
154
+ // The host owns authentication, storage, graph identities and attention rules.
155
+ // Each method receives a cancellation signal and may load only the requested node.
156
+ declare const graph: CauseGraph;
157
+ declare const selectedNodeId: string;
158
+ const marked = await attentionList(graph, {
159
+ nodeIds: [selectedNodeId], maxNodes: 50, maxCalls: 100, timeoutMs: 500,
160
+ });
161
+ const finding = await explain(graph, selectedNodeId, {
162
+ maxNodes: 50, maxDepth: 10, maxEdges: 100, maxCalls: 200, timeoutMs: 500,
163
+ });
164
+ // Display finding.attention, rootCause or alternatives, path, impact and gaps.
165
+ // An LLM can phrase these validated findings without inventing a cause.
166
+ ```
167
+
168
+ Attention marks contain a fixed reason code and evidence references, never a raw error or state value. Every mark needs a proof reference except `not-collected`, which records absence. The application decides which business conditions deserve a mark. `error`, `unknown-effect`, `gave-up`, `timeout`, `retried`, `quality-drop`, `not-collected` and `outlier` are standard kinds; future bounded kind names remain readable. An absent mark does not prove a node was healthy. `largestDrop(scores)` and `mostAffected(scores)` return optional root strategies over host-supplied, already recorded numeric scores; equal best scores remain tied and missing scores become gaps. The default uses the earliest marked ancestor, with no scoring model. An optional `rankImpact(nodes, context)` may reorder the discovered descendants, but cannot add, omit or duplicate references; failed ranking preserves the unranked references and reports a gap.
169
+
170
+ The default earliest-ancestor rule does not restrict a custom `rankRoots`: that callback receives all supported marked nodes in the backward walk, including a marked selected node, and may select a subset containing a nearer ancestor. It cannot introduce foreign or duplicate identities. This differs from `rankImpact`, which must return an exact permutation of every discovered descendant. Name the chosen root strategy when presenting its findings; a custom choice is not the default earliest-ancestor conclusion.
171
+
172
+ Query defaults are 100 nodes, depth 20, 1,000 edges, 1,000 calls and one second. Hard ceilings are 10,000 nodes, depth 1,000, 100,000 edges/calls and 30 seconds. The same budget covers both directions and ranking. Callbacks are trusted executable code: asynchronous waiting is bounded and receives an abort signal, while synchronous callbacks must cooperate. A late synchronous result is rejected after it returns. Hosts must use non-secret node and evidence IDs; the reader does not classify arbitrary ID strings for personal information.
173
+
174
+ Async hosts must return ordinary current-realm native Promises with no own `constructor`; promise wrappers are trusted host control objects, while their resolved data is untrusted. Custom thenables, subclasses and constructor/species overrides are outside this contract and the host must handle their rejections itself. The reader never mutates a promise to observe it. Ordinary native rejections are consumed and become fixed gaps; synchronous data objects' `then` accessors are never invoked.
175
+
176
+ ### Per-write provenance recipe
177
+
178
+ For precise edges between individual writes, configure the producer's record encoding with `writeProvenance: 'reads-prefix'`. `RecordFrame` captures the reads that preceded each write. For an execution that reads `a`, writes `x`, then reads `b` and writes `y`, the `x` query uses its recorded read prefix; the `y` query uses the later prefix. Without per-write provenance, the reader must conservatively consider all reads of that stage for either value and report that fallback. Later reads cannot be retroactively assigned to an earlier write. An overwritten version is distinct from the newer value, so impact follows the selected write rather than every later use of the same key name.
179
+
180
+ ```ts
181
+ import { EventLog, RecordFrame, SharedMemory } from 'foottrace/write';
182
+ const state = new SharedMemory(undefined, { a: 1, b: 2 });
183
+ const log = new EventLog(state.getState());
184
+ const frame = new RecordFrame(state, log);
185
+ frame.useEncoding({ commitValues: 'full', writeProvenance: 'reads-prefix' });
186
+ frame.noteRead([], 'a');
187
+ frame.write(frame.at([], 'x'), 1, 'set');
188
+ frame.noteRead([], 'b');
189
+ frame.write(frame.at([], 'y'), 3, 'set');
190
+ frame.commit(() => ({ stage: 'Derive', stageId: 'derive', runtimeStageId: 'derive#0' }));
191
+ frame.release();
192
+ ```
193
+
194
+ A stored host can expose one authorized log at a time:
195
+
196
+ ```ts
197
+ import { createStoredRecordGraph, explain, type StoredRecordHost } from 'foottrace/cause';
198
+
199
+ // loadLog(address, { signal }) returns native entries for exactly that address,
200
+ // plus recorded read/control/attention maps when available. It checks access.
201
+ declare const host: StoredRecordHost;
202
+ const graph = createStoredRecordGraph(host, {
203
+ maxScopes: 8, maxCommits: 256, maxFields: 20_000, maxEdges: 64, loadTimeoutMs: 500,
204
+ });
205
+ const address = { logRunId: 'run-1', drillPath: [] };
206
+ const selected = graph.nodeId(address, { runtimeStageId: 'derive#0', commitIdx: 0, key: 'y' });
207
+ const finding = await explain(graph, selected, { timeoutMs: 750, maxNodes: 50 });
208
+ // Only the requested scope is loaded; a different subflow is opened on demand.
209
+ ```
210
+
211
+ The stored adapter loads a requested log through the host, reuses `readStoredRecord`, and retains incomplete-record and gap evidence. A graph instance caches admitted scope snapshots; create a new instance to refresh a record that is still being written. Bind each host/graph/cache to one authorization domain or authorize before every query: a cache hit does not call `loadLog` again. Never share a global graph whose loader consults a changing request identity. Subflow containment is a navigation relationship, not automatically a causal dependency. Scope, execution, write position and optional key qualify node identities. The host must authorize each storage scope; cross-service and cross-run causal stitching remain outside this candidate. Lazy loading is real only when the host loader fetches a requested scope: expanding a cursor over an already loaded snapshot is an in-memory drill.
212
+
213
+ ### Established algorithms
214
+
215
+ Foundations: [Weiser, *Program slicing* (1981)](https://dl.acm.org/doi/10.5555/800078.802557), [Korel and Laski, *Dynamic program slicing* (1988)](https://doi.org/10.1016/0020-0190(88)90054-3), [Agrawal and Horgan, *Dynamic program slicing* (1990)](https://doi.org/10.1145/93542.93576), [Ferrante, Ottenstein and Warren, *The program dependence graph and its use in optimization* (1987)](https://doi.org/10.1145/24039.24041), and [Buneman, Khanna and Tan, *Why and Where: A Characterization of Data Provenance* (2001)](https://www.pure.ed.ac.uk/ws/files/16509989/Why_and_Where_A_Characterization_of_Data_Provenance.pdf). These are the historical techniques; this reader applies them only to dependencies present in a recording.
216
+
217
+ | Query | Established name |
218
+ | --- | --- |
219
+ | Recorded ancestors influencing a value or node | **Backward dynamic program slicing** (Weiser, 1981; Korel and Laski, 1988; Agrawal and Horgan, 1990) |
220
+ | Recorded data plus decision/control edges | **Program dependence graph** (Ferrante, Ottenstein and Warren, 1987) |
221
+ | Recorded downstream effects | **Forward slicing**, or change-impact analysis |
222
+ | Why a value exists and where it came from | **Why- and where-provenance** (Buneman, Khanna and Tan, 2001) |
223
+ | Loading only queried graph nodes/scopes | **Demand-driven, or lazy, graph traversal** |
224
+ | Default candidate selection | **Root-cause localisation on a dependency DAG**, using the earliest anomalous ancestor |
225
+
226
+ These names describe the techniques, not a claim of complete language-level instrumentation. Findings are bounded by the dependencies the producer recorded. Cause queries share one deadline and node/edge/call budget across reverse traversal, ranking and forward impact; limits, cancellation and unavailable reads become gaps. Output carries references and fixed codes, never a serialized DAG or business payload. `/cause` is ESM-only and `sideEffects: false`; its unused-import browser bundle is tested to match a bundle without the import. The package has no runtime dependencies. The cause reader shipped in 1.2.0; later engine stamping, viewer integration and agent tools are separate changes.
227
+
228
+ Direct `graph.openScope(address, { signal })` discovery rejects with a fixed `cause: record unavailable` error and a gap `code` on failure; an unavailable scope is never reported as an empty successful log. Public `explain` and `attentionList` queries turn unavailable reads into bounded incomplete findings.
229
+
230
+ Record-adapter coverage is conservative: its shared scope gaps can withhold a cause when a local forward edge cap is reached, even if the visible backward path is covered. A withheld cause is unknown under those bounds; it is not proof that no cause exists.
231
+
232
+ Package verification retains strict automatic entrypoint discovery for every door except `/cause`, then checks only `/cause` with Are the Types Wrong’s `esm-only` profile. No resolution error is ignored globally; the regression test fixes that exception to this one ESM-only subpath.
package/dist/cause.js ADDED
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createStoredRecordGraph = exports.createRecordGraph = exports.mostAffected = exports.largestDrop = exports.attentionList = exports.explain = void 0;
4
+ /** Optional read-only cause and impact queries over recorded dependency graphs. */
5
+ var explain_js_1 = require("./lib/cause/explain.js");
6
+ Object.defineProperty(exports, "explain", { enumerable: true, get: function () { return explain_js_1.explain; } });
7
+ Object.defineProperty(exports, "attentionList", { enumerable: true, get: function () { return explain_js_1.attentionList; } });
8
+ var strategies_js_1 = require("./lib/cause/strategies.js");
9
+ Object.defineProperty(exports, "largestDrop", { enumerable: true, get: function () { return strategies_js_1.largestDrop; } });
10
+ Object.defineProperty(exports, "mostAffected", { enumerable: true, get: function () { return strategies_js_1.mostAffected; } });
11
+ var recordAdapter_js_1 = require("./lib/cause/recordAdapter.js");
12
+ Object.defineProperty(exports, "createRecordGraph", { enumerable: true, get: function () { return recordAdapter_js_1.createRecordGraph; } });
13
+ var storedRecordAdapter_js_1 = require("./lib/cause/storedRecordAdapter.js");
14
+ Object.defineProperty(exports, "createStoredRecordGraph", { enumerable: true, get: function () { return storedRecordAdapter_js_1.createStoredRecordGraph; } });
@@ -0,0 +1,8 @@
1
+ /** Optional read-only cause and impact queries over recorded dependency graphs. */
2
+ export { explain, attentionList } from './lib/cause/explain.js';
3
+ export { largestDrop, mostAffected } from './lib/cause/strategies.js';
4
+ export { createRecordGraph } from './lib/cause/recordAdapter.js';
5
+ export { createStoredRecordGraph } from './lib/cause/storedRecordAdapter.js';
6
+ export type { Adjacency, AttentionListOptions, AttentionListResult, AttentionReason, CauseGraph, CauseResult, Confidence, ExplainOptions, Gap, GapCode, GraphContext, GraphEdge, ImpactResult, ImpactStrategy, MarkedNode, MaybePromise, NodeId, RootStrategy, TraversedEdge, WalkLimits, } from './lib/cause/types.js';
7
+ export type { RecordScope, RecordGraphOptions, RecordCauseGraph, RecordNode } from './lib/cause/recordAdapter.js';
8
+ export type { StoredRecordScope, StoredRecordHost } from './lib/cause/storedRecordAdapter.js';
@@ -0,0 +1,5 @@
1
+ /** Optional read-only cause and impact queries over recorded dependency graphs. */
2
+ export { explain, attentionList } from './lib/cause/explain.js';
3
+ export { largestDrop, mostAffected } from './lib/cause/strategies.js';
4
+ export { createRecordGraph } from './lib/cause/recordAdapter.js';
5
+ export { createStoredRecordGraph } from './lib/cause/storedRecordAdapter.js';
@@ -28,3 +28,9 @@ export type { EmitSourcePosition, LogAddress } from './lib/memory/eventPosition.
28
28
  export { EXECUTION_DELIMITER, PATH_DELIMITER, isExecutionKey, joinPath, pathSegments as idPathSegments, stageIdOf, subflowSegmentsOf, } from './lib/ids/runtimeStageId.js';
29
29
  export { deepEqual } from './lib/memory/equality.js';
30
30
  export { serveRecord } from './lib/capture/freeze.js';
31
+ /** Record attention types are shared with producers; the optional cause reader has its own door. */
32
+ export type { Attention, AttentionKind } from './lib/cause/types.js';
33
+ /** Reference-only requirement records: structural admission never grants authority or verifies domain truth. */
34
+ export type { RequirementReason } from './lib/evidence/codes.js';
35
+ export type { RequirementCheck, RequirementCheckDisposition, RequirementResult, } from './lib/evidence/requirementTypes.js';
36
+ export { readRequirementResults, RequirementValidationError, REQUIREMENT_RESULT_LIMITS, } from './lib/evidence/requirements.js';
package/dist/esm/index.js CHANGED
@@ -11,3 +11,4 @@ export { CommitRangeIndex } from './lib/recorder/CommitRangeIndex.js';
11
11
  export { EXECUTION_DELIMITER, PATH_DELIMITER, isExecutionKey, joinPath, pathSegments as idPathSegments, stageIdOf, subflowSegmentsOf, } from './lib/ids/runtimeStageId.js';
12
12
  export { deepEqual } from './lib/memory/equality.js';
13
13
  export { serveRecord } from './lib/capture/freeze.js';
14
+ export { readRequirementResults, RequirementValidationError, REQUIREMENT_RESULT_LIMITS, } from './lib/evidence/requirements.js';
@@ -0,0 +1,5 @@
1
+ import type { AttentionListOptions, AttentionListResult, CauseGraph, CauseResult, ExplainOptions, NodeId } from './types.js';
2
+ /** Read-only deterministic cause localisation and impact. No inference from timestamps or structural ancestry. */
3
+ export declare function explain(graph: CauseGraph, nodeId: NodeId, options?: ExplainOptions): Promise<CauseResult>;
4
+ /** Discover only bounded host seeds; this is not a global scan of arbitrary storage. */
5
+ export declare function attentionList(graph: CauseGraph, options?: AttentionListOptions): Promise<AttentionListResult>;
@@ -0,0 +1,114 @@
1
+ import { Session } from './session.js';
2
+ import { frontier, pathTo, walk } from './walk.js';
3
+ import { gap, graphName, nodeIds, graphOwn, uniqueGaps } from './validation.js';
4
+ import { admitRanking } from './ranking.js';
5
+ /** Read-only deterministic cause localisation and impact. No inference from timestamps or structural ancestry. */
6
+ export async function explain(graph, nodeId, options = {}) {
7
+ graphName(nodeId);
8
+ const session = new Session(graph, options);
9
+ try {
10
+ const backward = await walk(session, [nodeId], 'parents');
11
+ const missing = [...backward.gaps];
12
+ let candidates = frontier(backward).map((item) => item.nodeId);
13
+ const rank = graphOwn(options, 'rankRoots');
14
+ if (rank !== undefined) {
15
+ if (typeof rank !== 'function')
16
+ throw new TypeError('CAUSE_CONFIGURATION_INVALID');
17
+ const eligible = backward.marked.filter((item) => item.attention.some((mark) => mark.kind !== 'not-collected'));
18
+ const selection = await session.invoke(undefined, () => rank(Object.freeze(eligible), session.context), (raw) => admitRanking(raw, new Set(eligible.map((item) => item.nodeId)), {
19
+ exactPermutation: false,
20
+ maximum: session.limits.maxNodes,
21
+ }));
22
+ if (selection.gap)
23
+ missing.push(selection.gap, gap('strategy-failed'));
24
+ else
25
+ candidates = [...(selection.value ?? [])];
26
+ }
27
+ const stopped = session.stopGap(nodeId);
28
+ if (stopped)
29
+ missing.push(stopped);
30
+ const causeComplete = missing.length === 0;
31
+ const rootCause = causeComplete && candidates.length === 1 ? candidates[0] : undefined;
32
+ const forward = await walk(session, [nodeId], 'children');
33
+ let impactNodes = Object.freeze(forward.nodes.filter((id) => id !== nodeId));
34
+ const impactGaps = [...forward.gaps], rankImpact = graphOwn(options, 'rankImpact');
35
+ if (typeof rankImpact === 'function') {
36
+ const selection = await session.invoke(undefined, () => rankImpact(impactNodes, session.context), (raw) => admitRanking(raw, new Set(impactNodes), {
37
+ exactPermutation: true,
38
+ maximum: session.limits.maxNodes,
39
+ }));
40
+ if (selection.gap)
41
+ impactGaps.push(selection.gap, gap('strategy-failed'));
42
+ else
43
+ impactNodes = selection.value ?? impactNodes;
44
+ }
45
+ const impact = Object.freeze({
46
+ nodes: impactNodes,
47
+ edges: forward.edges,
48
+ confidence: impactGaps.length ? 'incomplete' : 'complete',
49
+ gaps: uniqueGaps(impactGaps),
50
+ });
51
+ const allGaps = uniqueGaps([...missing, ...impactGaps]);
52
+ return Object.freeze({
53
+ nodeId,
54
+ attention: backward.marked.find((item) => item.nodeId === nodeId)?.attention ?? Object.freeze([]),
55
+ ...(rootCause ? { rootCause } : {}),
56
+ path: rootCause ? pathTo(backward.edges, nodeId, rootCause) : Object.freeze([]),
57
+ alternatives: Object.freeze(rootCause ? [] : candidates),
58
+ impact,
59
+ confidence: allGaps.length ? 'incomplete' : 'complete',
60
+ gaps: allGaps,
61
+ });
62
+ }
63
+ finally {
64
+ session.dispose();
65
+ }
66
+ }
67
+ /** Discover only bounded host seeds; this is not a global scan of arbitrary storage. */
68
+ export async function attentionList(graph, options = {}) {
69
+ const session = new Session(graph, options);
70
+ try {
71
+ let seeds = [], missing = [];
72
+ const requested = graphOwn(options, 'nodeIds');
73
+ const admitSeeds = (raw) => {
74
+ // Only inspect the bounded prefix; the unread tail is explicitly incomplete.
75
+ if (!Array.isArray(raw))
76
+ return nodeIds(raw, session.limits.maxNodes);
77
+ const length = graphOwn(raw, 'length');
78
+ if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0)
79
+ return nodeIds(raw, session.limits.maxNodes);
80
+ if (length > session.limits.maxNodes)
81
+ missing.push(gap('node-limit'));
82
+ const prefix = [];
83
+ for (let i = 0; i < Math.min(length, session.limits.maxNodes); i++)
84
+ prefix.push(graphOwn(raw, String(i)));
85
+ return nodeIds(prefix, session.limits.maxNodes);
86
+ };
87
+ if (requested !== undefined) {
88
+ const read = await session.invoke(undefined, () => requested, admitSeeds);
89
+ if (read.gap)
90
+ missing.push(read.gap);
91
+ else
92
+ seeds = read.value ?? [];
93
+ }
94
+ else if (session.hasRoots()) {
95
+ const read = await session.read('roots', undefined, admitSeeds);
96
+ if (read.gap)
97
+ missing.push(read.gap);
98
+ else
99
+ seeds = read.value ?? [];
100
+ }
101
+ else
102
+ missing.push(gap('not-collected'));
103
+ const result = await walk(session, seeds, 'children');
104
+ missing = [...missing, ...result.gaps];
105
+ return Object.freeze({
106
+ nodes: result.marked,
107
+ confidence: missing.length ? 'incomplete' : 'complete',
108
+ gaps: uniqueGaps(missing),
109
+ });
110
+ }
111
+ finally {
112
+ session.dispose();
113
+ }
114
+ }
@@ -0,0 +1,18 @@
1
+ import type { GraphContext } from './types.js';
2
+ /** Share bounded derived work within one query without retaining it in the graph's storage cache. */
3
+ declare class QueryMemo {
4
+ private readonly maximumEntries;
5
+ private readonly owners;
6
+ private entries;
7
+ private closed;
8
+ constructor(maximumEntries: number);
9
+ remember<T>(owner: object, key: unknown, create: () => T): T;
10
+ close(): void;
11
+ }
12
+ /** Reserve one resolution and one inspection per admitted node, shared across all adapter owners. */
13
+ export declare function beginQueryMemo(context: GraphContext, maximumNodes: number): void;
14
+ /** Direct graph calls have no managed query lifetime and deliberately remain uncached. */
15
+ export declare function queryMemo(context: GraphContext): QueryMemo | undefined;
16
+ /** Clear synchronously without aborting an otherwise successful query's public signal. */
17
+ export declare function endQueryMemo(context: GraphContext): void;
18
+ export {};
@@ -0,0 +1,46 @@
1
+ /** Share bounded derived work within one query without retaining it in the graph's storage cache. */
2
+ class QueryMemo {
3
+ maximumEntries;
4
+ owners = new Map();
5
+ entries = 0;
6
+ closed = false;
7
+ constructor(maximumEntries) {
8
+ this.maximumEntries = maximumEntries;
9
+ }
10
+ remember(owner, key, create) {
11
+ const previous = this.owners.get(owner);
12
+ if (previous?.has(key))
13
+ return previous.get(key);
14
+ if (this.closed || this.entries >= this.maximumEntries)
15
+ return create();
16
+ const value = create();
17
+ // Creation can finish after disposal or re-enter other readers; neither may refill a closed/full memo.
18
+ if (!this.closed && this.entries < this.maximumEntries) {
19
+ const bucket = this.owners.get(owner) ?? new Map();
20
+ if (!bucket.has(key))
21
+ this.entries++;
22
+ bucket.set(key, value);
23
+ this.owners.set(owner, bucket);
24
+ }
25
+ return value;
26
+ }
27
+ close() {
28
+ this.closed = true;
29
+ this.owners.clear();
30
+ this.entries = 0;
31
+ }
32
+ }
33
+ const queries = new WeakMap();
34
+ /** Reserve one resolution and one inspection per admitted node, shared across all adapter owners. */
35
+ export function beginQueryMemo(context, maximumNodes) {
36
+ queries.set(context, new QueryMemo(maximumNodes * 2));
37
+ }
38
+ /** Direct graph calls have no managed query lifetime and deliberately remain uncached. */
39
+ export function queryMemo(context) {
40
+ return queries.get(context);
41
+ }
42
+ /** Clear synchronously without aborting an otherwise successful query's public signal. */
43
+ export function endQueryMemo(context) {
44
+ queries.get(context)?.close();
45
+ queries.delete(context);
46
+ }
@@ -0,0 +1,6 @@
1
+ import type { NodeId } from './types.js';
2
+ /** Strategies may only select admitted nodes; impact ranking must preserve the full set. */
3
+ export declare function admitRanking(raw: unknown, allowed: ReadonlySet<NodeId>, options: {
4
+ readonly exactPermutation: boolean;
5
+ readonly maximum: number;
6
+ }): readonly NodeId[];
@@ -0,0 +1,10 @@
1
+ import { nodeIds } from './validation.js';
2
+ /** Strategies may only select admitted nodes; impact ranking must preserve the full set. */
3
+ export function admitRanking(raw, allowed, options) {
4
+ const ids = nodeIds(raw, options.maximum);
5
+ if ((options.exactPermutation && ids.length !== allowed.size) ||
6
+ new Set(ids).size !== ids.length ||
7
+ ids.some((id) => !allowed.has(id)))
8
+ throw new TypeError();
9
+ return ids;
10
+ }
@@ -0,0 +1,9 @@
1
+ import type { LogAddress } from '../memory/eventPosition.js';
2
+ import type { RecordCauseGraph, RecordGraphOptions, RecordLoaderResult, RecordScope } from './recordTypes.js';
3
+ import type { GraphContext } from './types.js';
4
+ export type { RecordCauseGraph, RecordGraphOptions, RecordLoaderResult, RecordNode, RecordScope, } from './recordTypes.js';
5
+ export { recordOptions } from './recordScope.js';
6
+ /** Common engine for memory and one-scope-at-a-time host storage. Loader functions are trusted executable code. */
7
+ export declare function createLazyRecordGraph(load: (address: LogAddress, context: GraphContext) => RecordLoaderResult | Promise<RecordLoaderResult>, options?: RecordGraphOptions): RecordCauseGraph;
8
+ /** A finished record already in memory; payload admission is lazy per requested scope. */
9
+ export declare function createRecordGraph(scopes: readonly RecordScope[], options?: RecordGraphOptions): RecordCauseGraph;
@@ -0,0 +1,42 @@
1
+ import { childrenForRecordNode } from './recordChildren.js';
2
+ import { attentionForRecordNode, gapForRecordFailure } from './recordCoverage.js';
3
+ import { nodeIdFor } from './recordIdentity.js';
4
+ import { createRecordScopeLoader, openRecordScope } from './recordLoading.js';
5
+ import { createRecordQuery, whenResolved } from './recordQuery.js';
6
+ import { recordOptions } from './recordScope.js';
7
+ import { addressKey, addressOf, recordList, recordOwn, RecordRefusal } from './recordValidation.js';
8
+ /** Give each failed port its own incomplete page rather than sharing a mutable edge array. */
9
+ const unavailableAdjacency = () => ({ edges: [], complete: false });
10
+ export { recordOptions } from './recordScope.js';
11
+ /** Common engine for memory and one-scope-at-a-time host storage. Loader functions are trusted executable code. */
12
+ export function createLazyRecordGraph(load, options = {}) {
13
+ const limits = recordOptions(options), loader = createRecordScopeLoader(load, limits);
14
+ const query = createRecordQuery(loader, limits.maxEdges);
15
+ return Object.freeze({
16
+ nodeId: (address, node) => nodeIdFor(address, node),
17
+ openScope: (address, context) => openRecordScope(address, context, loader),
18
+ async parents(nodeId, context) {
19
+ return whenResolved(await query.resolve(nodeId, context), (node) => query.inspect(node, context).adjacency, unavailableAdjacency);
20
+ },
21
+ async gaps(nodeId, context) {
22
+ return whenResolved(await query.resolve(nodeId, context), (node) => query.inspect(node, context).gaps, (failure) => gapForRecordFailure(failure.failure));
23
+ },
24
+ async attention(nodeId, context) {
25
+ return whenResolved(await query.resolve(nodeId, context), attentionForRecordNode, () => []);
26
+ },
27
+ async children(nodeId, context) {
28
+ return whenResolved(await query.resolve(nodeId, context), (node) => childrenForRecordNode(node, limits.maxEdges, query.inspect(node, context)), unavailableAdjacency);
29
+ },
30
+ });
31
+ }
32
+ /** A finished record already in memory; payload admission is lazy per requested scope. */
33
+ export function createRecordGraph(scopes, options = {}) {
34
+ const limits = recordOptions(options);
35
+ return createLazyRecordGraph((address) => {
36
+ for (const raw of recordList(scopes, limits.maxScopes)) {
37
+ if (addressKey(addressOf(recordOwn(raw, 'address'))) === addressKey(address))
38
+ return { scope: raw };
39
+ }
40
+ throw new RecordRefusal('unavailable');
41
+ }, limits);
42
+ }
@@ -0,0 +1,4 @@
1
+ import type { RecordInspection, ResolvedRecordNode } from './recordTypes.js';
2
+ import type { Adjacency } from './types.js';
3
+ /** Share parent-side coverage checks before appending data then control children in their established order. */
4
+ export declare function childrenForRecordNode(node: ResolvedRecordNode, maximumEdges: number, inspection: RecordInspection): Adjacency;
@@ -0,0 +1,78 @@
1
+ import { forwardSliceForKey } from '../slice/forwardSliceForKey.js';
2
+ import { appendControlChildren } from './recordControl.js';
3
+ import { allStageReadsCollected, writtenKeysForNode } from './recordCoverage.js';
4
+ import { nodeIdFor } from './recordIdentity.js';
5
+ import { readKeysForStage } from './recordScope.js';
6
+ /** Keep per-write and conservative fed edges distinct while retaining existing cap and deduplication order. */
7
+ function appendFedChildren(scope, root, maximumEdges, edges) {
8
+ let complete = true;
9
+ for (const edge of root.fedEdges) {
10
+ const child = edge.child;
11
+ if (child.commitIdx === undefined || !child.runtimeStageId) {
12
+ complete = false;
13
+ continue;
14
+ }
15
+ if (edges.length >= maximumEdges) {
16
+ complete = false;
17
+ break;
18
+ }
19
+ const nodeId = nodeIdFor(scope.address, {
20
+ runtimeStageId: child.runtimeStageId,
21
+ commitIdx: child.commitIdx,
22
+ key: child.key,
23
+ });
24
+ if (!edges.some((existing) => existing.nodeId === nodeId))
25
+ edges.push({ nodeId, kind: 'data', attribution: edge.basis, evidence: ['commit:' + child.commitIdx] });
26
+ if (edge.basis === 'stage')
27
+ complete = false;
28
+ }
29
+ return complete;
30
+ }
31
+ /** A consumer with no writes still needs a qualified node so its recorded control flow remains reachable. */
32
+ function appendReadOnlyChildren(scope, root, key, maximumEdges, edges) {
33
+ let complete = true;
34
+ for (const read of root.reads) {
35
+ if (scope.log[read.commitIdx]?.trace.length)
36
+ continue;
37
+ if (edges.length >= maximumEdges) {
38
+ complete = false;
39
+ break;
40
+ }
41
+ const nodeId = nodeIdFor(scope.address, { runtimeStageId: read.runtimeStageId, commitIdx: read.commitIdx, key }, true);
42
+ if (!edges.some((existing) => existing.nodeId === nodeId))
43
+ edges.push({ nodeId, kind: 'data', attribution: 'per-write', evidence: ['commit:' + read.commitIdx] });
44
+ }
45
+ return complete;
46
+ }
47
+ /** Query each written value at its original commit boundary instead of the value's latest writer. */
48
+ function appendDataChildren(scope, identity, commitIndex, maximumEdges, edges) {
49
+ let complete = true;
50
+ const lookup = (runtimeStageId) => readKeysForStage(scope, runtimeStageId);
51
+ for (const key of writtenKeysForNode(scope.log[commitIndex], identity)) {
52
+ const forward = forwardSliceForKey(scope.log, key, lookup, {
53
+ before: commitIndex + 1,
54
+ maxDepth: 1,
55
+ maxNodes: maximumEdges + 1,
56
+ });
57
+ if (forward.root?.truncated?.byNodes)
58
+ complete = false;
59
+ if (forward.root?.commitIdx !== commitIndex) {
60
+ complete = false;
61
+ continue;
62
+ }
63
+ complete = appendFedChildren(scope, forward.root, maximumEdges, edges) && complete;
64
+ complete = appendReadOnlyChildren(scope, forward.root, key, maximumEdges, edges) && complete;
65
+ }
66
+ return complete;
67
+ }
68
+ /** Share parent-side coverage checks before appending data then control children in their established order. */
69
+ export function childrenForRecordNode(node, maximumEdges, inspection) {
70
+ const { scope, identity, commitIndex } = node;
71
+ const edges = [];
72
+ let complete = inspection.gaps.length === 0;
73
+ if (!allStageReadsCollected(scope))
74
+ complete = false;
75
+ complete = appendDataChildren(scope, identity, commitIndex, maximumEdges, edges) && complete;
76
+ complete = appendControlChildren(scope, identity, commitIndex, maximumEdges, edges) && complete;
77
+ return { edges, complete };
78
+ }
@@ -0,0 +1,13 @@
1
+ import type { ControlDependency } from '../memory/backtrack.js';
2
+ import type { AddRecordGap, PreparedRecordScope, RecordIdentity } from './recordTypes.js';
3
+ import type { GraphEdge } from './types.js';
4
+ /** Only an execution or captured read may govern control flow; a written key alone cannot. */
5
+ export declare function controlChildren(scope: PreparedRecordScope, identity: RecordIdentity): Iterable<readonly [string, ControlDependency]>;
6
+ /** Canonical slicing receives only control parents that existed before the dependent execution. */
7
+ export declare function controlParentLookup(scope: PreparedRecordScope): ((runtimeStageId: string) => ControlDependency | undefined) | undefined;
8
+ /** Keep missing or future deciders visible rather than silently dropping their recorded dependency. */
9
+ export declare function collectControlParentCoverage(scope: PreparedRecordScope, identity: RecordIdentity, firstCommitIndex: number, addGap: AddRecordGap): void;
10
+ /** Include control fan-out in the same immediate-neighbor cap used for data adjacency. */
11
+ export declare function collectControlChildCoverage(scope: PreparedRecordScope, identity: RecordIdentity, firstCommitIndex: number, neighbors: Set<string>, addGap: AddRecordGap): void;
12
+ /** Preserve recorded control order and coverage when a dependent execution is absent or capped. */
13
+ export declare function appendControlChildren(scope: PreparedRecordScope, identity: RecordIdentity, commitIndex: number, maximumEdges: number, edges: GraphEdge[]): boolean;
@@ -0,0 +1,66 @@
1
+ import { nodeIdFor } from './recordIdentity.js';
2
+ /** Only an execution or captured read may govern control flow; a written key alone cannot. */
3
+ export function controlChildren(scope, identity) {
4
+ // A read-only execution may govern later work; a particular written key is not itself a decision.
5
+ return identity.key === undefined || identity.read ? scope.control ?? [] : [];
6
+ }
7
+ /** Canonical slicing receives only control parents that existed before the dependent execution. */
8
+ export function controlParentLookup(scope) {
9
+ return scope.control === undefined
10
+ ? undefined
11
+ : (runtimeStageId) => {
12
+ const dependency = scope.control.get(runtimeStageId);
13
+ if (!dependency)
14
+ return undefined;
15
+ const parentIndex = scope.log.findIndex((bundle) => bundle.runtimeStageId === dependency.deciderId), childIndex = scope.log.findIndex((bundle) => bundle.runtimeStageId === runtimeStageId);
16
+ return parentIndex >= 0 && parentIndex < childIndex ? dependency : undefined;
17
+ };
18
+ }
19
+ /** Keep missing or future deciders visible rather than silently dropping their recorded dependency. */
20
+ export function collectControlParentCoverage(scope, identity, firstCommitIndex, addGap) {
21
+ const dependency = scope.control?.get(identity.runtimeStageId);
22
+ if (!dependency)
23
+ return;
24
+ const parentIndex = scope.log.findIndex((bundle) => bundle.runtimeStageId === dependency.deciderId);
25
+ if (parentIndex < 0)
26
+ addGap('scope-boundary');
27
+ else if (parentIndex >= firstCommitIndex)
28
+ addGap('invalid-data');
29
+ }
30
+ /** Include control fan-out in the same immediate-neighbor cap used for data adjacency. */
31
+ export function collectControlChildCoverage(scope, identity, firstCommitIndex, neighbors, addGap) {
32
+ for (const [childStageId, dependency] of controlChildren(scope, identity))
33
+ if (dependency.deciderId === identity.runtimeStageId) {
34
+ const childIndex = scope.log.findIndex((bundle) => bundle.runtimeStageId === childStageId);
35
+ if (childIndex < 0)
36
+ addGap('scope-boundary');
37
+ else if (childIndex <= firstCommitIndex)
38
+ addGap('invalid-data');
39
+ else
40
+ neighbors.add(JSON.stringify(['control', childStageId]));
41
+ }
42
+ }
43
+ /** Preserve recorded control order and coverage when a dependent execution is absent or capped. */
44
+ export function appendControlChildren(scope, identity, commitIndex, maximumEdges, edges) {
45
+ let complete = true;
46
+ for (const [childStageId, dependency] of controlChildren(scope, identity)) {
47
+ if (dependency.deciderId !== identity.runtimeStageId)
48
+ continue;
49
+ const childIndex = scope.log.findIndex((bundle) => bundle.runtimeStageId === childStageId);
50
+ if (childIndex <= commitIndex) {
51
+ complete = false;
52
+ continue;
53
+ }
54
+ if (edges.length >= maximumEdges) {
55
+ complete = false;
56
+ break;
57
+ }
58
+ edges.push({
59
+ nodeId: nodeIdFor(scope.address, { runtimeStageId: childStageId, commitIdx: childIndex }),
60
+ kind: 'control',
61
+ attribution: 'recorded-control',
62
+ evidence: ['commit:' + childIndex],
63
+ });
64
+ }
65
+ return complete;
66
+ }