@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1

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 (228) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/agentic-security.js +111 -1
  3. package/dist/1128.index.js +912 -0
  4. package/dist/1665.index.js +828 -0
  5. package/dist/2584.index.js +1 -1
  6. package/dist/4171.index.js +2 -2
  7. package/dist/5752.index.js +466 -0
  8. package/dist/7405.index.js +1 -1
  9. package/dist/8158.index.js +1713 -0
  10. package/dist/8798.index.js +1 -1
  11. package/dist/9382.index.js +2 -2
  12. package/dist/9579.index.js +1 -1
  13. package/dist/agentic-security.mjs +29 -29
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
  16. package/dist/frontend/src/shell.js +12 -2
  17. package/package.json +29 -12
  18. package/src/dataflow/CLAUDE.md +46 -1
  19. package/src/dataflow/access-paths.js +12 -2
  20. package/src/dataflow/backward.js +42 -7
  21. package/src/dataflow/cross-service-taint.js +83 -1
  22. package/src/dataflow/demand-query.js +1168 -0
  23. package/src/dataflow/dep-fingerprint.js +718 -0
  24. package/src/dataflow/engine.js +48 -6
  25. package/src/dataflow/exploit-prover.js +6 -2
  26. package/src/dataflow/ifds-precise.js +22 -0
  27. package/src/dataflow/incremental-replay.js +118 -0
  28. package/src/dataflow/incremental-session.js +257 -0
  29. package/src/dataflow/incremental.js +237 -35
  30. package/src/dataflow/index.js +25 -94
  31. package/src/dataflow/ir-cache.js +268 -0
  32. package/src/dataflow/privacy-taint.js +7 -0
  33. package/src/dataflow/summaries.js +72 -6
  34. package/src/dataflow/tabulation.js +80 -24
  35. package/src/engine.js +113 -8
  36. package/src/graph/CLAUDE.md +31 -0
  37. package/src/graph/adapters/artifact.js +81 -0
  38. package/src/graph/adapters/crossrepo.js +75 -0
  39. package/src/graph/adapters/dataflow.js +76 -0
  40. package/src/graph/adapters/provenance.js +92 -0
  41. package/src/graph/adapters/semantic.js +89 -0
  42. package/src/graph/crosswalk-builders.js +152 -0
  43. package/src/graph/crosswalk.js +146 -0
  44. package/src/graph/index.js +15 -0
  45. package/src/graph/observation.js +57 -0
  46. package/src/graph/refs.js +105 -0
  47. package/src/graph/registry.js +129 -0
  48. package/src/graph/relations.js +81 -0
  49. package/src/graph/scc.js +241 -0
  50. package/src/graph/view.js +254 -0
  51. package/src/ir/CLAUDE.md +4 -0
  52. package/src/ir/callgraph.js +36 -8
  53. package/src/ir/index.js +18 -4
  54. package/src/ir/parser-js.js +10 -0
  55. package/src/language/CLAUDE.md +13 -0
  56. package/src/language/nix-option-types.js +170 -0
  57. package/src/language/nix-script-taint.js +46 -3
  58. package/src/language/nix-secrets.js +20 -2
  59. package/src/lineage/CLAUDE.md +72 -8
  60. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  61. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  62. package/src/lineage/boundary-contract.js +273 -0
  63. package/src/lineage/completion.js +205 -0
  64. package/src/lineage/continuation.js +140 -0
  65. package/src/lineage/control-credit.js +313 -0
  66. package/src/lineage/correlation.js +199 -0
  67. package/src/lineage/coverage.js +8 -1
  68. package/src/lineage/cross-repo-link.js +22 -0
  69. package/src/lineage/cut-planner.js +548 -0
  70. package/src/lineage/driver.js +219 -66
  71. package/src/lineage/engine.js +166 -21
  72. package/src/lineage/export-json.js +5 -0
  73. package/src/lineage/graph-builder.js +84 -7
  74. package/src/lineage/haskell-view.js +59 -0
  75. package/src/lineage/index.js +3 -0
  76. package/src/lineage/nix-view.js +35 -0
  77. package/src/lineage/owned-build.js +77 -0
  78. package/src/lineage/owned-worker-entry.js +22 -0
  79. package/src/lineage/owned-worker.js +95 -0
  80. package/src/lineage/path-evidence.js +123 -0
  81. package/src/lineage/path-predicates.js +529 -0
  82. package/src/lineage/path-query.js +189 -20
  83. package/src/lineage/path-store.js +191 -17
  84. package/src/lineage/scc-solver.js +186 -0
  85. package/src/lineage/smt-orchestrator.js +174 -0
  86. package/src/lineage/smt-z3-backend.js +294 -0
  87. package/src/lineage/source-seeding.js +36 -2
  88. package/src/lineage/summaries.js +66 -4
  89. package/src/lineage/value-versions.js +130 -0
  90. package/src/lineage/witness-selection.js +95 -0
  91. package/src/pipeline/scan-health.js +18 -0
  92. package/src/posture/CLAUDE.md +17 -0
  93. package/src/posture/business-logic.js +16 -7
  94. package/src/posture/dag-commands.js +442 -0
  95. package/src/posture/evidence-bundle.js +1 -1
  96. package/src/posture/finding-defaults.js +19 -0
  97. package/src/posture/git-history.js +91 -1
  98. package/src/posture/witness-bundle.js +573 -0
  99. package/src/posture/witness-runtime.js +276 -0
  100. package/src/report/index.js +2 -0
  101. package/src/sast/CLAUDE.md +2 -0
  102. package/src/sast/_call-args.js +70 -0
  103. package/src/sast/init-scope.js +87 -0
  104. package/src/sast/listen-bind-address.js +49 -0
  105. package/src/sast/redirect-target.js +106 -0
  106. package/src/sast/reflected-input.js +76 -0
  107. package/src/sast/send-file-root.js +27 -0
  108. package/src/sast/sensitive-data-logging.js +38 -1
  109. package/dist/119.index.js +0 -197
  110. package/dist/128.index.js +0 -129
  111. package/dist/1293.index.js +0 -5689
  112. package/dist/1336.index.js +0 -16182
  113. package/dist/1351.index.js +0 -133
  114. package/dist/1387.index.js +0 -271
  115. package/dist/1437.index.js +0 -23
  116. package/dist/1445.index.js +0 -110
  117. package/dist/1505.index.js +0 -151
  118. package/dist/1557.index.js +0 -293
  119. package/dist/1621.index.js +0 -311
  120. package/dist/1751.index.js +0 -160
  121. package/dist/1765.index.js +0 -727
  122. package/dist/1861.index.js +0 -690
  123. package/dist/1906.index.js +0 -433
  124. package/dist/208.index.js +0 -1631
  125. package/dist/2223.index.js +0 -159
  126. package/dist/2238.index.js +0 -295
  127. package/dist/2530.index.js +0 -152
  128. package/dist/2565.index.js +0 -116
  129. package/dist/2574.index.js +0 -127
  130. package/dist/2620.index.js +0 -345
  131. package/dist/2773.index.js +0 -98
  132. package/dist/2775.index.js +0 -32
  133. package/dist/2966.index.js +0 -171
  134. package/dist/2972.index.js +0 -87
  135. package/dist/2991.index.js +0 -131
  136. package/dist/3063.index.js +0 -833
  137. package/dist/3075.index.js +0 -227
  138. package/dist/3121.index.js +0 -297
  139. package/dist/3314.index.js +0 -263
  140. package/dist/3480.index.js +0 -1822
  141. package/dist/3481.index.js +0 -24
  142. package/dist/3506.index.js +0 -268
  143. package/dist/3533.index.js +0 -591
  144. package/dist/3653.index.js +0 -450
  145. package/dist/3740.index.js +0 -722
  146. package/dist/3822.index.js +0 -131
  147. package/dist/3868.index.js +0 -292
  148. package/dist/3918.index.js +0 -192
  149. package/dist/4090.index.js +0 -288
  150. package/dist/4113.index.js +0 -1286
  151. package/dist/4121.index.js +0 -1190
  152. package/dist/4142.index.js +0 -690
  153. package/dist/4168.index.js +0 -509
  154. package/dist/4281.index.js +0 -993
  155. package/dist/4336.index.js +0 -436
  156. package/dist/4387.index.js +0 -532
  157. package/dist/4413.index.js +0 -302
  158. package/dist/4476.index.js +0 -4510
  159. package/dist/4491.index.js +0 -133
  160. package/dist/4538.index.js +0 -185
  161. package/dist/4684.index.js +0 -319
  162. package/dist/4740.index.js +0 -165
  163. package/dist/4780.index.js +0 -209
  164. package/dist/4802.index.js +0 -225
  165. package/dist/4812.index.js +0 -379
  166. package/dist/4835.index.js +0 -126
  167. package/dist/494.index.js +0 -226
  168. package/dist/4991.index.js +0 -549
  169. package/dist/5199.index.js +0 -305
  170. package/dist/5216.index.js +0 -109
  171. package/dist/5439.index.js +0 -301
  172. package/dist/5443.index.js +0 -97
  173. package/dist/5496.index.js +0 -377
  174. package/dist/5738.index.js +0 -240
  175. package/dist/5801.index.js +0 -588
  176. package/dist/5817.index.js +0 -714
  177. package/dist/5858.index.js +0 -223
  178. package/dist/5933.index.js +0 -143
  179. package/dist/601.index.js +0 -23
  180. package/dist/6173.index.js +0 -273
  181. package/dist/6291.index.js +0 -1871
  182. package/dist/6312.index.js +0 -666
  183. package/dist/634.index.js +0 -16182
  184. package/dist/644.index.js +0 -87
  185. package/dist/6508.index.js +0 -283
  186. package/dist/6560.index.js +0 -645
  187. package/dist/6576.index.js +0 -422
  188. package/dist/6579.index.js +0 -271
  189. package/dist/6655.index.js +0 -3221
  190. package/dist/6745.index.js +0 -305
  191. package/dist/6818.index.js +0 -21642
  192. package/dist/6932.index.js +0 -1190
  193. package/dist/6972.index.js +0 -167
  194. package/dist/6995.index.js +0 -163
  195. package/dist/7249.index.js +0 -191
  196. package/dist/7372.index.js +0 -1197
  197. package/dist/7546.index.js +0 -541
  198. package/dist/76.index.js +0 -448
  199. package/dist/7879.index.js +0 -163
  200. package/dist/8025.index.js +0 -130
  201. package/dist/8054.index.js +0 -248
  202. package/dist/808.index.js +0 -1330
  203. package/dist/8097.index.js +0 -201
  204. package/dist/8126.index.js +0 -191
  205. package/dist/8178.index.js +0 -78
  206. package/dist/8382.index.js +0 -93
  207. package/dist/8408.index.js +0 -261
  208. package/dist/8540.index.js +0 -174
  209. package/dist/8635.index.js +0 -802
  210. package/dist/8688.index.js +0 -410
  211. package/dist/8699.index.js +0 -779
  212. package/dist/8891.index.js +0 -457
  213. package/dist/8916.index.js +0 -885
  214. package/dist/9146.index.js +0 -170
  215. package/dist/9168.index.js +0 -841
  216. package/dist/9292.index.js +0 -369
  217. package/dist/9422.index.js +0 -866
  218. package/dist/9440.index.js +0 -453
  219. package/dist/9479.index.js +0 -152
  220. package/dist/9481.index.js +0 -220
  221. package/dist/9539.index.js +0 -100
  222. package/dist/9547.index.js +0 -103
  223. package/dist/9692.index.js +0 -90
  224. package/dist/9800.index.js +0 -462
  225. package/dist/9899.index.js +0 -138
  226. package/dist/9981.index.js +0 -206
  227. package/dist/999.index.js +0 -117
  228. package/dist/9999.index.js +0 -1698
