@clear-capabilities/agentic-security-scanner 0.183.1 → 0.185.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 (85) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +88 -0
  3. package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/2923.index.js +17 -9
  8. package/dist/9382.index.js +23 -2
  9. package/dist/9579.index.js +11 -4
  10. package/dist/agentic-security.mjs +15 -15
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/frontend/src/shell.js +12 -2
  13. package/package.json +27 -9
  14. package/src/context-intelligence/controls.js +15 -0
  15. package/src/context-intelligence/reachability.js +8 -2
  16. package/src/dataflow/CLAUDE.md +43 -1
  17. package/src/dataflow/backward.js +42 -7
  18. package/src/dataflow/cross-service-taint.js +83 -1
  19. package/src/dataflow/demand-query.js +929 -0
  20. package/src/dataflow/dep-fingerprint.js +718 -0
  21. package/src/dataflow/engine.js +48 -6
  22. package/src/dataflow/ifds-precise.js +22 -0
  23. package/src/dataflow/incremental-replay.js +118 -0
  24. package/src/dataflow/incremental-session.js +257 -0
  25. package/src/dataflow/incremental.js +237 -35
  26. package/src/dataflow/index.js +25 -94
  27. package/src/dataflow/summaries.js +72 -6
  28. package/src/dataflow/tabulation.js +80 -24
  29. package/src/discovery/hunter.js +6 -2
  30. package/src/discovery/index.js +5 -2
  31. package/src/engine.js +63 -3
  32. package/src/graph/CLAUDE.md +31 -0
  33. package/src/graph/adapters/artifact.js +81 -0
  34. package/src/graph/adapters/crossrepo.js +75 -0
  35. package/src/graph/adapters/dataflow.js +76 -0
  36. package/src/graph/adapters/provenance.js +92 -0
  37. package/src/graph/adapters/semantic.js +89 -0
  38. package/src/graph/crosswalk-builders.js +152 -0
  39. package/src/graph/crosswalk.js +146 -0
  40. package/src/graph/index.js +15 -0
  41. package/src/graph/observation.js +57 -0
  42. package/src/graph/refs.js +105 -0
  43. package/src/graph/registry.js +129 -0
  44. package/src/graph/relations.js +81 -0
  45. package/src/graph/scc.js +241 -0
  46. package/src/graph/view.js +254 -0
  47. package/src/language/haskell-disclosure.js +13 -1
  48. package/src/lineage/CLAUDE.md +71 -8
  49. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  50. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  51. package/src/lineage/boundary-contract.js +273 -0
  52. package/src/lineage/completion.js +205 -0
  53. package/src/lineage/continuation.js +140 -0
  54. package/src/lineage/control-credit.js +313 -0
  55. package/src/lineage/correlation.js +199 -0
  56. package/src/lineage/coverage.js +8 -1
  57. package/src/lineage/cross-repo-link.js +22 -0
  58. package/src/lineage/cut-planner.js +548 -0
  59. package/src/lineage/driver.js +219 -66
  60. package/src/lineage/engine.js +166 -21
  61. package/src/lineage/export-json.js +5 -0
  62. package/src/lineage/graph-builder.js +84 -7
  63. package/src/lineage/haskell-view.js +59 -0
  64. package/src/lineage/index.js +3 -0
  65. package/src/lineage/nix-view.js +35 -0
  66. package/src/lineage/observation-adapters.js +17 -9
  67. package/src/lineage/owned-build.js +77 -0
  68. package/src/lineage/owned-worker-entry.js +22 -0
  69. package/src/lineage/owned-worker.js +95 -0
  70. package/src/lineage/path-evidence.js +123 -0
  71. package/src/lineage/path-predicates.js +529 -0
  72. package/src/lineage/path-query.js +189 -20
  73. package/src/lineage/path-store.js +191 -17
  74. package/src/lineage/scc-solver.js +186 -0
  75. package/src/lineage/smt-orchestrator.js +138 -0
  76. package/src/lineage/source-seeding.js +36 -2
  77. package/src/lineage/summaries.js +66 -4
  78. package/src/lineage/value-versions.js +130 -0
  79. package/src/lineage/witness-selection.js +95 -0
  80. package/src/pipeline/scan-health.js +19 -0
  81. package/src/posture/CLAUDE.md +13 -0
  82. package/src/posture/evidence-bundle.js +1 -1
  83. package/src/posture/witness-bundle.js +573 -0
  84. package/src/posture/witness-runtime.js +276 -0
  85. package/src/report/index.js +2 -0
@@ -23,6 +23,11 @@
23
23
 
24
24
  import { pathId } from './ids.js';
25
25
 
