@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -0,0 +1,126 @@
1
+ // obligation-mapping.js — Milestone 4 sub-project 6a: the ObligationMapping
2
+ // extension contract (FR-504 §7.12, PRD §10.10's own field list).
3
+ //
4
+ // A PURE schema/validation module for ObligationMapping records. Zero
5
+ // imports, matching flow-grade.js's own "pure function library" precedent
6
+ // — this file has even less reason to import anything, since a record's
7
+ // shape check needs no graph traversal.
8
+ //
9
+ // ObligationMapping records are explicitly NOT DataFlowGraph v1 entities
10
+ // (PRD §10.10: extension records are "associated with, but not required
11
+ // inside" the immutable base graph) — never added to
12
+ // dataflow-graph.schema.json, never routed through validate.js's
13
+ // validateGraph(), never given a node:/edge:/flow:/data: canonical ID.
14
+ // See ids.js's obligationId() for the id scheme and its own header
15
+ // comment for why (mirrors provenanceNodeId/provenanceEdgeId's own
16
+ // precedent for "a real, stable-ID'd entity that deliberately is not a
17
+ // base-graph entity").
18
+ //
19
+ // The predicate/mapping ENGINE that actually produces real records from a
20
+ // real graph is a separate, later sub-project — this file only defines
21
+ // what a valid record looks like.
22
+
23
+ // PRD line 503-508's own six states.
24
+ export const OBLIGATION_STATES = Object.freeze([
25
+ 'evidence_supported', 'gap_detected', 'unknown',
26
+ 'manual_required', 'not_applicable', 'accepted_exception',
27
+ ]);
28
+
29
+ // PRD §10.10's cross-cutting fact-typing rule, applied to every extension
30
+ // contract, not just this one.
31
+ export const OBLIGATION_FACT_TYPES = Object.freeze([
32
+ 'code_inferred', 'config_correlated', 'runtime_observed',
33
+ 'declared', 'manual', 'hypothetical',
34
+ ]);
35
+
36
+ // FR-504's own applicability-inputs list (line 512): "entity role,
37
+ // jurisdiction, data subject, business process, merchant level, system
38
+ // scope, AI-system role... must be explicitly configured or marked
39
+ // unknown — never guessed from a field name." Every key defaults to
40
+ // null (== "not configured") rather than being omitted, so a record can
41
+ // never silently lack an input the PRD requires be shown.
42
+ export const APPLICABILITY_INPUT_KEYS = Object.freeze([
43
+ 'entityRole', 'jurisdiction', 'dataSubject', 'businessProcess',
44
+ 'merchantLevel', 'systemScope', 'aiSystemRole',
45
+ ]);
46
+
47
+ function _isNonEmptyString(v) {
48
+ return typeof v === 'string' && v.length > 0;
49
+ }
50
+
51
+ function _isStringOrNull(v) {
52
+ return v === null || v === undefined || typeof v === 'string';
53
+ }
54
+
55
+ function _isStringArray(v) {
56
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
57
+ }
58
+
59
+ /**
60
+ * Structural validation only — no cross-reference into any real graph
61
+ * (this module has zero graph access by design). Returns {valid, errors}
62
+ * — errors is an array of {path, message}, mirroring validate.js's own
63
+ * shape. Never throws.
64
+ */
65
+ export function validateObligationMapping(record) {
66
+ const errors = [];
67
+ const err = (path, message) => errors.push({ path, message });
68
+
69
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
70
+ err('$', 'obligation mapping record must be an object');
71
+ return { valid: false, errors };
72
+ }
73
+
74
+ if (!_isNonEmptyString(record.id) || !record.id.startsWith('obligation:')) {
75
+ err('$.id', 'id is required and must start with "obligation:"');
76
+ }
77
+ if (!_isNonEmptyString(record.graphId)) err('$.graphId', 'graphId is required');
78
+ if (!_isNonEmptyString(record.graphDigest)) err('$.graphDigest', 'graphDigest is required');
79
+ if (!_isNonEmptyString(record.framework)) err('$.framework', 'framework is required');
80
+ if (!_isNonEmptyString(record.frameworkVersion)) err('$.frameworkVersion', 'frameworkVersion is required');
81
+ if (!_isNonEmptyString(record.requirementId)) err('$.requirementId', 'requirementId is required');
82
+ if (!_isStringOrNull(record.requirementSource)) err('$.requirementSource', 'requirementSource must be a string or null');
83
+
84
+ if (!record.applicabilityInputs || typeof record.applicabilityInputs !== 'object' || Array.isArray(record.applicabilityInputs)) {
85
+ err('$.applicabilityInputs', 'applicabilityInputs is required and must be an object');
86
+ } else {
87
+ for (const key of APPLICABILITY_INPUT_KEYS) {
88
+ if (!_isStringOrNull(record.applicabilityInputs[key])) {
89
+ err(`$.applicabilityInputs.${key}`, `applicabilityInputs.${key} must be a string or null`);
90
+ }
91
+ }
92
+ }
93
+
94
+ if (!OBLIGATION_STATES.includes(record.state)) {
95
+ err('$.state', `unrecognized state "${record.state}" — must be one of ${OBLIGATION_STATES.join('|')}`);
96
+ }
97
+ if (!_isNonEmptyString(record.predicate)) err('$.predicate', 'predicate is required');
98
+ if (!OBLIGATION_FACT_TYPES.includes(record.factType)) {
99
+ err('$.factType', `unrecognized factType "${record.factType}" — must be one of ${OBLIGATION_FACT_TYPES.join('|')}`);
100
+ }
101
+
102
+ if (!_isStringArray(record.contributingGraphIds ?? [])) err('$.contributingGraphIds', 'contributingGraphIds must be an array of strings');
103
+ if (!_isStringArray(record.evidence ?? [])) err('$.evidence', 'evidence must be an array of strings');
104
+ if (!_isStringArray(record.conflicts ?? [])) err('$.conflicts', 'conflicts must be an array of strings');
105
+ if (!_isStringArray(record.missingManualArtifacts ?? [])) err('$.missingManualArtifacts', 'missingManualArtifacts must be an array of strings');
106
+
107
+ if (!_isStringOrNull(record.reviewer)) err('$.reviewer', 'reviewer must be a string or null');
108
+ if (!_isStringOrNull(record.reviewedAt)) err('$.reviewedAt', 'reviewedAt must be a string or null');
109
+ if (!_isStringOrNull(record.expiresAt)) err('$.expiresAt', 'expiresAt must be a string or null');
110
+
111
+ // AC-28's own binding rule: PRD line 514 is explicit that
112
+ // evidence_supported means only "this predicate's evidence is
113
+ // supported," never organizational compliance — this module does NOT
114
+ // reject a record for having some null applicability inputs alongside
115
+ // evidence_supported (an input can be genuinely inapplicable to a
116
+ // given predicate). What IS enforced structurally: accepted_exception
117
+ // requires a real reviewer and expiresAt — an exception with no owner
118
+ // or no expiry is exactly the silent-permanent-waiver failure mode
119
+ // this state exists to prevent from being invisible.
120
+ if (record.state === 'accepted_exception') {
121
+ if (!_isNonEmptyString(record.reviewer)) err('$.reviewer', 'accepted_exception requires a reviewer');
122
+ if (!_isNonEmptyString(record.expiresAt)) err('$.expiresAt', 'accepted_exception requires an expiresAt');
123
+ }
124
+
125
+ return { valid: errors.length === 0, errors };
126
+ }
@@ -0,0 +1,235 @@
1
+ // obligation-predicates.js — Milestone 4 sub-project 6b: the graph-fact
2
+ // predicate evaluator + ObligationMapping record builder (FR-504 §7.12).
3
+ //
4
+ // A `graph-flow` predicate spec is a small declarative match object,
5
+ // mirroring dataflow/catalog.js's own established `match`-object +
6
+ // hand-written-matcher pattern (this codebase's convention for "a small
7
+ // declarative query, evaluated by a hand-written function," rather than a
8
+ // general query language) — {type:'graph-flow', dataClass, sinkKind,
9
+ // dimension, requiredVerdict}. No pre-joined "enriched flow" view exists
10
+ // on a shipped DataFlowGraph v1 document, so evaluateGraphFlowPredicate
11
+ // builds its own id->entity Maps and joins flow.dataElementIds/.sink
12
+ // against dataElements[]/nodes[], exactly as graph-builder.js's own
13
+ // internal loops already do.
14
+ //
15
+ // buildObligationMappingFromGraphPredicate mints a real ObligationMapping
16
+ // record (sub-project 6a) from an evaluation result. State derivation
17
+ // (scoping doc ruling 4, REVISED — see the "worst case wins" note below,
18
+ // found by the final whole-branch review): graph absent -> unknown;
19
+ // predicate matches zero relevant flows -> not_applicable; at least one
20
+ // relevant flow is genuinely, ASSESSED-and-failing -> gap_detected; no
21
+ // genuine failure but at least one relevant flow was never assessed ->
22
+ // unknown; every relevant flow was assessed and cleared ->
23
+ // evidence_supported. applicabilityInputs stays all-null (ruling 5 — no
24
+ // operator-config source for it exists anywhere in this codebase yet, a
25
+ // deliberately deferred, separate increment).
26
+ //
27
+ // WORST-CASE-WINS STATE DERIVATION (found by the final whole-branch
28
+ // review, reproduced live against this repo's own real
29
+ // bench/data-lineage/ corpus): the original version collapsed an
30
+ // UNASSESSED protection verdict (`not_assessed`/`unknown` — e.g. every
31
+ // external category other than external-api, which is the only one
32
+ // transit-protection.js actually evaluates) into "failed the requirement"
33
+ // exactly the same as a genuine `unprotected` verdict. On the real
34
+ // corpus this produced a false `gap_detected` — a HIPAA transmission-
35
+ // security gap asserted with zero real evidence — for the ONE non-
36
+ // not_applicable case the predicate reached, and did so silently (no
37
+ // warning, `evidence: []`). This directly violated the same "missing
38
+ // evidence must read as unknown/not_assessed, never a verdict" rule
39
+ // this codebase already leans on for AC-06 (`at-rest-protection.test.js`)
40
+ // and `docs/OSCAL.md`'s own "an unassessed control gets no finding" rule.
41
+ // Fixed by tracking assessed-vs-unassessed per flow and prioritizing a
42
+ // genuine failure > genuine unassessment > genuine success — mirroring
43
+ // `protection.js`'s own `aggregateVerdicts()` risk-precedence
44
+ // convention (the pessimistic answer wins), the established precedent
45
+ // this package already follows elsewhere (`flow-grade.js`'s `gradePath`,
46
+ // `graph-builder.js`'s `flow.protectionSummary` computation).
47
+ //
48
+ // KIND-VS-UNRESOLVED MATCHING (found by the same review, reproduced live
49
+ // against this repo's own AC-07 flagship fixture — PHI reaching an AI
50
+ // model provider): FR-203 (`coverage.js#resolveSiteDecision`) rewrites a
51
+ // sink node's `kind` to `'unresolved'` when its destination can't be
52
+ // statically resolved, retaining `subtype`/`category` — the highest-risk
53
+ // shape (a dynamic destination) was therefore silently excluded from
54
+ // this predicate's own sink-kind filter, reporting `not_applicable`
55
+ // ("this requirement does not apply") for exactly the case that most
56
+ // needs it to apply. Fixed by also matching `kind === 'unresolved'`, but
57
+ // (found by the fix round's own scoped re-review, also reproduced live)
58
+ // NOT unconditionally: FR-203 rewrites `kind: 'unresolved'` for `store`
59
+ // and `queue` sinks too (`coverage.js`'s `FR203_ELIGIBLE_KINDS`), so a
60
+ // bare `kind === 'unresolved'` OR-match let a purely local, in-process
61
+ // database write read as applicable to an `external`-scoped requirement
62
+ // — the exact same false-applicability failure mode as the bug just
63
+ // fixed, just moved one category over. The category is still recoverable
64
+ // via `sinkNode.subtype` (FR-203 retains it) and `CATEGORY_NODE_KIND`
65
+ // (`sink-registry.js`), the same table `graph-builder.js` used to assign
66
+ // the node's kind in the first place — so an unresolved sink now only
67
+ // matches when ITS OWN real category still maps to `spec.sinkKind`. Once
68
+ // matched, the worst-case-wins state derivation above correctly resolves
69
+ // it to `unknown` (an unresolved destination has no real transit verdict
70
+ // either), never a false `not_applicable` or a false `gap_detected`.
71
+ //
72
+ // EVIDENCE GAP, DISCLOSED (found by the same review): `evidence` is
73
+ // sourced from `edge.evidenceRefs`, but `graph-builder.js` hardcodes
74
+ // every real edge's `evidenceRefs` to `[]` and nothing downstream ever
75
+ // populates it — confirmed by grep, zero write sites beyond that one
76
+ // literal. So `record.evidence` is structurally always empty on any
77
+ // graph the real pipeline produces, including a genuine
78
+ // `evidence_supported` record. This is a real, upstream gap in
79
+ // `graph-builder.js` (no edge-level evidence producer exists for
80
+ // protection verdicts today), not something this module can close on
81
+ // its own — named here rather than silently shipped as if it worked.
82
+ //
83
+ // KNOWN LIMITATION: evaluateGraphFlowPredicate reads only
84
+ // flow.edgeIds[0] — a flow with multiple edges is evaluated using its
85
+ // first edge only, silently ignoring the rest. Not currently reachable:
86
+ // graph-builder.js (the only real producer of flows) always mints a
87
+ // single-element edgeIds array. This is a latent gap for any future
88
+ // multi-edge-flow producer, or a hand-built graph conforming to the
89
+ // schema (which places no length constraint on flow.edgeIds) — worth
90
+ // revisiting (e.g. worst-verdict-wins across all edges, mirroring
91
+ // aggregateVerdicts()'s own precedent) if that assumption ever changes.
92
+ //
93
+ // NEVER THROWS, defensively, not just by convention (found by the same
94
+ // review: 7 of 11 malformed-graph shapes tried threw a raw TypeError
95
+ // that propagated all the way out of auditor-walkthrough.js's
96
+ // evaluateFramework, losing every control's evaluation for the whole
97
+ // framework — a total compliance-report outage from one degraded
98
+ // graph). Every array field is defensively coerced via Array.isArray
99
+ // before use; a non-object entry inside an array is filtered out rather
100
+ // than dereferenced.
101
+
102
+ import { computeGraphDigest } from './export-json.js';
103
+ import { obligationId } from './ids.js';
104
+ import { CATEGORY_NODE_KIND } from './sink-registry.js';
105
+
106
+ function _asArray(v) {
107
+ return Array.isArray(v) ? v : [];
108
+ }
109
+
110
+ export function evaluateGraphFlowPredicate(spec, graph) {
111
+ const dataElementsById = new Map(_asArray(graph?.dataElements).filter(Boolean).map((d) => [d.id, d]));
112
+ const nodesById = new Map(_asArray(graph?.nodes).filter(Boolean).map((n) => [n.id, n]));
113
+ const edgesById = new Map(_asArray(graph?.edges).filter(Boolean).map((e) => [e.id, e]));
114
+
115
+ const relevantFlows = _asArray(graph?.flows).filter((f) => {
116
+ if (!f || typeof f !== 'object') return false;
117
+ const des = _asArray(f.dataElementIds).map((id) => dataElementsById.get(id)).filter(Boolean);
118
+ const hasClass = des.some((d) => _asArray(d.dataClasses).includes(spec.dataClass));
119
+ if (!hasClass) return false;
120
+ const sinkNode = nodesById.get(f.sink);
121
+ if (!sinkNode) return false;
122
+ // Also match 'unresolved', but only when the sink's OWN real category
123
+ // (subtype) still maps to spec.sinkKind — see the KIND-VS-UNRESOLVED
124
+ // MATCHING note above. A bare kind==='unresolved' OR-match would also
125
+ // admit unresolved store/queue sinks, which FR-203 rewrites the same
126
+ // way.
127
+ return sinkNode.kind === spec.sinkKind ||
128
+ (sinkNode.kind === 'unresolved' && CATEGORY_NODE_KIND[sinkNode.subtype] === spec.sinkKind);
129
+ });
130
+
131
+ if (relevantFlows.length === 0) {
132
+ return {
133
+ applicable: false, matched: null, hasFailure: false, hasUnassessed: false,
134
+ contributingGraphIds: [], evidence: [], resultsCount: 0, failedCount: 0, notAssessedCount: 0,
135
+ };
136
+ }
137
+
138
+ // A verdict of 'not_assessed'/'unknown'/'not_applicable' means "never
139
+ // checked" (or "this dimension doesn't apply here"), never "checked and
140
+ // failed" — see the WORST-CASE-WINS STATE DERIVATION note above for why
141
+ // this distinction is load-bearing. 'not_applicable' is included
142
+ // defensively (found by the fix round's own scoped re-review): no real
143
+ // producer emits it on a `protection.<dimension>.verdict` today, but it
144
+ // IS a member of `protection.js`'s own `PROTECTION_VERDICTS`, and that
145
+ // module's own `_PRECEDENCE` ranks it below `protected` — treating it as
146
+ // a failure here would contradict the very precedent this worst-case-
147
+ // wins ordering is modeled on.
148
+ const UNASSESSED_VERDICTS = new Set(['not_assessed', 'unknown', 'not_applicable']);
149
+ const results = relevantFlows.map((f) => {
150
+ const edge = edgesById.get(_asArray(f.edgeIds)[0]);
151
+ const verdict = edge?.protection?.[spec.dimension]?.verdict ?? 'not_assessed';
152
+ const assessed = !UNASSESSED_VERDICTS.has(verdict);
153
+ return { flow: f, edge, verdict, assessed, cleared: verdict === spec.requiredVerdict };
154
+ });
155
+
156
+ const hasFailure = results.some((r) => r.assessed && !r.cleared);
157
+ const hasUnassessed = results.some((r) => !r.assessed);
158
+
159
+ return {
160
+ applicable: true,
161
+ // Kept for backward compatibility with any caller reading the
162
+ // simple boolean — true only in the unambiguous "every relevant
163
+ // flow was assessed and cleared" case; buildObligationMappingFromGraphPredicate
164
+ // itself reads hasFailure/hasUnassessed directly for the real,
165
+ // worst-case-wins state derivation.
166
+ matched: !hasFailure && !hasUnassessed,
167
+ hasFailure,
168
+ hasUnassessed,
169
+ contributingGraphIds: results.map((r) => r.flow.id).filter((id) => typeof id === 'string'),
170
+ // Filtered to strings, matching the contributingGraphIds hardening
171
+ // just above and validateObligationMapping's own "evidence must be an
172
+ // array of strings" invariant (found by the fix round's own scoped
173
+ // re-review: a malformed edge.evidenceRefs entry used to reach the
174
+ // record unfiltered and fail validation downstream instead of here).
175
+ evidence: results.flatMap((r) => _asArray(r.edge?.evidenceRefs)).filter((e) => typeof e === 'string'),
176
+ resultsCount: results.length,
177
+ failedCount: results.filter((r) => r.assessed && !r.cleared).length,
178
+ notAssessedCount: results.filter((r) => !r.assessed).length,
179
+ };
180
+ }
181
+
182
+ const _NULL_APPLICABILITY_INPUTS = Object.freeze({
183
+ entityRole: null, jurisdiction: null, dataSubject: null, businessProcess: null,
184
+ merchantLevel: null, systemScope: null, aiSystemRole: null,
185
+ });
186
+
187
+ export function buildObligationMappingFromGraphPredicate({
188
+ framework, frameworkVersion, requirementId, requirementSource, predicateLabel, graph, evaluation,
189
+ }) {
190
+ const graphId = graph?.graphId ?? null;
191
+ const graphDigest = graph ? computeGraphDigest(graph) : null;
192
+
193
+ // Found by this task's own review: a caller-contract violation (a
194
+ // truthy graph paired with a missing/null evaluation — i.e. forgetting
195
+ // to call evaluateGraphFlowPredicate first) used to throw here,
196
+ // inconsistent with this package's "public API never throws on
197
+ // malformed input" convention (obligation-mapping.js's own JSDoc,
198
+ // path-query.js, flow-grade.js). Not reachable through this sub-
199
+ // project's own documented calling convention (Task 2's wiring always
200
+ // pairs graph/evaluation correctly), but degrading to 'unknown' here
201
+ // — the same answer a genuinely absent graph gets — is honest and
202
+ // matches every sibling module's own never-throw contract.
203
+ //
204
+ // Worst-case-wins (found by the final whole-branch review — see the
205
+ // WORST-CASE-WINS STATE DERIVATION note at the top of this file): a
206
+ // genuine assessed failure always outranks a genuine unassessment,
207
+ // which always outranks a genuine clean pass.
208
+ let state;
209
+ if (!graph || !evaluation) state = 'unknown';
210
+ else if (!evaluation.applicable) state = 'not_applicable';
211
+ else if (evaluation.hasFailure) state = 'gap_detected';
212
+ else if (evaluation.hasUnassessed) state = 'unknown';
213
+ else state = 'evidence_supported';
214
+
215
+ return {
216
+ id: obligationId({ framework, frameworkVersion, requirementId, graphId: graphId ?? '', graphDigest: graphDigest ?? '' }),
217
+ graphId: graphId ?? '(no graph)',
218
+ graphDigest: graphDigest ?? '(no graph)',
219
+ framework,
220
+ frameworkVersion,
221
+ requirementId,
222
+ requirementSource: requirementSource ?? null,
223
+ applicabilityInputs: { ..._NULL_APPLICABILITY_INPUTS },
224
+ state,
225
+ predicate: predicateLabel,
226
+ factType: 'code_inferred',
227
+ contributingGraphIds: evaluation?.contributingGraphIds ?? [],
228
+ evidence: evaluation?.evidence ?? [],
229
+ conflicts: [],
230
+ missingManualArtifacts: [],
231
+ reviewer: null,
232
+ reviewedAt: null,
233
+ expiresAt: null,
234
+ };
235
+ }
@@ -0,0 +1,282 @@
1
+ // observation-adapters.js — the RuntimeObservation ADAPTER interface, plus
2
+ // the one shipped implementation (`native-jsonl`) (M5 deliverable #7,
3
+ // "Runtime-Corroborated Digital Twin" — the runtime-observed half only,
4
+ // "7b"; "7a", config-declared edges, is out of scope for this whole
5
+ // sub-project — see the scoping doc's §4.0). FR-505, AC-29. Pure by
6
+ // contract: zero imports (mirrors `runtime-observation.js`'s/
7
+ // `observation-correlation.js`'s/`flow-grade.js`'s own precedent, one step
8
+ // stricter even than `observation-correlation.js`'s single-file import
9
+ // list — see AD/6a), no fs, no I/O, never throws.
10
+ //
11
+ // ── Why only ONE adapter ships here ─────────────────────────────────────
12
+ //
13
+ // FR-505 itself names an open-ended adapter list: "beginning with approved
14
+ // OpenTelemetry traces/service graphs, gateway/mesh metadata, cloud flow
15
+ // metadata, and application-provided schema-safe events." The scoping
16
+ // doc's §4.3 ruling is to ship the INTERFACE plus a native, operator-
17
+ // authored JSONL adapter first, and defer OTLP to a second increment —
18
+ // not because OTLP is unimportant, but because an OTLP `resourceSpans`
19
+ // document carries an unbounded attribute surface (`http.url` with a live
20
+ // query string, `db.statement`, `messaging.message.payload`, arbitrary
21
+ // `gen_ai.*` prompt/completion attributes under active, ongoing extension
22
+ // by the OpenTelemetry semantic-conventions project). Mapping that surface
23
+ // safely means writing and defending an allowlist against an adversarial,
24
+ // EVOLVING external vocabulary — real, separately-scoped work with its
25
+ // own review, not something to bundle into the increment that also
26
+ // invents the RuntimeObservation contract (Task 1), the correlation match
27
+ // ladder (Task 2), and the import-keyed store (Task 4). This module exists
28
+ // so THAT increment is "write a second implementation of an
29
+ // already-proven interface," not "invent the interface and the mapping at
30
+ // the same time."
31
+ //
32
+ // ── Why external evidence arrives as a FILE, never live ingestion ──────
33
+ //
34
+ // FR-505's own no-egress rule, the root `CLAUDE.md`'s "no runtime cloud
35
+ // calls" convention, and `posture/runtime-correlation.js`'s own already-
36
+ // proven offline-file contract (an operator-supplied eBPF trace JSONL,
37
+ // read from disk, never pulled from a live collector by this codebase)
38
+ // all point the same direction: this module takes a TEXT STRING an
39
+ // operator has already exported and already has on disk, and returns
40
+ // drafts — it has no fetch, no socket, no fs call of any kind (AD/6a pins
41
+ // this structurally, not just by convention). The CLI that will eventually
42
+ // call this (a future task) is what reads the file; this module never
43
+ // does.
44
+ //
45
+ // ── Why the wire key set is CLOSED, exactly like the record it feeds ───
46
+ //
47
+ // `runtime-observation.js`'s own header explains at length why a
48
+ // RuntimeObservation is closed-world rather than open-world: it is built
49
+ // from operator-supplied telemetry this codebase never generated and
50
+ // cannot vouch for, and PRD line 983's own "reject fields capable of
51
+ // carrying payload values" requirement is a REJECT requirement, not a
52
+ // redact-and-hope one. That reasoning applies one layer further out, at
53
+ // the WIRE itself: `NATIVE_JSONL_RECORD_KEYS` is a closed allowlist of the
54
+ // only top-level JSON keys a native-JSONL record may carry, and any other
55
+ // top-level key — an attacker's `prompt`, an operator's well-meaning
56
+ // `note`, or (the smuggling attempt nobody would think to test on their
57
+ // own) a pre-declared `matchedNodeIds`/`matchConfidence` — is rejected,
58
+ // never ignored (AD/4c). `id`/`matchedNodeIds`/`matchedEdgeIds`/
59
+ // `matchedFlowIds`/`matchMethod`/`matchConfidence` are refused from the
60
+ // wire for a reason specific to THIS module: those six fields are
61
+ // minted/derived downstream (an id at import time, a match at correlation
62
+ // time) — if the wire could supply them directly, a compromised exporter,
63
+ // or an operator simply copy-pasting an already-matched record from one
64
+ // import into a hand-edited new one, could dictate what the graph BELIEVES
65
+ // was observed, bypassing the match ladder (`observation-correlation.js`,
66
+ // Task 2) entirely. None of the six are members of `NATIVE_JSONL_RECORD_KEYS`,
67
+ // so the SAME closed-world sweep that rejects an unrecognized field also
68
+ // rejects every one of these — there is no separate check to keep in sync
69
+ // (AD/4b, error 4).
70
+ //
71
+ // ── The two-layer rejection split (this module vs. `validateRuntimeObservation`) ──
72
+ //
73
+ // This module enforces the WIRE SHAPE only: `attributes` must be a plain
74
+ // object whose every value is a scalar (string/number/boolean) or an
75
+ // array of strings, no top-level key outside `NATIVE_JSONL_RECORD_KEYS`,
76
+ // and `eventCountBand`/`firstObservedAt`/`lastObservedAt` must each be a
77
+ // non-empty string. It does NOT validate WHICH attribute keys are
78
+ // approved — `validateRuntimeObservation` (Task 1, `runtime-observation.js`)
79
+ // is the single authority on that allowlist, and this module never
80
+ // duplicates it (a duplicated allowlist is exactly the kind of thing that
81
+ // silently drifts). Against the payload fixture, this means the adapter
82
+ // itself catches only 2 of the fixture's 4 smuggling attempts (line 3's
83
+ // top-level `prompt`, line 4's `matchedNodeIds`/`matchConfidence`) — the
84
+ // other two (line 1's `http.url` attribute key, line 2's `db.statement`
85
+ // attribute key) are structurally fine at the wire layer and are caught
86
+ // one layer up, by `validateRuntimeObservation`, at IMPORT time
87
+ // (`CLI/import-4`, a future task's own test — not a hole in this one).
88
+ // Any adapter error means the whole file yields nothing usable: this
89
+ // module always returns whatever drafts it produced regardless of
90
+ // `errors.length`, but the IMPORT command (a future task) refuses the
91
+ // file WHOLE the moment `errors.length > 0` — that refuse-the-whole-import
92
+ // behavior is that future task's own to prove, not this module's.
93
+ //
94
+ // ── The native-JSONL wire format, worked example ────────────────────────
95
+ //
96
+ // One JSON object per line (no comments — JSONL admits none), each with
97
+ // up to 5 top-level keys (`NATIVE_JSONL_RECORD_KEYS`):
98
+ //
99
+ // {"environment":"production","attributes":{"destination.host":"api.stripe.com","destination.scheme":"https","tls.version":"1.3"},"eventCountBand":"101-1k","firstObservedAt":"2026-08-02T10:00:00.000Z","lastObservedAt":"2026-08-30T10:00:00.000Z"}
100
+ //
101
+ // `environment` is optional per line — when omitted, the record inherits
102
+ // the default environment of the surrounding call, `context.environment`
103
+ // (supplied by the CALLER, never by the file). `attributes` keys must be
104
+ // drawn from `RUNTIME_ATTRIBUTE_KEYS` (`runtime-observation.js`) — this
105
+ // module does not check that, but a key outside that list will be
106
+ // rejected one layer up, at import time. `eventCountBand` is a BAND
107
+ // (`RUNTIME_OBSERVATION_VERSION`'s sibling `EVENT_COUNT_BANDS`, e.g.
108
+ // `'1'`/`'2-10'`/`'11-100'`/`'101-1k'`/`'1k+'`), never a raw count.
109
+ // `firstObservedAt`/`lastObservedAt` are ISO-8601 date-times.
110
+ //
111
+ // ── `version` is caller-supplied, not read from `runtime-observation.js` ──
112
+ //
113
+ // This is precisely why this module has NO import at all, not even of
114
+ // `runtime-observation.js`'s own `RUNTIME_OBSERVATION_VERSION` constant:
115
+ // the CLI (a future task) passes that value in as `context.version`. A
116
+ // zero-import module can never itself drift from the contract it feeds —
117
+ // there is no cached/stale constant here to go stale.
118
+
119
+ // The 5 allowed top-level keys of a native-JSONL wire record. Any other
120
+ // top-level key — including every one of the six RuntimeObservation
121
+ // fields that are minted/derived downstream (`id`, `matchedNodeIds`,
122
+ // `matchedEdgeIds`, `matchedFlowIds`, `matchMethod`, `matchConfidence`) —
123
+ // is rejected by the closed-world sweep below, never ignored.
124
+ export const NATIVE_JSONL_RECORD_KEYS = Object.freeze([
125
+ 'environment', 'attributes', 'eventCountBand', 'firstObservedAt', 'lastObservedAt',
126
+ ]);
127
+
128
+ function _isPlainObject(v) {
129
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
130
+ }
131
+
132
+ function _isNonEmptyString(v) {
133
+ return typeof v === 'string' && v.length > 0;
134
+ }
135
+
136
+ function _isScalar(v) {
137
+ const t = typeof v;
138
+ return t === 'string' || t === 'number' || t === 'boolean';
139
+ }
140
+
141
+ // The WIRE shape only — never which attribute keys are approved. A value
142
+ // must be a scalar, or an array of strings (mirroring
143
+ // `RUNTIME_ARRAY_ATTRIBUTE_KEYS`'s own shape one layer up, without this
144
+ // module knowing which specific key that applies to).
145
+ function _attributesShapeOk(attrs) {
146
+ if (!_isPlainObject(attrs)) return false;
147
+ for (const value of Object.values(attrs)) {
148
+ if (Array.isArray(value)) {
149
+ if (!value.every((x) => typeof x === 'string')) return false;
150
+ } else if (!_isScalar(value)) {
151
+ return false;
152
+ }
153
+ }
154
+ return true;
155
+ }
156
+
157
+ /**
158
+ * Parses native-JSONL runtime-observation text into RuntimeObservation
159
+ * DRAFTS — every field a RuntimeObservation record needs EXCEPT `id`,
160
+ * `matchedNodeIds`, `matchedEdgeIds`, `matchedFlowIds`, `matchMethod`,
161
+ * `matchConfidence`, which are minted/derived downstream and can never be
162
+ * supplied by the wire. `context` is
163
+ * `{version, adapter, source, environment, windowStart, windowEnd, importedAt, retention}`
164
+ * — every draft carries these verbatim, EXCEPT `environment`, which a
165
+ * per-line `environment` key overrides.
166
+ *
167
+ * Never throws. `text` must be a string and `context` a plain object, or
168
+ * this returns `{drafts: [], errors: [{line: 0, message: '...'}]}`
169
+ * immediately. Blank lines (and the trailing newline JSONL files
170
+ * ordinarily end with) are skipped silently — never reported as errors.
171
+ * A malformed line (invalid JSON, wrong top-level shape, an unapproved
172
+ * top-level key, a malformed `attributes`/`eventCountBand`/
173
+ * `firstObservedAt`/`lastObservedAt`) yields exactly ONE `{line, message}`
174
+ * error for that line (`line` 1-based) and is skipped — every OTHER line
175
+ * still parses, so an operator sees every problem in one pass rather than
176
+ * stopping at the first.
177
+ */
178
+ export function parseNativeJsonlObservations(text, context) {
179
+ if (typeof text !== 'string' || !_isPlainObject(context)) {
180
+ return {
181
+ drafts: [],
182
+ errors: [{ line: 0, message: 'parseNativeJsonlObservations requires a string text and a plain-object context' }],
183
+ };
184
+ }
185
+
186
+ const {
187
+ version, adapter, source, environment: defaultEnvironment,
188
+ windowStart, windowEnd, importedAt, retention,
189
+ } = context;
190
+
191
+ const drafts = [];
192
+ const errors = [];
193
+
194
+ const lines = text.split(/\r?\n/);
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const rawLine = lines[i];
197
+ if (rawLine.trim().length === 0) continue; // blank line, or the trailing newline's own empty tail
198
+ const line = i + 1;
199
+
200
+ let record;
201
+ try {
202
+ record = JSON.parse(rawLine);
203
+ } catch {
204
+ // M2 (final review): every OTHER rejection path in this module is
205
+ // key-only/value-free by design — V8's own JSON.parse error message
206
+ // quotes a snippet of the offending input (`e.message`), which would
207
+ // make this the sole value-echo channel in the whole command. Name
208
+ // only the line number, never the parse error's own text.
209
+ errors.push({ line, message: `invalid JSON on line ${line}` });
210
+ continue;
211
+ }
212
+
213
+ if (!_isPlainObject(record)) {
214
+ const gotType = record === null ? 'null' : Array.isArray(record) ? 'array' : typeof record;
215
+ errors.push({ line, message: `record must be a JSON object, got ${gotType}` });
216
+ continue;
217
+ }
218
+
219
+ const unknownKeys = Object.keys(record).filter((k) => !NATIVE_JSONL_RECORD_KEYS.includes(k));
220
+ if (unknownKeys.length > 0) {
221
+ errors.push({
222
+ line,
223
+ message: `unknown field(s) not permitted on the wire: ${unknownKeys.join(', ')} — native-JSONL records are closed-world (mirroring RuntimeObservation's own closed-world rule): only ${NATIVE_JSONL_RECORD_KEYS.join('/')} are accepted, and this rejects every one of id/matchedNodeIds/matchedEdgeIds/matchedFlowIds/matchMethod/matchConfidence too, since none are members of that allowlist`,
224
+ });
225
+ continue;
226
+ }
227
+
228
+ if (!_attributesShapeOk(record.attributes)) {
229
+ errors.push({
230
+ line,
231
+ message: 'attributes must be a plain object whose every value is a scalar (string/number/boolean) or an array of strings',
232
+ });
233
+ continue;
234
+ }
235
+
236
+ if (!_isNonEmptyString(record.eventCountBand)
237
+ || !_isNonEmptyString(record.firstObservedAt)
238
+ || !_isNonEmptyString(record.lastObservedAt)) {
239
+ errors.push({
240
+ line,
241
+ message: 'eventCountBand, firstObservedAt, and lastObservedAt must each be present and a non-empty string',
242
+ });
243
+ continue;
244
+ }
245
+
246
+ drafts.push({
247
+ version,
248
+ adapter,
249
+ source,
250
+ environment: 'environment' in record ? record.environment : defaultEnvironment,
251
+ windowStart,
252
+ windowEnd,
253
+ attributes: record.attributes,
254
+ eventCountBand: record.eventCountBand,
255
+ firstObservedAt: record.firstObservedAt,
256
+ lastObservedAt: record.lastObservedAt,
257
+ retention,
258
+ importedAt,
259
+ });
260
+ }
261
+
262
+ return { drafts, errors };
263
+ }
264
+
265
+ // The adapter registry — a frozen, module-level lookup. `adapterFor`
266
+ // returns `null` for anything unknown, NEVER a default: an unimplemented
267
+ // adapter name (e.g. `'otlp'`, deferred per the scoping doc's §4.3 ruling)
268
+ // must be rejected outright, not silently accepted on faith that it will
269
+ // someday exist.
270
+ const _ADAPTERS = Object.freeze({
271
+ 'native-jsonl': Object.freeze({ name: 'native-jsonl', parse: parseNativeJsonlObservations }),
272
+ });
273
+
274
+ /**
275
+ * `adapterFor(name) -> {name, parse} | null` — the registry lookup.
276
+ * `parse` has the `(text, context) -> {drafts, errors}` signature of
277
+ * `parseNativeJsonlObservations` above.
278
+ */
279
+ export function adapterFor(name) {
280
+ if (typeof name !== 'string') return null;
281
+ return Object.prototype.hasOwnProperty.call(_ADAPTERS, name) ? _ADAPTERS[name] : null;
282
+ }