@@ -0,0 +1,273 @@
1
+ // boundary-contract.js: DAG-08. Versioned, field-aware contracts for the HTTP, queue and storage edges between services.
2
+ //
3
+ // A contract is an OPERATOR DECLARATION that a producer site hands named fields to a consumer site across a trust boundary.
4
+ // What it can establish, and what it cannot:
5
+ // * It establishes a MODELED untrusted input at the consumer: the declared consumer parameter/record fields are treated as
6
+ // attacker-influenced when the taint engine runs. It does not establish that any value was observed on the wire, nor that a
7
+ // sink in the consumer is exploited. Every attribution carries `modeledInput: true, observed: false`.
8
+ // * It is bound to a parser-resolved local site (a function in the call graph and one of its parameters). A declaration that
9
+ // cannot be bound (no such file/function/parameter, an ambiguous name, a field the Haskell record does not have) becomes an
10
+ // explicit COVERAGE GAP. It never becomes a finding at a guessed function.
11
+ // * Field mappings carry renames (`customer_id` -> `customerId`). The consumer is seeded only at the mapped consumer fields
12
+ // (consumer projection), so a sibling field such as `displayName` is not attributed to the boundary.
13
+ // * `persistence` separates an immediate call edge (http) from a handoff that outlives the request (storage, durable queue).
14
+ // A storage write and a later read correlate only under a declared storage contract that names the same store, table and
15
+ // key/schema version. Two sites that merely share a field name are never joined.
16
+ // * Evidence kinds stay separately attributable: foreign graph validation, digest check, manual assertion, code mapping and
17
+ // observed connection. Only an `observed-connection` item can make `observed` true, and this module never creates one.
18
+ //
19
+ // Pure: no file access, no dependency on the taint engine. The seeding step that needs the engine lives in
20
+ // dataflow/cross-service-taint.js and consumes the bindings built here.
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { EDGE_PROVENANCE_VALUES } from './schema.js';
24
+ import { computeScc } from '../graph/scc.js';
25
+
26
+ export const BOUNDARY_CONTRACT_VERSION = '1.0.0';
27
+ export const BOUNDARY_CONTRACTS_FILENAME = 'boundary-contracts.json';
28
+ export const BOUNDARY_KINDS = Object.freeze(['http', 'queue', 'storage']);
29
+ export const PERSISTENCE_VALUES = Object.freeze(['immediate', 'persisted']);
30
+ export const EVIDENCE_KINDS = Object.freeze(['foreign-graph-validation', 'digest-check', 'manual-assertion', 'code-mapping', 'observed-connection']);
31
+
32
+ const isStr = (v) => typeof v === 'string' && v.length > 0;
33
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
34
+ const sorted = (a) => [...a].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
35
+
36
+ function canonical(v) {
37
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
38
+ if (isObj(v)) return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
39
+ return JSON.stringify(v === undefined ? null : v);
40
+ }
41
+
42
+ /** Digest of everything that defines what the contract says (not who declared it or when). */
43
+ export function contractDigest(c) {
44
+ const basis = {
45
+ version: c.version, kind: c.kind, persistence: c.persistence, channel: c.channel, schema: c.schema,
46
+ producer: { service: c.producer && c.producer.service, site: (c.producer && c.producer.site) || null, untrustedFields: sorted((c.producer && c.producer.untrustedFields) || []) },
47
+ consumer: { service: c.consumer && c.consumer.service, site: (c.consumer && c.consumer.site) || null },
48
+ fieldMappings: (c.fieldMappings || []).map((m) => ({ from: m.from, to: m.to })).sort((a, b) => (a.from + a.to < b.from + b.to ? -1 : 1)),
49
+ };
50
+ return `sha256:${createHash('sha256').update(canonical(basis)).digest('hex')}`;
51
+ }
52
+
53
+ function channelKey(c) {
54
+ const ch = c.channel || {};
55
+ if (c.kind === 'http') return `http:${String(ch.method || '*').toUpperCase()} ${ch.route || ''}`;
56
+ if (c.kind === 'queue') return `queue:${ch.topic || ''}`;
57
+ return `storage:${ch.store || ''}/${ch.table || ''}#${ch.key || ''}`;
58
+ }
59
+
60
+ /** Structural validation. Never throws, never touches a graph. */
61
+ export function validateBoundaryContract(c) {
62
+ const errors = [];
63
+ const err = (path, message) => errors.push({ path, message });
64
+ if (!isObj(c)) return { valid: false, errors: [{ path: '$', message: 'contract must be an object' }] };
65
+ if (!isStr(c.id) || !c.id.startsWith('boundary:')) err('$.id', 'id is required and must start with "boundary:"');
66
+ if (c.version !== BOUNDARY_CONTRACT_VERSION) err('$.version', `unsupported contract version "${c.version}" (supported: ${BOUNDARY_CONTRACT_VERSION})`);
67
+ if (!BOUNDARY_KINDS.includes(c.kind)) err('$.kind', `kind must be one of ${BOUNDARY_KINDS.join('|')}`);
68
+ if (!PERSISTENCE_VALUES.includes(c.persistence)) err('$.persistence', `persistence must be one of ${PERSISTENCE_VALUES.join('|')}`);
69
+ else if (c.kind === 'storage' && c.persistence !== 'persisted') err('$.persistence', 'a storage contract is persisted: the value outlives the request');
70
+ else if (c.kind === 'http' && c.persistence !== 'immediate') err('$.persistence', 'an http contract is an immediate call edge');
71
+ if (!isObj(c.channel)) err('$.channel', 'channel is required');
72
+ else if (c.kind === 'http' && !isStr(c.channel.route)) err('$.channel.route', 'http channel needs a route');
73
+ else if (c.kind === 'queue' && !isStr(c.channel.topic)) err('$.channel.topic', 'queue channel needs a topic');
74
+ else if (c.kind === 'storage') {
75
+ if (!isStr(c.channel.store)) err('$.channel.store', 'storage channel needs a store');
76
+ if (!isStr(c.channel.table)) err('$.channel.table', 'storage channel needs a table');
77
+ if (!isStr(c.channel.key)) err('$.channel.key', 'storage channel needs a key contract');
78
+ }
79
+ if (!isObj(c.schema) || !isStr(c.schema.name) || !isStr(c.schema.version)) err('$.schema', 'schema {name, version} is required');
80
+ for (const side of ['producer', 'consumer']) {
81
+ const s = c[side];
82
+ if (!isObj(s) || !isStr(s.service)) { err(`$.${side}.service`, `${side}.service is required`); continue; }
83
+ if (s.site !== undefined && s.site !== null) {
84
+ if (!isObj(s.site) || !isStr(s.site.file) || !isStr(s.site.function)) err(`$.${side}.site`, `${side}.site needs file and function`);
85
+ else if (side === 'consumer' && !isStr(s.site.param)) err('$.consumer.site.param', 'consumer.site needs the handler parameter that receives the record');
86
+ } else if (side === 'consumer') err('$.consumer.site', 'consumer.site is required to seed taint');
87
+ }
88
+ if (!Array.isArray(c.fieldMappings)) err('$.fieldMappings', 'fieldMappings must be an array');
89
+ else c.fieldMappings.forEach((m, i) => { if (!isObj(m) || !isStr(m.from) || !isStr(m.to)) err(`$.fieldMappings[${i}]`, 'mapping needs from and to'); });
90
+ if (!EDGE_PROVENANCE_VALUES.includes(c.provenance)) err('$.provenance', `provenance must be one of ${EDGE_PROVENANCE_VALUES.join('|')}`);
91
+ if (!isObj(c.evidence) || c.evidence.declared !== true || c.evidence.observed !== false) err('$.evidence', 'evidence must be {declared: true, observed: false}: a contract is never an observed flow');
92
+ if (c.configDigest !== undefined && !isStr(c.configDigest)) err('$.configDigest', 'configDigest must be a string');
93
+ if (!isStr(c.declaredBy)) err('$.declaredBy', 'declaredBy is required');
94
+ return { valid: errors.length === 0, errors };
95
+ }
96
+
97
+ /** Build a well-formed contract (the digest is computed, never trusted from input). */
98
+ export function createBoundaryContract(spec) {
99
+ const c = {
100
+ version: BOUNDARY_CONTRACT_VERSION, provenance: 'manual', evidence: { declared: true, observed: false }, ...spec,
101
+ };
102
+ if (!c.persistence) c.persistence = c.kind === 'storage' ? 'persisted' : c.kind === 'http' ? 'immediate' : 'persisted';
103
+ c.configDigest = contractDigest(c);
104
+ return c;
105
+ }
106
+
107
+ /** Evidence items; each keeps its own kind and basis. `observed` is true only for an observed-connection item. */
108
+ export function evidenceItem(kind, detail = {}) {
109
+ if (!EVIDENCE_KINDS.includes(kind)) throw new TypeError(`unknown evidence kind "${kind}"`);
110
+ return { kind, observed: kind === 'observed-connection', ...detail };
111
+ }
112
+
113
+ export function attributeEvidence(items) {
114
+ const byKind = Object.fromEntries(EVIDENCE_KINDS.map((k) => [k, []]));
115
+ for (const it of items || []) if (it && byKind[it.kind]) byKind[it.kind].push(it);
116
+ return { byKind, observed: byKind['observed-connection'].length > 0 && byKind['observed-connection'].every((i) => i.observed === true), declaredOnly: byKind['observed-connection'].length === 0 };
117
+ }
118
+
119
+ /** Evidence available for one contract. A passed digest check is a digest check, not an observation. */
120
+ export function contractEvidence(c, { binding = null, foreignValidation = null } = {}) {
121
+ const items = [];
122
+ if (c.provenance === 'manual' || c.provenance === 'schema') items.push(evidenceItem('manual-assertion', { declaredBy: c.declaredBy, provenance: c.provenance }));
123
+ if (c.configDigest) items.push(evidenceItem('digest-check', { expected: c.configDigest, actual: contractDigest(c), ok: c.configDigest === contractDigest(c) }));
124
+ if (binding) items.push(evidenceItem('code-mapping', { file: binding.file, line: binding.line, qid: binding.qid }));
125
+ if (foreignValidation) items.push(evidenceItem('foreign-graph-validation', { ok: foreignValidation.ok === true, graphDigest: foreignValidation.graphDigest || null }));
126
+ return items;
127
+ }
128
+
129
+ const paramNameOf = (p) => (typeof p === 'string' ? p : p && p.name);
130
+ const norm = (f) => String(f || '').replace(/^\.\//, '').replace(/\\/g, '/');
131
+
132
+ function resolveSite(site, callGraph) {
133
+ const fns = [...(callGraph && callGraph.functions && callGraph.functions.values ? callGraph.functions.values() : [])];
134
+ const file = norm(site.file);
135
+ const hits = fns.filter((f) => norm(f.file) === file && (f.name === site.function || String(f.name).endsWith(`.${site.function}`)));
136
+ if (hits.length === 1) return { fn: hits[0] };
137
+ if (hits.length > 1) return { gap: 'ambiguous-site', detail: `${hits.length} functions named ${site.function} in ${site.file}` };
138
+ return { gap: fns.some((f) => norm(f.file) === file) ? 'function-not-found' : 'file-not-found', detail: `${site.function} in ${site.file}` };
139
+ }
140
+
141
+ /**
142
+ * Bind consumer (and, when given, producer) sites to parser-resolved functions.
143
+ * Returns `{bindings, gaps, rejected}`; nothing is guessed. A consumer binding carries the access paths to seed.
144
+ */
145
+ export function bindBoundaryContracts(contracts, callGraph, { language = null } = {}) {
146
+ const bindings = []; const gaps = []; const rejected = [];
147
+ for (const c of contracts || []) {
148
+ const v = validateBoundaryContract(c);
149
+ if (!v.valid) { rejected.push({ contractId: c && c.id, errors: v.errors }); continue; }
150
+ const gap = (side, reason, detail, extra = {}) => gaps.push({ contractId: c.id, kind: 'unbound-boundary', side, reason, detail, service: c[side].service, site: c[side].site || null, ...extra });
151
+ if (c.producer.site) {
152
+ const r = resolveSite(c.producer.site, callGraph);
153
+ if (r.gap) gap('producer', r.gap, r.detail);
154
+ }
155
+ const site = c.consumer.site;
156
+ const r = resolveSite(site, callGraph);
157
+ if (r.gap) { gap('consumer', r.gap, r.detail); continue; }
158
+ const fn = r.fn;
159
+ const rec = ((fn.hs && fn.hs.recordParams) || []).find((p) => p.name === site.param) || null;
160
+ if (!(fn.params || []).map(paramNameOf).includes(site.param)) { gap('consumer', 'param-not-found', `${site.param} is not a parameter of ${fn.name}`); continue; }
161
+ if (!c.fieldMappings.length) { gap('consumer', 'no-field-mappings', 'a contract with no field mappings seeds nothing'); continue; }
162
+ const seeds = []; const mapped = [];
163
+ for (const m of c.fieldMappings) {
164
+ if (rec && !(rec.fields || []).includes(m.to)) { gap('consumer', 'field-not-in-record', `${m.to} is not a field of ${rec.type}`, { field: m.to }); continue; }
165
+ seeds.push([site.param, site.recordPath, m.to].filter(Boolean).join('.'));
166
+ mapped.push({ from: m.from, to: m.to, renamed: m.from !== m.to });
167
+ }
168
+ if (!seeds.length) continue;
169
+ bindings.push({
170
+ contractId: c.id, kind: c.kind, persistence: c.persistence, digest: contractDigest(c), schema: c.schema,
171
+ channel: channelKey(c), upstream: { service: c.producer.service, site: c.producer.site || null },
172
+ consumerService: c.consumer.service, qid: fn.qid, file: fn.file, line: fn.line, param: site.param,
173
+ language: language || (/\.l?hs$/i.test(fn.file) ? 'haskell' : null), record: rec ? { type: rec.type, fields: rec.fields } : null,
174
+ originalLocation: { file: fn.file, line: fn.line }, seeds, mappedFields: mapped,
175
+ evidence: contractEvidence(c, { binding: { file: fn.file, line: fn.line, qid: fn.qid } }),
176
+ });
177
+ }
178
+ return { bindings, gaps, rejected };
179
+ }
180
+
181
+ /** The attribution stamped on a finding that came from a bound boundary seed. A declaration is a modeled input only. */
182
+ export function boundaryAttribution(binding) {
183
+ return {
184
+ contractId: binding.contractId, contractVersion: BOUNDARY_CONTRACT_VERSION, digest: binding.digest, kind: binding.kind,
185
+ persistence: binding.persistence, channel: binding.channel, schema: binding.schema, upstream: binding.upstream,
186
+ consumerService: binding.consumerService, param: binding.param, mappedFields: binding.mappedFields,
187
+ modeledInput: true, observed: false, exploited: false, evidence: binding.evidence,
188
+ attribution: attributeEvidence(binding.evidence),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Storage correlation. A write and a later read join only under a declared storage contract that names the same store and
194
+ * table, and (when both sites state it) the same key and schema version. Field names alone never join two sites.
195
+ * @param {object[]} contracts
196
+ * @param {Array<{store,table,key?,schemaVersion?,fields:string[],site?:any}>} writes
197
+ * @param {Array<{store,table,key?,schemaVersion?,fields:string[],site?:any}>} reads
198
+ */
199
+ export function correlateStorage(contracts, writes, reads) {
200
+ const storage = (contracts || []).filter((c) => c.kind === 'storage' && validateBoundaryContract(c).valid);
201
+ const joins = []; const unjoined = [];
202
+ const matches = (c, s) => c.channel.store === s.store && c.channel.table === s.table
203
+ && (!s.key || s.key === c.channel.key) && (!s.schemaVersion || s.schemaVersion === c.schema.version);
204
+ const used = new Set();
205
+ for (const w of writes || []) {
206
+ const c = storage.find((x) => matches(x, w));
207
+ if (!c) { unjoined.push({ side: 'write', site: w.site || null, reason: 'no-storage-contract', store: w.store, table: w.table }); continue; }
208
+ for (const r of reads || []) {
209
+ if (!matches(c, r)) continue;
210
+ used.add(r);
211
+ const map = new Map(c.fieldMappings.map((m) => [m.from, m.to]));
212
+ const fields = (w.fields || []).filter((f) => map.has(f) && (r.fields || []).includes(map.get(f))).map((f) => ({ from: f, to: map.get(f) }));
213
+ joins.push({ contractId: c.id, persistence: 'persisted', immediate: false, store: w.store, table: w.table, key: c.channel.key, schemaVersion: c.schema.version, write: w.site || null, read: r.site || null, fields });
214
+ }
215
+ }
216
+ for (const r of reads || []) if (!used.has(r)) unjoined.push({ side: 'read', site: r.site || null, reason: 'no-storage-contract', store: r.store, table: r.table });
217
+ return { joins, unjoined };
218
+ }
219
+
220
+ export const DEFAULT_SERVICE_ROUNDS = 16;
221
+ export const DEFAULT_SERVICE_FACTS = 2000;
222
+
223
+ /**
224
+ * Which fields are modeled-tainted in which service, through contract edges. The service graph may contain cycles: it is
225
+ * condensed with SCC (graph/scc.js) and each recursive component is solved by a worklist under a round and fact budget.
226
+ * A budget hit is reported (`truncated`), never silently treated as convergence.
227
+ */
228
+ export function analyzeServiceBoundaries(contracts, { entry = {}, maxRounds = DEFAULT_SERVICE_ROUNDS, maxFacts = DEFAULT_SERVICE_FACTS } = {}) {
229
+ const valid = (contracts || []).filter((c) => validateBoundaryContract(c).valid);
230
+ const nodes = sorted([...new Set(valid.flatMap((c) => [c.producer.service, c.consumer.service]))]);
231
+ const edges = valid.map((c) => [c.producer.service, c.consumer.service]);
232
+ const scc = computeScc({ nodes, edges });
233
+ const tainted = new Map(nodes.map((n) => [n, new Set(entry[n] || [])]));
234
+ const origins = new Map(); // service\0field -> {contractId, from}
235
+ let facts = nodes.reduce((a, n) => a + tainted.get(n).size, 0);
236
+ let truncated = false; let rounds = 0; let converged = true;
237
+ const out = new Map();
238
+ for (const c of valid) { if (!out.has(c.producer.service)) out.set(c.producer.service, []); out.get(c.producer.service).push(c); }
239
+
240
+ const step = (svc) => {
241
+ let changed = false;
242
+ for (const c of out.get(svc) || []) {
243
+ const have = tainted.get(svc);
244
+ const dst = tainted.get(c.consumer.service);
245
+ for (const m of c.fieldMappings) {
246
+ if (!(have.has(m.from) || (c.producer.untrustedFields || []).includes(m.from))) continue;
247
+ if (dst.has(m.to)) continue;
248
+ if (facts >= maxFacts) { truncated = true; continue; }
249
+ dst.add(m.to); facts++; changed = true;
250
+ origins.set(`${c.consumer.service}\0${m.to}`, { contractId: c.id, from: `${svc}.${m.from}` });
251
+ }
252
+ }
253
+ return changed;
254
+ };
255
+
256
+ for (const id of scc.order) {
257
+ const comp = scc.components[id];
258
+ if (!comp.recursive) { for (const s of comp.members) step(s); continue; }
259
+ let r = 0; let again = true;
260
+ while (again && r < maxRounds && !truncated) { again = false; r++; for (const s of comp.members) if (step(s)) again = true; }
261
+ rounds = Math.max(rounds, r);
262
+ if (again) { converged = false; }
263
+ }
264
+ if (truncated) converged = false;
265
+ return {
266
+ services: nodes,
267
+ cycles: scc.components.filter((c) => c.recursive).map((c) => c.members),
268
+ taintedFields: Object.fromEntries(nodes.map((n) => [n, sorted([...tainted.get(n)])])),
269
+ origins: Object.fromEntries([...origins].map(([k, v]) => [k.replace('\0', '.'), v])),
270
+ rounds, converged, truncated,
271
+ note: 'Fields are modeled from declarations; none is an observed runtime flow.',
272
+ };
273
+ }
@@ -0,0 +1,205 @@
1
+ // completion.js: the shared completion envelope (DAG-07 R07.1/R07.2, PRD 5.2).
2
+ //
3
+ // One small, pure vocabulary that every long-running or bounded step uses to
4
+ // say how far it got: field analysis, summary computation, source seeding,
5
+ // graph construction, reverse selection and path queries. It imports nothing
6
+ // from any analysis engine, so both the lineage package and the executable
7
+ // taint package can produce and consume it.
8
+ //
9
+ // Two facts are kept apart on purpose:
10
+ // - queryExhausted: did THIS step run to the end of its own frontier?
11
+ // - modelCoverage: does the MODEL it ran over describe the whole software?
12
+ // Exhausting a modeled graph never proves the software has no missing edges,
13
+ // so `negativeClaimAllowed` (the only license to say "no modeled path") needs
14
+ // a complete status, an exhausted query and an empty frontier, and even then
15
+ // the claim is scoped to the modeled graph, never to "no vulnerability".
16
+
17
+ export const COMPLETION_SCHEMA_VERSION = '1.0.0';
18
+
19
+ export const COMPLETION_STATUSES = Object.freeze(['complete', 'partial', 'cancelled', 'unavailable', 'invalid']);
20
+ export const MODEL_COVERAGE = Object.freeze(['complete', 'partial', 'unknown']);
21
+
22
+ // Higher rank wins when records are combined: the combined answer is only as
23
+ // good as its worst part.
24
+ const RANK = { complete: 0, partial: 1, unavailable: 2, cancelled: 3, invalid: 4 };
25
+
26
+ export const NEGATIVE_CLAIM_SCOPE = 'modeled-graph-only';
27
+
28
+ function nonNegInt(v) {
29
+ return Number.isInteger(v) && v >= 0 ? v : 0;
30
+ }
31
+
32
+ function uniqSorted(list) {
33
+ return [...new Set((list ?? []).filter((x) => typeof x === 'string' && x.length > 0))].sort();
34
+ }
35
+
36
+ /**
37
+ * Build a validated completion envelope. Unknown statuses become `invalid`
38
+ * (with a reason) rather than being passed through, so a consumer can trust
39
+ * the vocabulary. `negativeClaimAllowed` is always derived here, never taken
40
+ * from the caller.
41
+ */
42
+ export function makeCompletion(fields = {}) {
43
+ const reasons = uniqSorted(fields.reasons);
44
+ let status = fields.status;
45
+ if (!COMPLETION_STATUSES.includes(status)) {
46
+ status = 'invalid';
47
+ reasons.push('unknown-completion-status');
48
+ }
49
+ const unexplored = nonNegInt(fields.unexploredFrontierCount);
50
+ const modelCoverage = MODEL_COVERAGE.includes(fields.modelCoverage)
51
+ ? fields.modelCoverage
52
+ : (status === 'complete' ? 'complete' : 'partial');
53
+ // Partial work is never an exhausted query, whatever the caller says.
54
+ const queryExhausted = status === 'complete' && fields.queryExhausted !== false && unexplored === 0;
55
+ const env = {
56
+ schemaVersion: COMPLETION_SCHEMA_VERSION,
57
+ status,
58
+ queryExhausted,
59
+ modelCoverage,
60
+ negativeClaimAllowed: status === 'complete' && queryExhausted && unexplored === 0 && reasons.length === 0,
61
+ reasons: uniqSorted(reasons),
62
+ limits: fields.limits && typeof fields.limits === 'object' ? { ...fields.limits } : {},
63
+ unexploredFrontierCount: unexplored,
64
+ };
65
+ if (env.negativeClaimAllowed) env.negativeClaimScope = NEGATIVE_CLAIM_SCOPE;
66
+ if (typeof fields.unit === 'string') env.unit = fields.unit;
67
+ return env;
68
+ }
69
+
70
+ /** Combine several envelopes: worst status wins, reasons union, frontier sums, limits namespaced by unit. */
71
+ export function combineCompletions(list, unit) {
72
+ const items = (list ?? []).filter(Boolean);
73
+ if (items.length === 0) return makeCompletion({ status: 'unavailable', reasons: ['no-completion-records'], unit });
74
+ let status = 'complete';
75
+ let modelCoverage = 'complete';
76
+ let unexplored = 0;
77
+ const reasons = [];
78
+ const limits = {};
79
+ for (const c of items) {
80
+ if ((RANK[c.status] ?? 4) > RANK[status]) status = c.status;
81
+ if (c.modelCoverage === 'partial' || (c.modelCoverage === 'unknown' && modelCoverage === 'complete')) modelCoverage = c.modelCoverage;
82
+ unexplored += nonNegInt(c.unexploredFrontierCount);
83
+ reasons.push(...(c.reasons ?? []));
84
+ if (c.unit) limits[c.unit] = c.limits ?? {};
85
+ }
86
+ return makeCompletion({ status, modelCoverage, unexploredFrontierCount: unexplored, reasons, limits, unit });
87
+ }
88
+
89
+ /**
90
+ * Adapter for the roll-up the field-identity driver / graph builder produce
91
+ * (`analysisCompletion`): {status, functionsAnalyzed, functionsNotAnalyzed,
92
+ * incomplete[], degraded[], storeLimits?, seeding?}.
93
+ */
94
+ export function fromAnalysisCompletion(ac) {
95
+ if (!ac || typeof ac !== 'object') return makeCompletion({ status: 'unavailable', reasons: ['no-analysis-completion-record'], unit: 'analysis' });
96
+ const reasons = [];
97
+ for (const u of ac.incomplete ?? []) reasons.push(...(u.reasons ?? ['analysis-incomplete']));
98
+ for (const d of ac.degraded ?? []) reasons.push(d.reason === 'context-cap' ? 'context-cap' : d.reason === 'recursion-nonconverged' ? 'recursion-nonconverged' : 'degraded-context');
99
+ if (ac.status === 'cancelled') reasons.push('cancelled');
100
+ if (ac.storeLimits?.hit) reasons.push('construction-limit');
101
+ if (ac.seeding && ac.seeding.status && ac.seeding.status !== 'complete') reasons.push(...(ac.seeding.reasons ?? ['seeding-incomplete']));
102
+ if (ac.incremental && ac.incremental.status && ac.incremental.status !== 'complete') reasons.push(...(ac.incremental.reasons ?? []));
103
+ const unfinished = (ac.incomplete ?? []).reduce((n, u) => n + nonNegInt(u.unfinishedWork), 0) + nonNegInt(ac.functionsNotAnalyzed);
104
+ const status = COMPLETION_STATUSES.includes(ac.status) ? ac.status : 'invalid';
105
+ const limits = {
106
+ functionsAnalyzed: nonNegInt(ac.functionsAnalyzed),
107
+ functionsNotAnalyzed: nonNegInt(ac.functionsNotAnalyzed),
108
+ incompleteUnits: (ac.incomplete ?? []).length,
109
+ degradedContexts: (ac.degraded ?? []).length,
110
+ };
111
+ if (ac.storeLimits?.hit) {
112
+ limits.hopsRefused = nonNegInt(ac.storeLimits.hopsRefused);
113
+ limits.groupsSkipped = (ac.storeLimits.groupsSkipped ?? []).length;
114
+ limits.edgesRefused = nonNegInt(ac.storeLimits.edgesRefused);
115
+ }
116
+ return makeCompletion({
117
+ status: status === 'complete' && reasons.length ? 'partial' : status,
118
+ modelCoverage: status === 'complete' && reasons.length === 0 ? 'complete' : 'partial',
119
+ unexploredFrontierCount: unfinished,
120
+ reasons, limits, unit: 'analysis',
121
+ });
122
+ }
123
+
124
+ /** Adapter for a `reconstructPaths` result (path-query.js). */
125
+ export function fromQueryResult(r) {
126
+ if (!r || typeof r !== 'object') return makeCompletion({ status: 'unavailable', reasons: ['no-query-result'], unit: 'query' });
127
+ const reasons = [...(r.truncationReasons ?? [])];
128
+ if (r.status === 'invalid') reasons.push('invalid-budget');
129
+ if (r.unknownStartNode) reasons.push('unknown-start-node');
130
+ if (r.analysisTruncated) reasons.push('analysis-truncated');
131
+ const status = r.status === 'invalid' ? 'invalid' : (r.status === 'cancelled' ? 'cancelled' : (r.status === 'complete' ? 'complete' : 'partial'));
132
+ return makeCompletion({
133
+ status,
134
+ modelCoverage: r.analysisTruncated ? 'partial' : undefined,
135
+ unexploredFrontierCount: nonNegInt(r.unexploredFrontierCount) || nonNegInt(r.droppedPathCount),
136
+ reasons,
137
+ limits: { ...(r.budget ?? {}) },
138
+ unit: 'query',
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Completion of a whole DataFlowGraph, derived from what the graph already
144
+ * carries. A graph with no completion record at all is NOT assumed complete
145
+ * unless its own scanHealth says so; zero projected flows next to an
146
+ * incomplete analysis is called out explicitly, because an empty result is
147
+ * the case where the limit is easiest to miss.
148
+ */
149
+ export function completionOfGraph(graph) {
150
+ if (!graph || typeof graph !== 'object') return makeCompletion({ status: 'unavailable', reasons: ['no-graph'], unit: 'graph' });
151
+ const ac = graph.coverage?.analysisCompletion ?? graph.scanHealth?.analysisCompletion ?? null;
152
+ let env;
153
+ if (ac) {
154
+ env = fromAnalysisCompletion(ac);
155
+ } else if (graph.scanHealth?.status === 'complete') {
156
+ env = makeCompletion({ status: 'complete', modelCoverage: 'complete', unit: 'graph' });
157
+ } else if (graph.scanHealth?.status === 'partial' || graph.scanHealth?.status === 'cancelled') {
158
+ env = makeCompletion({ status: graph.scanHealth.status, reasons: [graph.scanHealth.reason ?? 'scan-health-not-complete'], unit: 'graph' });
159
+ } else {
160
+ env = makeCompletion({ status: 'unavailable', reasons: ['no-completion-record'], unit: 'graph' });
161
+ }
162
+ const flows = Array.isArray(graph.flows) ? graph.flows.length : null;
163
+ if (flows === 0 && env.status !== 'complete') {
164
+ env = makeCompletion({ ...env, reasons: [...env.reasons, 'zero-flows-with-incomplete-analysis'], limits: { ...env.limits, flowsProjected: 0 } });
165
+ } else if (flows !== null) {
166
+ env.limits = { ...env.limits, flowsProjected: flows };
167
+ }
168
+ env.unit = 'graph';
169
+ return env;
170
+ }
171
+
172
+ const REASON_TEXT = {
173
+ 'analysis-iteration-budget': 'a function hit its analysis iteration budget',
174
+ 'context-cap': 'a call context fell back to a coarser summary (context cap)',
175
+ 'recursion-nonconverged': 'recursion did not converge',
176
+ 'degraded-context': 'a call context was degraded',
177
+ cancelled: 'the analysis was cancelled',
178
+ 'construction-limit': 'a provenance construction limit was reached',
179
+ 'zero-flows-with-incomplete-analysis': 'zero flows were projected from an incomplete analysis',
180
+ 'seeding-limit': 'source seeding stopped at its limit',
181
+ 'incremental-empty-entry-reuse': 'a cached empty-entry summary stood in for a tainted entry',
182
+ 'caller-depth-limit': 'the reverse call-graph walk stopped at its depth limit',
183
+ 'worklist-limit': 'the reverse selection stopped at its worklist limit',
184
+ 'slice-node-budget': 'a backward slice stopped at its node budget',
185
+ 'no-completion-record': 'no completion record is available',
186
+ };
187
+
188
+ /** Human-readable lines for CLI output. Empty for a complete envelope. */
189
+ export function describeCompletion(env) {
190
+ if (!env || env.status === 'complete') return [];
191
+ const lines = [`analysis ${env.status}: absence of a flow is not evidence that none exists`];
192
+ for (const r of env.reasons ?? []) lines.push(` - ${REASON_TEXT[r] ?? r}`);
193
+ if (env.unexploredFrontierCount > 0) lines.push(` - ${env.unexploredFrontierCount} unit(s) of work were left unexplored`);
194
+ return lines;
195
+ }
196
+
197
+ /** Wire digest-neutral summary used by scan health. */
198
+ export function summarizeForHealth(env) {
199
+ return {
200
+ status: env.status,
201
+ reasons: env.reasons,
202
+ unexploredFrontierCount: env.unexploredFrontierCount,
203
+ negativeClaimAllowed: env.negativeClaimAllowed,
204
+ };
205
+ }
@@ -0,0 +1,140 @@
1
+ // continuation.js: resumable path queries (DAG-07 R07.9).
2
+ //
3
+ // A path query that stopped at a budget leaves a frontier. This module keeps
4
+ // that frontier in memory under an opaque token so the next page can pick up
5
+ // where the previous one stopped, instead of repeating the work.
6
+ //
7
+ // Resume is a convenience and never a source of soundness:
8
+ // - a token is bound to the EXACT graph digest and to the digest of the
9
+ // query configuration it was issued under; a different graph or a
10
+ // different configuration is rejected, never silently re-based;
11
+ // - tokens expire (TTL) and the store is bounded in entries and bytes, so a
12
+ // client cannot make it grow without limit; the oldest token is evicted
13
+ // first and an evicted token reports `evicted`, never a wrong answer;
14
+ // - a resumed page is always marked `resumed` and can never license a
15
+ // negative claim (path-query.js enforces that), so partial results stay
16
+ // partial however many times they are resumed.
17
+
18
+ import { createHash, randomBytes } from 'node:crypto';
19
+ import { reconstructPaths, DEFAULTS } from './path-query.js';
20
+
21
+ export const CONTINUATION_DEFAULTS = Object.freeze({
22
+ maxEntries: 64,
23
+ maxBytes: 64 * 1024 * 1024,
24
+ ttlMs: 10 * 60 * 1000,
25
+ });
26
+
27
+ function stableJson(v) {
28
+ if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`;
29
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(',')}}`;
30
+ return JSON.stringify(v);
31
+ }
32
+
33
+ /** Digest of the query configuration that affects results (budgets and start). */
34
+ export function configDigest(startNodeId, opts = {}) {
35
+ const budget = {};
36
+ for (const k of Object.keys(DEFAULTS)) budget[k] = opts[k] ?? DEFAULTS[k];
37
+ return createHash('sha256').update(stableJson({ startNodeId, budget })).digest('hex');
38
+ }
39
+
40
+ function approxBytes(state) {
41
+ // Frames and edges are small objects; a coarse, deliberately generous estimate.
42
+ return 256 + (state.stack?.length ?? 0) * 160 + (state.edgesRev?.length ?? 0) * 320 + (state.nodesRev?.length ?? 0) * 96;
43
+ }
44
+
45
+ export class ContinuationStore {
46
+ constructor(limits = {}) {
47
+ const pick = (k) => (Number.isInteger(limits[k]) && limits[k] > 0 ? limits[k] : CONTINUATION_DEFAULTS[k]);
48
+ this.limits = { maxEntries: pick('maxEntries'), maxBytes: pick('maxBytes'), ttlMs: pick('ttlMs') };
49
+ this._now = typeof limits.now === 'function' ? limits.now : Date.now;
50
+ this._entries = new Map(); // token -> {graphDigest, configDigest, state, bytes, expiresAt}
51
+ this._evicted = new Set();
52
+ this._bytes = 0;
53
+ }
54
+
55
+ size() { return this._entries.size; }
56
+ bytes() { return this._bytes; }
57
+
58
+ _drop(token, evicted) {
59
+ const e = this._entries.get(token);
60
+ if (!e) return;
61
+ this._bytes -= e.bytes;
62
+ this._entries.delete(token);
63
+ if (evicted) {
64
+ this._evicted.add(token);
65
+ if (this._evicted.size > 1024) this._evicted.delete(this._evicted.values().next().value);
66
+ }
67
+ }
68
+
69
+ _sweep() {
70
+ const now = this._now();
71
+ for (const [t, e] of this._entries) if (e.expiresAt <= now) this._drop(t, false);
72
+ }
73
+
74
+ /** Store a frontier. Returns {ok, token} or {ok:false, reason:'too-large'}. */
75
+ issue({ graphDigest, configDigest: cfg, state }) {
76
+ this._sweep();
77
+ const bytes = approxBytes(state);
78
+ if (bytes > this.limits.maxBytes) return { ok: false, reason: 'too-large' };
79
+ while (this._entries.size >= this.limits.maxEntries || this._bytes + bytes > this.limits.maxBytes) {
80
+ const oldest = this._entries.keys().next().value;
81
+ if (oldest === undefined) break;
82
+ this._drop(oldest, true);
83
+ }
84
+ const token = `cont:${randomBytes(12).toString('hex')}`;
85
+ this._entries.set(token, { graphDigest, configDigest: cfg, state, bytes, expiresAt: this._now() + this.limits.ttlMs });
86
+ this._bytes += bytes;
87
+ return { ok: true, token };
88
+ }
89
+
90
+ /**
91
+ * Redeem a token against the CURRENT graph and configuration digests.
92
+ * One-shot: a successful redeem removes the entry (the next page issues a
93
+ * new token), so a token cannot be replayed to fork the frontier.
94
+ */
95
+ redeem(token, { graphDigest, configDigest: cfg }) {
96
+ this._sweep();
97
+ const e = this._entries.get(token);
98
+ if (!e) return { ok: false, reason: this._evicted.has(token) ? 'evicted' : 'unknown-or-expired' };
99
+ if (e.graphDigest !== graphDigest) return { ok: false, reason: 'graph-changed' };
100
+ if (e.configDigest !== cfg) return { ok: false, reason: 'config-changed' };
101
+ this._drop(token, false);
102
+ return { ok: true, state: e.state };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Run (or resume) a path query. `continuationToken` in `opts` resumes.
108
+ * Returns the reconstructPaths result plus `continuation`:
109
+ * { token|null, rejected: reason|null }. A token is issued only when the
110
+ * query stopped with work left.
111
+ */
112
+ export function queryWithContinuation(store, continuations, startNodeId, opts = {}) {
113
+ const { continuationToken, ...queryOpts } = opts;
114
+ const graphDigest = store.digest();
115
+ const cfg = configDigest(startNodeId, queryOpts);
116
+ let resumeState;
117
+ if (continuationToken !== undefined) {
118
+ const r = continuations.redeem(continuationToken, { graphDigest, configDigest: cfg });
119
+ if (!r.ok) {
120
+ // Rejected: report invalid, never fall back to a fresh query that
121
+ // would look like the continuation worked.
122
+ return {
123
+ status: 'invalid', paths: [], truncated: true, truncationReasons: [], negativeClaimAllowed: false,
124
+ budgetErrors: [`continuation rejected: ${r.reason}`],
125
+ continuation: { token: null, rejected: r.reason },
126
+ };
127
+ }
128
+ resumeState = r.state;
129
+ }
130
+ const result = reconstructPaths(store, startNodeId, { ...queryOpts, ...(resumeState ? { resumeState } : {}) });
131
+ let token = null;
132
+ let note = null;
133
+ if (result.resumeState) {
134
+ const issued = continuations.issue({ graphDigest, configDigest: cfg, state: result.resumeState });
135
+ if (issued.ok) token = issued.token; else note = issued.reason;
136
+ delete result.resumeState;
137
+ }
138
+ result.continuation = { token, rejected: null, ...(note ? { notIssued: note } : {}) };
139
+ return result;
140
+ }