26
+ // Bound on the call-context stack a version 2 walk tracks. Kept here, not
27
+ // imported from correlation.js, because this module's only import is ids.js
28
+ // (§15.1); correlation.test.js pins the two values equal.
29
+ export const DEFAULT_MAX_CALL_DEPTH = 8;
30
+
26
31
  /**
27
32
  * All `opts`-overridable. Two-plus orders of magnitude above what every
28
33
  * fixture measured in the design task's PoC needs (§15.11's largest row
@@ -35,10 +40,38 @@ export const DEFAULTS = {
35
40
  maxPaths: 32,
36
41
  maxPathsPerTerminal: 8,
37
42
  maxCandidatePaths: 256,
43
+ // R07.5: total hops copied into candidate paths. Each candidate costs its
44
+ // length, so this bounds output memory independently of the candidate count.
45
+ maxMaterializedHops: 1000000,
38
46
  maxExpansions: 10000,
39
47
  maxDepth: 64,
40
48
  };
41
49
 
50
+ // DAG-07 R07.4: documented upper bounds. A budget must be a finite,
51
+ // nonnegative integer no larger than its bound; anything else (NaN,
52
+ // Infinity, negative, fractional, a string) makes the query `invalid`
53
+ // rather than silently running unbounded or being coerced.
54
+ export const BUDGET_UPPER_BOUNDS = {
55
+ maxPaths: 1e7,
56
+ maxPathsPerTerminal: 1e7,
57
+ maxCandidatePaths: 1e9,
58
+ maxMaterializedHops: 1e9,
59
+ maxExpansions: 1e9,
60
+ maxDepth: 1e6,
61
+ };
62
+
63
+ export function validateBudget(opts) {
64
+ const errors = [];
65
+ for (const [k, v] of Object.entries(opts ?? {})) {
66
+ if (!(k in BUDGET_UPPER_BOUNDS)) continue;
67
+ if (v === undefined) continue;
68
+ if (!Number.isInteger(v) || v < 0 || v > BUDGET_UPPER_BOUNDS[k]) {
69
+ errors.push(`${k} must be an integer in [0, ${BUDGET_UPPER_BOUNDS[k]}], got ${typeof v === 'number' ? String(v) : typeof v}`);
70
+ }
71
+ }
72
+ return errors;
73
+ }
74
+
42
75
  // §15.4's terminal-reason vocabulary. Only 'origin' means "this really is
43
76
  // where the recorded flow starts" (`complete: true`). 'expansion-budget'
44
77
  // and 'candidate-cap' are deliberately NOT terminal reasons — see the loop
@@ -48,6 +81,9 @@ const TERMINAL_ORIGIN = 'origin';
48
81
  const TERMINAL_INCOMPLETE = 'incomplete-record';
49
82
  const TERMINAL_CYCLE = 'cycle';
50
83
  const TERMINAL_DEPTH = 'depth-limit';
84
+ // Version 2 only: every incoming edge of the node would have returned to a
85
+ // call site other than the one this walk entered through.
86
+ const TERMINAL_UNBALANCED = 'unbalanced-call';
51
87
 
52
88
  /**
53
89
  * §15.9's stand-in for Sub-project D's source/sink registry, which does not
@@ -89,6 +125,13 @@ function hopOf(edge) {
89
125
  annotations: edge.annotations,
90
126
  originated: edge.originated,
91
127
  truncated: edge.truncated,
128
+ // Version 2 edges only; a version 1 hop has exactly the fields it had.
129
+ ...(edge.correlationVersion ? {
130
+ correlationVersion: edge.correlationVersion,
131
+ correlation: edge.correlation,
132
+ callBoundary: edge.callBoundary,
133
+ invocationId: edge.invocationId,
134
+ } : null),
92
135
  };
93
136
  }
94
137
 
@@ -98,7 +141,7 @@ function hopOf(edge) {
98
141
  * a flow source -> sink, so the arrays are reversed here, once, at the
99
142
  * point a candidate is emitted.
100
143
  */
101
- function materialize(store, nodesRev, edgesRev, terminalReason) {
144
+ function materialize(store, nodesRev, edgesRev, terminalReason, callContext = null) {
102
145
  const nodeIds = [...nodesRev].reverse();
103
146
  const edges = [...edgesRev].reverse();
104
147
  const hops = edges.map((e) => hopOf(e));
@@ -138,6 +181,9 @@ function materialize(store, nodesRev, edgesRev, terminalReason) {
138
181
  lossHopCount,
139
182
  ambiguousHopCount,
140
183
  analysisTruncated,
184
+ // Correlation version 2 only (absent, not null, in version 1): whether the
185
+ // walk had to abstract the call context to stay within its bound.
186
+ ...(callContext ? { callContext } : null),
141
187
  // §15.7's diversity signature. `transformation` and `protection` are
142
188
  // DELIBERATELY absent — Sub-project D / Milestone 2 own them; using
143
189
  // today's widen/loss reasons as a stand-in for either would be
@@ -187,6 +233,7 @@ export function comparePaths(a, b) {
187
233
  * (§18.4's single most load-bearing constraint).
188
234
  */
189
235
  export function reconstructPaths(store, startNodeId, opts = {}) {
236
+ const budgetErrors = validateBudget(opts);
190
237
  const budget = { ...DEFAULTS, ...opts };
191
238
  const startNode = store.getNode(startNodeId);
192
239
  const truncationReasons = new Set();
@@ -206,36 +253,125 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
206
253
  cyclesClipped: 0,
207
254
  terminals: [],
208
255
  analysisTruncated: false,
256
+ status: 'complete',
257
+ negativeClaimAllowed: false,
209
258
  budget: { ...budget, expansionsUsed: 0 },
210
259
  };
260
+ if (budgetErrors.length) {
261
+ // Invalid input is neither "no path" nor a partial answer.
262
+ result.status = 'invalid';
263
+ result.budgetErrors = budgetErrors;
264
+ result.budget = { ...DEFAULTS, expansionsUsed: 0 };
265
+ return result;
266
+ }
211
267
  // An id that names no node is NOT "no path exists" — it is "you asked
212
268
  // about something that is not in this store". §15.4 keeps the two apart,
213
269
  // because collapsing them is §18.4's failure mode wearing a different hat.
214
- if (!startNode) return result;
270
+ if (!startNode) {
271
+ result.status = 'partial';
272
+ // A store that refused input may simply not contain the node asked for.
273
+ if (typeof store.limitStatus === 'function' && store.limitStatus().hit) {
274
+ result.truncated = true;
275
+ result.truncationReasons = ['store-limit'];
276
+ }
277
+ return result;
278
+ }
215
279
 
216
280
  const orphaned = new Set(store.diagnostics().orphanedPeerSources.map((o) => o.nodeId));
217
281
 
282
+ // Correlation version 2 (DAG-03, R03.5): a precise witness is a REALIZABLE
283
+ // path, so a return edge may only be followed back to the call it returned
284
+ // to. `path.calls` is the stack of invocations the backward walk has
285
+ // entered (a return edge pushes its invocation, the matching call edge
286
+ // pops it). Past `maxCallDepth` the stack is abstracted: further entries
287
+ // are only counted (`overflow`), a call edge met while any are pending is
288
+ // accepted without a match, and the path says so in `callContext`.
289
+ const v2 = typeof store.correlationVersion === 'function' && store.correlationVersion() === 2;
290
+ const maxCallDepth = Number.isInteger(opts.maxCallDepth) && opts.maxCallDepth >= 1 ? opts.maxCallDepth : DEFAULT_MAX_CALL_DEPTH;
291
+ const balancing = { enabled: v2, maxCallDepth, unbalancedPruned: 0, prunedEdgeIds: [], contextWidenedPaths: 0 };
292
+ if (v2) {
293
+ result.correlationVersion = 2;
294
+ result.budget.maxCallDepth = maxCallDepth;
295
+ }
296
+
218
297
  const candidates = [];
219
298
  let expansions = 0;
220
- const stack = [{
221
- nodeId: startNodeId,
222
- nodesRev: [startNodeId],
223
- edgesRev: [],
224
- onPath: new Set([startNodeId]),
225
- }];
299
+ let materializedHops = 0;
300
+ const materializeCounted = (n, e, reason) => { materializedHops += e.length; return materialize(store, n, e, reason, ctxOf()); };
301
+ // DAG-07 R07.6: ONE shared path (arrays + visited set) that is unwound on
302
+ // backtrack, instead of copying both for every frame (which made a chain
303
+ // of depth d cost O(d^2)). A frame is only {nodeId, edge, hops}; visit
304
+ // order and expansion accounting are identical to the copying version.
305
+ const path = {
306
+ nodesRev: [startNodeId], edgesRev: [], onPath: new Set([startNodeId]),
307
+ calls: [], overflow: 0, effects: [], widened: 0,
308
+ };
309
+ const ctxOf = () => (v2 ? { balanced: path.widened === 0, widened: path.widened > 0, maxCallDepth } : null);
310
+ const stack = [{ nodeId: startNodeId, edge: null, hops: 0 }];
311
+ // R07.9: a continuation (continuation.js) hands back the frontier a
312
+ // previous call stopped at. Shape is checked, never trusted blindly; a
313
+ // malformed one makes the query invalid rather than silently restarting.
314
+ if (opts.resumeState !== undefined) {
315
+ const rs = opts.resumeState;
316
+ const ok = rs && rs.startNodeId === startNodeId && Array.isArray(rs.nodesRev) && Array.isArray(rs.edgesRev)
317
+ && Array.isArray(rs.stack) && rs.nodesRev.length === rs.edgesRev.length + 1;
318
+ if (!ok) {
319
+ result.status = 'invalid';
320
+ result.budgetErrors = ['resumeState is malformed or belongs to a different start node'];
321
+ return result;
322
+ }
323
+ path.nodesRev = rs.nodesRev.slice();
324
+ path.edgesRev = rs.edgesRev.slice();
325
+ path.onPath = new Set(rs.nodesRev);
326
+ stack.length = 0;
327
+ for (const f of rs.stack) stack.push({ ...f });
328
+ result.resumed = true;
329
+ }
226
330
 
227
331
  while (stack.length > 0) {
228
332
  if (candidates.length >= budget.maxCandidatePaths) { truncationReasons.add('candidate-cap'); break; }
333
+ if (materializedHops >= budget.maxMaterializedHops) { truncationReasons.add('materialization-budget'); break; }
229
334
  if (expansions >= budget.maxExpansions) { truncationReasons.add('expansion-budget'); break; }
230
335
  const frame = stack.pop();
231
- const hops = frame.edgesRev.length;
336
+ const hops = frame.hops;
337
+ // Unwind to this frame's parent, then step onto the frame.
338
+ const parentLen = hops === 0 ? 0 : hops - 1;
339
+ while (path.edgesRev.length > parentLen) {
340
+ path.edgesRev.pop();
341
+ path.onPath.delete(path.nodesRev.pop());
342
+ if (v2) {
343
+ const eff = path.effects.pop();
344
+ if (eff?.push) path.calls.pop();
345
+ if (eff?.pop) path.calls.push(eff.pop);
346
+ if (eff?.overflowInc) path.overflow -= 1;
347
+ if (eff?.overflowDec) path.overflow += 1;
348
+ if (eff?.widened) path.widened -= 1;
349
+ }
350
+ }
351
+ if (frame.edge) {
352
+ if (v2) {
353
+ const eff = {};
354
+ const b = frame.edge.callBoundary;
355
+ if (b === 'return') {
356
+ if (path.calls.length < maxCallDepth) { path.calls.push(frame.edge.invocationId); eff.push = true; }
357
+ else { path.overflow += 1; eff.overflowInc = true; eff.widened = true; path.widened += 1; }
358
+ } else if (b === 'call') {
359
+ if (path.overflow > 0) { path.overflow -= 1; eff.overflowDec = true; eff.widened = true; path.widened += 1; }
360
+ else if (path.calls.length > 0) eff.pop = path.calls.pop();
361
+ }
362
+ path.effects.push(eff);
363
+ }
364
+ path.edgesRev.push(frame.edge);
365
+ path.nodesRev.push(frame.nodeId);
366
+ path.onPath.add(frame.nodeId);
367
+ }
232
368
  // `edgesTo` is the traversal primitive; sorted for determinism, since
233
369
  // it is backed by a Set and carries no inherent order.
234
370
  const incoming = [...store.edgesTo(frame.nodeId)].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
235
371
 
236
372
  if (incoming.length === 0) {
237
373
  if (hops > 0) {
238
- candidates.push(materialize(store, frame.nodesRev, frame.edgesRev,
374
+ candidates.push(materializeCounted(path.nodesRev, path.edgesRev,
239
375
  orphaned.has(frame.nodeId) ? TERMINAL_INCOMPLETE : TERMINAL_ORIGIN));
240
376
  }
241
377
  continue;
@@ -265,33 +401,56 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
265
401
  // literal zero-hop budget will not get one.
266
402
  if (hops >= budget.maxDepth && hops > 0) {
267
403
  truncationReasons.add('depth-limit');
268
- candidates.push(materialize(store, frame.nodesRev, frame.edgesRev, TERMINAL_DEPTH));
404
+ candidates.push(materializeCounted(path.nodesRev, path.edgesRev, TERMINAL_DEPTH));
269
405
  continue;
270
406
  }
271
407
 
272
408
  let extended = 0;
273
409
  let clipped = 0;
410
+ let unbalanced = 0;
274
411
  for (const e of incoming) {
275
412
  expansions += 1;
276
- if (frame.onPath.has(e.fromNodeId)) { clipped += 1; continue; }
413
+ if (v2 && e.callBoundary === 'call' && path.overflow === 0 && path.calls.length > 0
414
+ && path.calls[path.calls.length - 1] !== e.invocationId) {
415
+ // The walk entered the callee through another call site, so this
416
+ // edge belongs to a call/return pair no execution can produce.
417
+ unbalanced += 1;
418
+ balancing.unbalancedPruned += 1;
419
+ if (balancing.prunedEdgeIds.length < 10) balancing.prunedEdgeIds.push(e.id);
420
+ continue;
421
+ }
422
+ if (path.onPath.has(e.fromNodeId)) { clipped += 1; continue; }
277
423
  extended += 1;
278
- stack.push({
279
- nodeId: e.fromNodeId,
280
- nodesRev: [...frame.nodesRev, e.fromNodeId],
281
- edgesRev: [...frame.edgesRev, e],
282
- onPath: new Set([...frame.onPath, e.fromNodeId]),
283
- });
424
+ stack.push({ nodeId: e.fromNodeId, edge: e, hops: hops + 1 });
284
425
  }
285
426
  result.cyclesClipped += clipped;
427
+ if (extended === 0 && clipped === 0 && unbalanced > 0 && hops > 0) {
428
+ // Everything that led here is a mismatched call/return pair. Say so,
429
+ // as a partial path, instead of letting an empty answer read as
430
+ // "nothing flows".
431
+ candidates.push(materialize(store, path.nodesRev, path.edgesRev, TERMINAL_UNBALANCED, ctxOf()));
432
+ continue;
433
+ }
286
434
  if (extended === 0 && hops > 0) {
287
435
  // Every continuation would revisit a node already on this path. The
288
436
  // branch ends HERE, and it ends because of a cycle — never silently
289
437
  // as if this node were the flow's origin.
290
- candidates.push(materialize(store, frame.nodesRev, frame.edgesRev, TERMINAL_CYCLE));
438
+ candidates.push(materializeCounted(path.nodesRev, path.edgesRev, TERMINAL_CYCLE));
291
439
  }
292
440
  }
293
441
 
294
442
  result.budget.expansionsUsed = expansions;
443
+ // R07.1: work still queued when a budget stopped the walk (0 when exhausted).
444
+ result.unexploredFrontierCount = stack.length;
445
+ if (stack.length > 0) {
446
+ result.resumeState = { startNodeId, nodesRev: path.nodesRev.slice(), edgesRev: path.edgesRev.slice(), stack: stack.map((f) => ({ ...f })) };
447
+ }
448
+ result.budget.materializedHops = materializedHops;
449
+ if (v2) {
450
+ balancing.contextWidenedPaths = candidates.filter((p) => p.callContext?.widened).length;
451
+ balancing.prunedEdgeIds.sort();
452
+ result.balancing = balancing;
453
+ }
295
454
  result.enumeratedPathCount = candidates.length;
296
455
  // Task 2 review finding 1: this is scoped to EVERY complete path found
297
456
  // during enumeration, not just the ones the caps below end up returning
@@ -384,6 +543,9 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
384
543
  }
385
544
  result.terminals = terminals;
386
545
 
546
+ // R07.5: a store that refused hops/edges or skipped a group is missing
547
+ // pairings this query cannot see, so it can never prove "no path".
548
+ if (typeof store.limitStatus === 'function' && store.limitStatus().hit) truncationReasons.add('store-limit');
387
549
  result.truncated = truncationReasons.size > 0 || result.droppedPathCount > 0;
388
550
  result.truncationReasons = [...truncationReasons].sort();
389
551
 
@@ -395,6 +557,13 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
395
557
  // `noPathReason` — never a stylistic choice.
396
558
  result.noPathReason = orphaned.has(startNodeId) ? TERMINAL_INCOMPLETE : 'no-incoming-edges';
397
559
  }
560
+ result.status = isIncompleteAnswer(result) ? 'partial' : 'complete';
561
+ // "No modeled path" is allowed only for an exhausted query; it is a
562
+ // statement about the modeled graph, never about the software.
563
+ // A resumed page only describes the work done since the continuation, so it
564
+ // can never license a negative claim about the whole query.
565
+ result.negativeClaimAllowed = result.status === 'complete' && result.paths.length === 0 && !result.resumed
566
+ && result.noPathReason === 'no-incoming-edges';
398
567
  return result;
399
568
  }
400
569
 
@@ -404,7 +573,7 @@ export function reconstructPaths(store, startNodeId, opts = {}) {
404
573
  * term).
405
574
  */
406
575
  export function isIncompleteAnswer(result) {
407
- return result.truncated || result.unknownStartNode || result.analysisTruncated
576
+ return result.status === 'invalid' || result.truncated || result.unknownStartNode || result.analysisTruncated
408
577
  || result.noPathReason === TERMINAL_INCOMPLETE
409
578
  || result.paths.some((p) => !p.complete);
410
579
  }
@@ -21,7 +21,11 @@
21
21
  // C5's job and C5's alone.
22
22
  //
23
23
 
24
+ import { createHash } from 'node:crypto';
24
25
  import { provenanceNodeId, provenanceEdgeId } from './ids.js';
26
+ import {
27
+ CORRELATION_VERSION_2, V2_HOP_FIELDS, resolveCorrelationVersion, pairCompatibility, buildCrosswalk,
28
+ } from './correlation.js';
25
29
 
26
30
  // §3 + §13.0's exact 14-field hop shape, in a FIXED order. Never derive a
27
31
  // dedupe/discriminator key via `Object.keys(h)` — that is order-dependent,
@@ -40,8 +44,11 @@ export const HOP_FIELDS = [
40
44
 
41
45
  const IN_KINDS = new Set(['production', 'selection']);
42
46
 
43
- function hopDedupeKey(h) {
44
- return HOP_FIELDS.map((f) => JSON.stringify(h[f] ?? null)).join('|');
47
+ function hopDedupeKey(h, v2 = false) {
48
+ const base = HOP_FIELDS.map((f) => JSON.stringify(h[f] ?? null)).join('|');
49
+ // Version 2 hops carry additive evidence fields; two hops that differ only
50
+ // in that evidence are different hops. Version 1 never reads them.
51
+ return v2 ? `${base}|${V2_HOP_FIELDS.map((f) => JSON.stringify(h[f] ?? null)).join('|')}` : base;
45
52
  }
46
53
 
47
54
  function joinKeyOf(h) {
@@ -149,8 +156,31 @@ export function classifyOut(h) {
149
156
  return { role: 'unclassified', crossScope: false, node: null };
150
157
  }
151
158
 
159
+ // DAG-07 R07.5: construction bounds. Hitting one never throws and never
160
+ // silently drops: it is recorded in diagnostics().limits and makes every
161
+ // reconstruction over this store report `store-limit` truncation.
162
+ export const STORE_LIMIT_DEFAULTS = {
163
+ maxHops: 2000000,
164
+ maxPairingsPerGroup: 10000,
165
+ maxEdges: 5000000,
166
+ };
167
+
152
168
  export class PathStore {
153
- constructor() {
169
+ /**
170
+ * `options.correlationVersion` (or AGENTIC_SECURITY_LINEAGE_CORRELATION=2)
171
+ * selects provenance correlation version 2 (DAG-03): value-versioned path
172
+ * nodes, evidence-gated pairing of the two hop halves and call-boundary
173
+ * edges that carry their invocation. The default is version 1, whose nodes,
174
+ * edges, ids and diagnostics are byte-identical to the pre-DAG-03 store.
175
+ */
176
+ constructor(limits = {}, options = {}) {
177
+ this._v2 = resolveCorrelationVersion(options?.correlationVersion) === CORRELATION_VERSION_2;
178
+ this._limits = { ...STORE_LIMIT_DEFAULTS };
179
+ for (const k of Object.keys(STORE_LIMIT_DEFAULTS)) {
180
+ const v = limits?.[k];
181
+ if (Number.isInteger(v) && v >= 0) this._limits[k] = v;
182
+ }
183
+ this._limitHits = { hopsRefused: 0, groupsSkipped: [], edgesRefused: 0, summarizedGroups: 0 };
154
184
  this._groups = new Map();
155
185
  this._seen = new Set();
156
186
  this._truncations = new Map();
@@ -178,8 +208,12 @@ export class PathStore {
178
208
  if (missing.length > 0) {
179
209
  this._malformed.push({ hop, missing });
180
210
  }
181
- const k = hopDedupeKey(hop);
211
+ const k = hopDedupeKey(hop, this._v2);
182
212
  if (this._seen.has(k)) return false;
213
+ if (this._stats.hopsAccepted >= this._limits.maxHops) {
214
+ this._limitHits.hopsRefused += 1;
215
+ return false;
216
+ }
183
217
  this._seen.add(k);
184
218
  this._stats.hopsAccepted += 1;
185
219
 
@@ -196,14 +230,24 @@ export class PathStore {
196
230
  if (IN_KINDS.has(hop.kind)) {
197
231
  const c = classifyIn(hop);
198
232
  if (c.role === 'source') {
199
- g.in.push({ hop, ...c });
233
+ if (this._v2 && !c.crossScope && c.node.kind === 'path') {
234
+ // One in-half node per reaching definition: a merge keeps every
235
+ // alternative, a killed definition is simply absent.
236
+ const vs = Array.isArray(hop.fromVersions) && hop.fromVersions.length > 0 ? hop.fromVersions : [null];
237
+ for (const v of vs) g.in.push({ hop, ...c, node: { ...c.node, version: v } });
238
+ } else {
239
+ g.in.push({ hop, ...c });
240
+ }
200
241
  if (c.crossScope) this._peerSourced.push({ hop, node: c.node });
201
242
  } else {
202
243
  g.annotations.push(hop);
203
244
  }
204
245
  } else if (hop.kind === 'write-out') {
205
246
  const c = classifyOut(hop);
206
- if (c.role === 'target') g.out.push({ hop, ...c });
247
+ if (c.role === 'target') {
248
+ const node = this._v2 && c.node.kind === 'path' ? { ...c.node, version: hop.toVersion ?? null } : c.node;
249
+ g.out.push({ hop, ...c, node });
250
+ }
207
251
  else this._unclassified.push(hop);
208
252
  } else {
209
253
  this._unclassified.push(hop);
@@ -233,13 +277,15 @@ export class PathStore {
233
277
 
234
278
  _build() {
235
279
  if (this._built) return this._built;
280
+ this._limitHits = { hopsRefused: this._limitHits.hopsRefused, groupsSkipped: [], edgesRefused: 0, summarizedGroups: 0 };
236
281
  const nodes = new Map();
237
282
  const edges = new Map();
238
283
  const outIndex = new Map();
239
284
  const inIndex = new Map();
240
285
 
286
+ const nid = (desc) => this.nodeIdFor(desc);
241
287
  const intern = (desc) => {
242
- const id = provenanceNodeId(desc);
288
+ const id = nid(desc);
243
289
  let n = nodes.get(id);
244
290
  if (!n) {
245
291
  n = {
@@ -251,8 +297,29 @@ export class PathStore {
251
297
  return n;
252
298
  };
253
299
 
300
+ // Version 2: a definition that never wrote this data element at this path
301
+ // cannot be where it came from, so a source node (path, version, id) with
302
+ // no producing out-half is a phantom alternative and is dropped. The entry
303
+ // version has no producer by nature (a seed, or a caller binding), so it
304
+ // is kept. Counted in diagnostics, never silent.
305
+ const produced = new Set();
306
+ if (this._v2) {
307
+ for (const g of this._groups.values()) {
308
+ for (const o of g.out) if (o.node.kind === 'path') produced.add(nid(o.node));
309
+ }
310
+ }
311
+ let versionPruned = 0;
254
312
  for (const g of this._groups.values()) {
255
313
  let sources = g.in;
314
+ if (this._v2) {
315
+ sources = g.in.filter((s) => {
316
+ const v = s.node.version;
317
+ if (s.node.kind !== 'path' || v == null || v === 'entry') return true;
318
+ if (produced.has(nid(s.node))) return true;
319
+ versionPruned += 1;
320
+ return false;
321
+ });
322
+ }
256
323
  let originated = false;
257
324
  // Populated only when `originated` below — the union of every
258
325
  // annotation's widen/loss reason at this key, so an edge originating
@@ -266,7 +333,7 @@ export class PathStore {
266
333
  // exact shape a Sub-project D source registry will produce first).
267
334
  let originWidenReasons = [];
268
335
  let originLossReasons = [];
269
- if (sources.length === 0 && g.annotations.length > 0) {
336
+ if (g.in.length === 0 && g.annotations.length > 0) {
270
337
  // §2.2's annotation rule, in the ONE direction it is still right:
271
338
  // with no non-null in-half anywhere at this key, the annotation is
272
339
  // itself the origin of the value ("no prior aliasing source").
@@ -294,16 +361,65 @@ export class PathStore {
294
361
  // in-half and the return's in-half share one join key). Counting
295
362
  // only the pairings the store would ACTUALLY form (after the peer x
296
363
  // peer exclusion) recovers the distinction at zero cost.
297
- const pairable = (s, o) => !(s.crossScope && o.crossScope);
298
- const sourcesFor = (o) => new Set(sources.filter((s) => pairable(s, o)).map((s) => provenanceNodeId(s.node)));
299
- const targetsFor = (s) => new Set(g.out.filter((o) => pairable(s, o)).map((o) => provenanceNodeId(o.node)));
364
+ const pairable = (s, o) => {
365
+ if (s.crossScope && o.crossScope) return false;
366
+ return !this._v2 || originated || pairCompatibility(s.hop, o.hop).compatible;
367
+ };
368
+ const sourcesFor = (o) => new Set(sources.filter((s) => pairable(s, o)).map((s) => nid(s.node)));
369
+ const targetsFor = (s) => new Set(g.out.filter((o) => pairable(s, o)).map((o) => nid(o.node)));
300
370
 
301
371
  const annotations = g.annotations.map((a) => ({
302
372
  kind: a.kind, subKind: a.subKind,
303
373
  widenReason: a.widenReason ?? null, lossReason: a.lossReason ?? null,
304
374
  peerScope: a.peerScope ?? null, peerContext: a.peerContext ?? null,
305
375
  }));
376
+ // Version 2: the graph content must not depend on hop delivery order,
377
+ // and an annotation list is built in delivery order. Sorted by content.
378
+ if (this._v2) annotations.sort((a, b) => { const x = JSON.stringify(a); const y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; });
306
379
 
380
+ // R07.5: an explicit cross product above the cap is not materialized;
381
+ // the group is reported instead of being silently pruned.
382
+ if (sources.length * g.out.length > this._limits.maxPairingsPerGroup) {
383
+ this._limitHits.groupsSkipped.push({
384
+ scope: g.scope, nodeId: g.nodeId, dataElementId: g.dataElementId,
385
+ pairings: sources.length * g.out.length,
386
+ });
387
+ // R07.5: the explicit cross product (S x O edges) is replaced by a
388
+ // typed ambiguous-summary relation: every source reaches one summary
389
+ // node and that node reaches every target (S + O edges). Which
390
+ // source feeds which target is deliberately not claimed, so every
391
+ // such edge is marked ambiguous; reachability is kept, nothing is
392
+ // silently dropped, and the group stays reported above.
393
+ const hub = intern({ kind: 'summary', scope: g.scope, context: g.context, path: null, siteNodeId: g.nodeId, dataElementId: g.dataElementId });
394
+ this._limitHits.summarizedGroups += 1;
395
+ const addSummaryEdge = (fromN, toN, inHop, outHop, crossScope) => {
396
+ const widenReasons = [...new Set([inHop.widenReason, outHop.widenReason].filter((r) => r != null))].sort();
397
+ const lossReasons = [...new Set([inHop.lossReason, outHop.lossReason].filter((r) => r != null))].sort();
398
+ const desc = {
399
+ fromNodeId: fromN.id, toNodeId: toN.id, dataElementId: g.dataElementId,
400
+ scope: g.scope, context: g.context, siteNodeId: g.nodeId,
401
+ inKind: inHop.kind, inSubKind: inHop.subKind,
402
+ outKind: outHop.kind, outSubKind: outHop.subKind,
403
+ widenReasons, lossReasons,
404
+ };
405
+ const eid = provenanceEdgeId(desc);
406
+ if (edges.has(eid)) return;
407
+ if (edges.size >= this._limits.maxEdges) { this._limitHits.edgesRefused += 1; return; }
408
+ edges.set(eid, {
409
+ id: eid, ...desc, line: g.line, crossScope, originated,
410
+ ambiguousCorrelation: true, ambiguousSummary: true, annotations,
411
+ truncated: this._truncations.has(scopeKeyOf(g.scope, g.context)),
412
+ });
413
+ if (!outIndex.has(fromN.id)) outIndex.set(fromN.id, new Set());
414
+ if (!inIndex.has(toN.id)) inIndex.set(toN.id, new Set());
415
+ outIndex.get(fromN.id).add(eid);
416
+ inIndex.get(toN.id).add(eid);
417
+ };
418
+ const summaryHop = { kind: 'summary', subKind: 'ambiguous-summary', widenReason: null, lossReason: null };
419
+ for (const s of sources) addSummaryEdge(intern(s.node), hub, s.hop, summaryHop, s.crossScope);
420
+ for (const o of g.out) addSummaryEdge(hub, intern(o.node), summaryHop, o.hop, o.crossScope);
421
+ continue;
422
+ }
307
423
  for (const s of sources) {
308
424
  for (const o of g.out) {
309
425
  // §14.3: an edge with BOTH endpoints in the peer's namespace is
@@ -327,13 +443,34 @@ export class PathStore {
327
443
  outKind: o.hop.kind, outSubKind: o.hop.subKind,
328
444
  widenReasons, lossReasons,
329
445
  };
330
- const id = provenanceEdgeId(desc);
331
- if (!edges.has(id)) {
446
+ let v2Edge = null;
447
+ let idParts = [];
448
+ if (this._v2) {
449
+ const pc = originated ? { proven: false } : pairCompatibility(s.hop, o.hop);
450
+ const invocationId = s.hop.invocationId ?? o.hop.invocationId ?? null;
451
+ const callBoundary = o.hop.callRole === 'bind' ? 'call' : s.hop.callRole === 'return' ? 'return' : null;
452
+ v2Edge = {
453
+ correlationVersion: CORRELATION_VERSION_2,
454
+ correlation: pc.proven ? 'proven' : 'legacy-unknown',
455
+ invocationId, callBoundary,
456
+ };
457
+ idParts = ['cv2', invocationId, callBoundary];
458
+ }
459
+ const id = provenanceEdgeId(desc, idParts);
460
+ if (edges.has(id) && v2Edge && v2Edge.correlation !== 'proven') {
461
+ // Two pairings that dedupe to one edge: the edge is only as proven
462
+ // as its weakest pairing, whatever order they arrived in.
463
+ edges.get(id).correlation = 'legacy-unknown';
464
+ }
465
+ if (!edges.has(id) && edges.size >= this._limits.maxEdges) {
466
+ this._limitHits.edgesRefused += 1;
467
+ } else if (!edges.has(id)) {
332
468
  edges.set(id, {
333
469
  id, ...desc, line: g.line,
334
470
  crossScope: s.crossScope || o.crossScope,
335
471
  originated,
336
472
  ambiguousCorrelation: sourcesFor(o).size >= 2 && targetsFor(s).size >= 2,
473
+ ...v2Edge,
337
474
  annotations,
338
475
  truncated: this._truncations.has(scopeKeyOf(g.scope, g.context)),
339
476
  });
@@ -356,14 +493,14 @@ export class PathStore {
356
493
  // fabricated into an origin, never dropped.
357
494
  const orphanedPeerSources = [];
358
495
  for (const { hop, node } of this._peerSourced) {
359
- const id = provenanceNodeId(node);
496
+ const id = nid(node);
360
497
  const inEdges = inIndex.get(id);
361
498
  if (!inEdges || inEdges.size === 0) {
362
499
  orphanedPeerSources.push({ hop, nodeId: id });
363
500
  }
364
501
  }
365
502
 
366
- this._built = { nodes, edges, outIndex, inIndex, orphanedPeerSources };
503
+ this._built = { nodes, edges, outIndex, inIndex, orphanedPeerSources, versionPruned };
367
504
  return this._built;
368
505
  }
369
506
 
@@ -375,11 +512,46 @@ export class PathStore {
375
512
  edges() { return [...this._build().edges.values()]; }
376
513
  getNode(id) { return this._build().nodes.get(id) ?? null; }
377
514
  getEdge(id) { return this._build().edges.get(id) ?? null; }
378
- nodeIdFor(desc) { return provenanceNodeId(desc); }
515
+ nodeIdFor(desc) {
516
+ return this._v2 ? provenanceNodeId(desc, ['cv2', desc.version ?? null]) : provenanceNodeId(desc);
517
+ }
518
+ /** 1 or 2: the correlation version this store was built with. */
519
+ correlationVersion() { return this._v2 ? 2 : 1; }
520
+ /** Version 1 to version 2 id crosswalk (version 2 stores only; `null` for version 1). */
521
+ crosswalk() { return this._v2 ? buildCrosswalk(this) : null; }
379
522
  edgesFrom(nodeId) { return [...(this._build().outIndex.get(nodeId) ?? [])].map((e) => this._build().edges.get(e)); }
380
523
  edgesTo(nodeId) { return [...(this._build().inIndex.get(nodeId) ?? [])].map((e) => this._build().edges.get(e)); }
381
524
  hasEdge(fromId, toId) { return this.edgesFrom(fromId).some((e) => e.toNodeId === toId); }
382
- stats() { return { ...this._stats, groups: this._groups.size, nodes: this._build().nodes.size, edges: this._build().edges.size }; }
525
+ /**
526
+ * R07.9: content digest of the built graph (sorted node and edge ids plus
527
+ * the construction-limit outcome). Two stores with equal digests answer
528
+ * every query identically; a continuation is bound to it.
529
+ */
530
+ digest() {
531
+ const b = this._build();
532
+ const h = createHash('sha256');
533
+ h.update(JSON.stringify([[...b.nodes.keys()].sort(), [...b.edges.keys()].sort(), this.limitStatus().hit]));
534
+ return h.digest('hex');
535
+ }
536
+ /** R07.5: has any construction bound been hit? Forces the build so the join-time counters are current. */
537
+ limitStatus() {
538
+ this._build();
539
+ const h = this._limitHits;
540
+ return {
541
+ hit: h.hopsRefused > 0 || h.groupsSkipped.length > 0 || h.edgesRefused > 0,
542
+ hopsRefused: h.hopsRefused,
543
+ groupsSkipped: h.groupsSkipped.slice(),
544
+ edgesRefused: h.edgesRefused,
545
+ ambiguousSummaries: h.summarizedGroups,
546
+ limits: { ...this._limits },
547
+ };
548
+ }
549
+ stats() {
550
+ return {
551
+ ...this._stats, groups: this._groups.size, nodes: this._build().nodes.size, edges: this._build().edges.size,
552
+ ...(this._v2 ? { correlationVersion: 2 } : null),
553
+ };
554
+ }
383
555
 
384
556
  /**
385
557
  * Four buckets, all "recorded, never thrown, never silently dropped"
@@ -395,6 +567,8 @@ export class PathStore {
395
567
  unclassified: this._unclassified,
396
568
  truncations: [...this._truncations.entries()],
397
569
  orphanedPeerSources: this._build().orphanedPeerSources,
570
+ limits: this.limitStatus(),
571
+ ...(this._v2 ? { correlationVersion: 2, versionPruned: this._build().versionPruned } : null),
398
572
  };
399
573
  }
400
574
  }