@obsunified/collector 2.1.1 → 2.2.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 (37) hide show
  1. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  2. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  3. package/dist/index.d.ts +359 -2
  4. package/dist/index.js +5000 -334
  5. package/dist/index.js.map +1 -1
  6. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  7. package/package.json +2 -2
  8. package/src/__tests__/autosre-closed-loop.test.ts +2222 -0
  9. package/src/index.ts +43 -0
  10. package/src/lib/analyses-runner.ts +46 -0
  11. package/src/lib/autosre-change-correlation.ts +807 -0
  12. package/src/lib/autosre-findings.ts +763 -0
  13. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  14. package/src/lib/autosre-ontology.ts +938 -0
  15. package/src/lib/autosre-remediation-gate.ts +2127 -0
  16. package/src/lib/eval-cases-store.ts +63 -1
  17. package/src/lib/evidence-references.ts +6 -0
  18. package/src/lib/housekeeping.test.ts +150 -0
  19. package/src/lib/housekeeping.ts +109 -1
  20. package/src/lib/store/ingest.ts +46 -6
  21. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  22. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  23. package/src/migrations/alert-state-notified.test.ts +3 -3
  24. package/src/migrations/parity.test.ts +4 -0
  25. package/src/migrations/project-scoped-keys.test.ts +1 -1
  26. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  27. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  28. package/src/plugins/alerts-evaluator.ts +29 -1
  29. package/src/plugins/autosre-routes.ts +1134 -0
  30. package/src/plugins/connected-routes/manifest.ts +10 -1
  31. package/src/plugins/connected-routes.ts +241 -0
  32. package/src/plugins/default-span-enrichment.ts +13 -1
  33. package/src/plugins/eval-cases-routes.ts +57 -2
  34. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  35. package/src/plugins/evidence-retrieval-routes.ts +643 -5
  36. package/dist/chunk-XKQTVIRE.js.map +0 -1
  37. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -0,0 +1,763 @@
1
+ import type {
2
+ CausalProofTier,
3
+ CausalTriple,
4
+ CausalVerificationStatus,
5
+ CohortAttributionVerdict,
6
+ ConfidenceBreakdown,
7
+ EvidenceEntityKind,
8
+ FindingFingerprintInput,
9
+ InvestigationFinding,
10
+ InvestigationFindingInput,
11
+ PairedCounterfactualProof,
12
+ RecoveryStatus,
13
+ TelemetryCodeReference,
14
+ } from "@obsunified/types";
15
+ import {
16
+ buildCanonicalNodeId,
17
+ buildFindingFingerprint,
18
+ } from "@obsunified/types";
19
+ import { consoleLogger, type Logger } from "../framework/logger";
20
+ import { computeChangeBlastRadius } from "./autosre-change-correlation";
21
+ import { randomHex } from "./hash";
22
+ import { parseJsonArray, parseJsonRecord } from "./json";
23
+ import type { SqlDb } from "./sql-db";
24
+
25
+ interface FindingRow {
26
+ project_id: string;
27
+ environment: string;
28
+ id: string;
29
+ incident_id: string | null;
30
+ fingerprint: string;
31
+ fingerprint_input_json: string;
32
+ title: string;
33
+ summary: string;
34
+ causal_proof_tier: string;
35
+ confidence: number;
36
+ confidence_breakdown_json: string;
37
+ symptom_entity_kind: string;
38
+ symptom_entity_id: string;
39
+ fault_locus_entity_kind: string;
40
+ fault_locus_entity_id: string;
41
+ root_cause_entity_kind: string | null;
42
+ root_cause_entity_id: string | null;
43
+ contributing_change_id: string | null;
44
+ cohort_verdict: string | null;
45
+ contending_trace_id: string | null;
46
+ causal_chain_json: string;
47
+ cited_evidence_ids_json: string;
48
+ code_references_json: string | null;
49
+ counterfactual_proof_json: string | null;
50
+ recovery_status: string;
51
+ causal_verification_status: string;
52
+ watchdog_step: number;
53
+ baseline_request_rate_per_min: number | null;
54
+ next_watchdog_check_at: string | null;
55
+ actor_type: string;
56
+ actor_id: string;
57
+ agent_run_id: string | null;
58
+ created_at: string;
59
+ updated_at: string;
60
+ expires_at: string | null;
61
+ }
62
+
63
+ export const mapFindingRow = (row: FindingRow): InvestigationFinding => {
64
+ const rawFpInput = parseJsonRecord(row.fingerprint_input_json);
65
+ const fingerprintInput: FindingFingerprintInput = {
66
+ environment:
67
+ typeof rawFpInput.environment === "string"
68
+ ? rawFpInput.environment
69
+ : row.environment,
70
+ originatingNodeId:
71
+ typeof rawFpInput.originatingNodeId === "string"
72
+ ? rawFpInput.originatingNodeId
73
+ : row.fault_locus_entity_id,
74
+ normalizedOperation:
75
+ typeof rawFpInput.normalizedOperation === "string"
76
+ ? rawFpInput.normalizedOperation
77
+ : row.title,
78
+ errorSignature:
79
+ typeof rawFpInput.errorSignature === "string"
80
+ ? rawFpInput.errorSignature
81
+ : "ERROR",
82
+ downstreamTargetNodeId:
83
+ typeof rawFpInput.downstreamTargetNodeId === "string"
84
+ ? rawFpInput.downstreamTargetNodeId
85
+ : null,
86
+ };
87
+
88
+ const rawBreakdown = parseJsonRecord(row.confidence_breakdown_json);
89
+ const confidenceBreakdown: ConfidenceBreakdown = {
90
+ relationshipScore: Number(rawBreakdown.relationshipScore ?? 0.5),
91
+ cohortLiftScore: Number(rawBreakdown.cohortLiftScore ?? 0),
92
+ temporalProximityScore: Number(rawBreakdown.temporalProximityScore ?? 0.5),
93
+ counterfactualBonus: Number(rawBreakdown.counterfactualBonus ?? 0),
94
+ priorRecurrenceBonus: Number(rawBreakdown.priorRecurrenceBonus ?? 0),
95
+ finalConfidence: Number(rawBreakdown.finalConfidence ?? row.confidence),
96
+ };
97
+
98
+ let counterfactualProof: PairedCounterfactualProof | null = null;
99
+ if (row.counterfactual_proof_json) {
100
+ const rawCf = parseJsonRecord(row.counterfactual_proof_json);
101
+ if (typeof rawCf.evalCaseId === "string") {
102
+ counterfactualProof = {
103
+ evalCaseId: rawCf.evalCaseId,
104
+ inputPayloadHash: String(rawCf.inputPayloadHash ?? ""),
105
+ failingRunId: String(rawCf.failingRunId ?? ""),
106
+ failingVersion:
107
+ typeof rawCf.failingVersion === "string"
108
+ ? rawCf.failingVersion
109
+ : null,
110
+ failingCommitSha:
111
+ typeof rawCf.failingCommitSha === "string"
112
+ ? rawCf.failingCommitSha
113
+ : null,
114
+ failureFingerprint: String(rawCf.failureFingerprint ?? row.fingerprint),
115
+ passingRunIds: Array.isArray(rawCf.passingRunIds)
116
+ ? rawCf.passingRunIds.map(String)
117
+ : [],
118
+ passingVersion:
119
+ typeof rawCf.passingVersion === "string"
120
+ ? rawCf.passingVersion
121
+ : null,
122
+ passingCommitSha:
123
+ typeof rawCf.passingCommitSha === "string"
124
+ ? rawCf.passingCommitSha
125
+ : null,
126
+ requiredTrials: Number(rawCf.requiredTrials ?? 1),
127
+ verifiedAt: String(rawCf.verifiedAt ?? row.updated_at),
128
+ };
129
+ }
130
+ }
131
+
132
+ return {
133
+ id: row.id,
134
+ projectId: row.project_id,
135
+ environment: row.environment,
136
+ incidentId: row.incident_id,
137
+ fingerprint: row.fingerprint,
138
+ fingerprintInput,
139
+ title: row.title,
140
+ summary: row.summary,
141
+ causalProofTier: (row.causal_proof_tier ||
142
+ "unverified_locus") as CausalProofTier,
143
+ confidence: Number(row.confidence),
144
+ confidenceBreakdown,
145
+ symptomEntityKind: row.symptom_entity_kind as EvidenceEntityKind,
146
+ symptomEntityId: row.symptom_entity_id,
147
+ faultLocusEntityKind: row.fault_locus_entity_kind as EvidenceEntityKind,
148
+ faultLocusEntityId: row.fault_locus_entity_id,
149
+ rootCauseEntityKind:
150
+ (row.root_cause_entity_kind as EvidenceEntityKind | null) ?? null,
151
+ rootCauseEntityId: row.root_cause_entity_id,
152
+ contributingChangeId: row.contributing_change_id,
153
+ cohortVerdict:
154
+ (row.cohort_verdict as CohortAttributionVerdict | null) ?? null,
155
+ contendingTraceId: row.contending_trace_id,
156
+ causalChain: parseJsonArray(
157
+ row.causal_chain_json,
158
+ ) as unknown as CausalTriple[],
159
+ citedEvidenceIds: parseJsonArray(row.cited_evidence_ids_json).map(String),
160
+ codeReferences: parseJsonArray(
161
+ row.code_references_json ?? "[]",
162
+ ) as unknown as TelemetryCodeReference[],
163
+ counterfactualProof,
164
+ recoveryStatus: (row.recovery_status || "not_applicable") as RecoveryStatus,
165
+ causalVerificationStatus: (row.causal_verification_status ||
166
+ "unverified") as CausalVerificationStatus,
167
+ watchdogStep: Number(row.watchdog_step ?? 0),
168
+ baselineRequestRatePerMin:
169
+ row.baseline_request_rate_per_min === null
170
+ ? null
171
+ : Number(row.baseline_request_rate_per_min),
172
+ nextWatchdogCheckAt: row.next_watchdog_check_at,
173
+ actorType: row.actor_type,
174
+ actorId: row.actor_id,
175
+ agentRunId: row.agent_run_id,
176
+ createdAt: row.created_at,
177
+ updatedAt: row.updated_at,
178
+ expiresAt: row.expires_at,
179
+ };
180
+ };
181
+
182
+ /**
183
+ * Enforces P1 #1: the causal proof tier is server-derived. Nothing the client
184
+ * asserts (`cohortVerdict`, `causalProofTier`, an `isolated_to_version_cohort`
185
+ * triple) can raise the tier above `corroborated_candidate`. Only a verdict the
186
+ * collector recomputed itself from telemetry (`serverCohortVerdict`, produced by
187
+ * `computeChangeBlastRadius`) may yield `cohort_rate_isolated`, and only
188
+ * `verifyPairedCounterfactualProof` can reach `counterfactual_verified`.
189
+ */
190
+ function resolveSafeCausalProofTier(
191
+ input: InvestigationFindingInput,
192
+ serverCohortVerdict: CohortAttributionVerdict | null,
193
+ ): CausalProofTier {
194
+ if (serverCohortVerdict === "isolated_to_new_version") {
195
+ return "cohort_rate_isolated";
196
+ }
197
+ if (
198
+ serverCohortVerdict === "sequential_post_cutover_regression" ||
199
+ serverCohortVerdict === "shared_degradation_new_version_aggressor" ||
200
+ input.cohortVerdict === "isolated_to_new_version" ||
201
+ input.cohortVerdict === "sequential_post_cutover_regression" ||
202
+ input.cohortVerdict === "shared_degradation_new_version_aggressor"
203
+ ) {
204
+ return "corroborated_candidate";
205
+ }
206
+ const hasCodeOverlapOrContention = input.causalChain.some(
207
+ (t) =>
208
+ t.predicate === "touched_same_code_path" ||
209
+ t.predicate === "contended_on_shared_resource" ||
210
+ t.predicate === "induced_shared_resource_contention" ||
211
+ t.predicate === "degraded_after_change" ||
212
+ t.predicate === "isolated_to_version_cohort",
213
+ );
214
+ if (
215
+ hasCodeOverlapOrContention ||
216
+ input.causalProofTier === "cohort_rate_isolated" ||
217
+ input.causalProofTier === "corroborated_candidate"
218
+ ) {
219
+ return "corroborated_candidate";
220
+ }
221
+ return "unverified_locus";
222
+ }
223
+
224
+ export async function recordInvestigationFinding(
225
+ db: SqlDb,
226
+ projectId: string,
227
+ input: InvestigationFindingInput,
228
+ options: { logger?: Logger } = {},
229
+ ): Promise<InvestigationFinding> {
230
+ const logger = options.logger ?? consoleLogger;
231
+ const nowIso = new Date().toISOString();
232
+ const environment = (
233
+ input.environment ??
234
+ input.fingerprintInput?.environment ??
235
+ "default"
236
+ )
237
+ .trim()
238
+ .toLowerCase();
239
+
240
+ const defaultOriginNode = input.faultLocusEntityId.includes(":")
241
+ ? input.faultLocusEntityId
242
+ : buildCanonicalNodeId({
243
+ kind: "service",
244
+ environment,
245
+ serviceName: input.faultLocusEntityId,
246
+ }).id;
247
+
248
+ const fpResult = buildFindingFingerprint(
249
+ input.fingerprintInput ?? {
250
+ environment,
251
+ originatingNodeId: defaultOriginNode,
252
+ normalizedOperation: input.title,
253
+ errorSignature: input.summary.slice(0, 120),
254
+ downstreamTargetNodeId: null,
255
+ },
256
+ );
257
+
258
+ // Check for prior verified findings with the same stable fingerprint
259
+ const priorRow = await db
260
+ .prepare(
261
+ `SELECT id, causal_verification_status FROM investigation_findings
262
+ WHERE project_id = ? AND environment = ? AND fingerprint = ?
263
+ ORDER BY created_at DESC
264
+ LIMIT 1`,
265
+ )
266
+ .bind(projectId, environment, fpResult.fingerprint)
267
+ .first<{ id: string; causal_verification_status: string }>();
268
+
269
+ // Recompute the cohort verdict server-side; never trust the client's claim.
270
+ let serverCohortVerdict: CohortAttributionVerdict | null = null;
271
+ if (input.contributingChangeId) {
272
+ const blastRadius = await computeChangeBlastRadius(
273
+ db,
274
+ projectId,
275
+ input.contributingChangeId,
276
+ { environment },
277
+ );
278
+ serverCohortVerdict = blastRadius?.cohortVerdict ?? null;
279
+ }
280
+ const causalProofTier = resolveSafeCausalProofTier(
281
+ input,
282
+ serverCohortVerdict,
283
+ );
284
+ if (
285
+ input.cohortVerdict === "isolated_to_new_version" &&
286
+ serverCohortVerdict !== "isolated_to_new_version"
287
+ ) {
288
+ logger.warn(
289
+ "[autosre] client-asserted cohortVerdict=isolated_to_new_version was not corroborated server-side; capping proof tier",
290
+ {
291
+ projectId,
292
+ contributingChangeId: input.contributingChangeId ?? null,
293
+ serverCohortVerdict,
294
+ },
295
+ );
296
+ }
297
+ // An unverified client claim of isolation is not persisted as a verdict.
298
+ const storedCohortVerdict =
299
+ serverCohortVerdict ??
300
+ (input.cohortVerdict === "isolated_to_new_version"
301
+ ? null
302
+ : (input.cohortVerdict ?? null));
303
+
304
+ const relationshipScore = input.causalChain.some(
305
+ (c) => c.relationshipCertainty === "exact",
306
+ )
307
+ ? 0.9
308
+ : input.causalChain.some((c) => c.relationshipCertainty === "inferred")
309
+ ? 0.65
310
+ : 0.4;
311
+ const cohortLiftScore =
312
+ causalProofTier === "cohort_rate_isolated"
313
+ ? 0.9
314
+ : causalProofTier === "corroborated_candidate"
315
+ ? 0.6
316
+ : 0.2;
317
+ const priorBonus =
318
+ priorRow?.causal_verification_status === "counterfactual_verified" ||
319
+ priorRow?.causal_verification_status === "operationally_corroborated"
320
+ ? 0.1
321
+ : 0;
322
+
323
+ const computedConfidence = Math.min(
324
+ 0.99,
325
+ Number(
326
+ (
327
+ 0.4 * relationshipScore +
328
+ 0.4 * cohortLiftScore +
329
+ 0.2 * 0.7 +
330
+ priorBonus
331
+ ).toFixed(2),
332
+ ),
333
+ );
334
+ const confidence = input.confidence ?? computedConfidence;
335
+
336
+ const confidenceBreakdown: ConfidenceBreakdown = {
337
+ relationshipScore:
338
+ input.confidenceBreakdown?.relationshipScore ?? relationshipScore,
339
+ cohortLiftScore:
340
+ input.confidenceBreakdown?.cohortLiftScore ?? cohortLiftScore,
341
+ temporalProximityScore:
342
+ input.confidenceBreakdown?.temporalProximityScore ?? 0.7,
343
+ counterfactualBonus: input.confidenceBreakdown?.counterfactualBonus ?? 0,
344
+ priorRecurrenceBonus:
345
+ input.confidenceBreakdown?.priorRecurrenceBonus ?? priorBonus,
346
+ finalConfidence: confidence,
347
+ };
348
+
349
+ const id = input.id?.trim() || `fnd_${randomHex(10)}`;
350
+ const expiresAt = new Date(Date.now() + 90 * 24 * 3600_000).toISOString();
351
+
352
+ await db
353
+ .prepare(
354
+ `INSERT INTO investigation_findings (
355
+ project_id, environment, id, incident_id, fingerprint,
356
+ fingerprint_input_json, title, summary, causal_proof_tier,
357
+ confidence, confidence_breakdown_json, symptom_entity_kind,
358
+ symptom_entity_id, fault_locus_entity_kind, fault_locus_entity_id,
359
+ root_cause_entity_kind, root_cause_entity_id, contributing_change_id,
360
+ cohort_verdict, contending_trace_id, causal_chain_json,
361
+ cited_evidence_ids_json, code_references_json,
362
+ recovery_status, causal_verification_status, watchdog_step,
363
+ actor_type, actor_id, agent_run_id, created_at, updated_at, expires_at
364
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'not_applicable', 'unverified', 0, ?, ?, ?, ?, ?, ?)
365
+ ON CONFLICT (project_id, environment, id) DO UPDATE SET
366
+ title = excluded.title,
367
+ summary = excluded.summary,
368
+ causal_proof_tier = excluded.causal_proof_tier,
369
+ confidence = excluded.confidence,
370
+ confidence_breakdown_json = excluded.confidence_breakdown_json,
371
+ contributing_change_id = COALESCE(excluded.contributing_change_id, investigation_findings.contributing_change_id),
372
+ cohort_verdict = COALESCE(excluded.cohort_verdict, investigation_findings.cohort_verdict),
373
+ causal_chain_json = excluded.causal_chain_json,
374
+ cited_evidence_ids_json = excluded.cited_evidence_ids_json,
375
+ updated_at = excluded.updated_at`,
376
+ )
377
+ .bind(
378
+ projectId,
379
+ environment,
380
+ id,
381
+ input.incidentId ?? null,
382
+ fpResult.fingerprint,
383
+ JSON.stringify(fpResult.normalizedInput),
384
+ input.title,
385
+ input.summary,
386
+ causalProofTier,
387
+ confidence,
388
+ JSON.stringify(confidenceBreakdown),
389
+ input.symptomEntityKind,
390
+ input.symptomEntityId,
391
+ input.faultLocusEntityKind,
392
+ input.faultLocusEntityId,
393
+ input.rootCauseEntityKind ?? null,
394
+ input.rootCauseEntityId ?? null,
395
+ input.contributingChangeId ?? null,
396
+ storedCohortVerdict,
397
+ input.contendingTraceId ?? null,
398
+ JSON.stringify(input.causalChain),
399
+ JSON.stringify(input.citedEvidenceIds),
400
+ JSON.stringify(input.codeReferences ?? []),
401
+ input.actorType ?? "agent",
402
+ input.actorId ?? "autosre-agent",
403
+ input.agentRunId ?? null,
404
+ nowIso,
405
+ nowIso,
406
+ expiresAt,
407
+ )
408
+ .run();
409
+
410
+ const created = await getInvestigationFinding(db, projectId, id, environment);
411
+ if (!created) {
412
+ throw new Error(`Failed to load created finding ${id}`);
413
+ }
414
+ return created;
415
+ }
416
+
417
+ export async function getInvestigationFinding(
418
+ db: SqlDb,
419
+ projectId: string,
420
+ findingId: string,
421
+ environment?: string,
422
+ ): Promise<InvestigationFinding | null> {
423
+ const row = environment
424
+ ? await db
425
+ .prepare(
426
+ `SELECT * FROM investigation_findings
427
+ WHERE project_id = ? AND environment = ? AND id = ?`,
428
+ )
429
+ .bind(projectId, environment.toLowerCase(), findingId)
430
+ .first<FindingRow>()
431
+ : await db
432
+ .prepare(
433
+ `SELECT * FROM investigation_findings
434
+ WHERE project_id = ? AND id = ?
435
+ ORDER BY created_at DESC
436
+ LIMIT 1`,
437
+ )
438
+ .bind(projectId, findingId)
439
+ .first<FindingRow>();
440
+
441
+ return row ? mapFindingRow(row) : null;
442
+ }
443
+
444
+ export async function searchInvestigationFindings(
445
+ db: SqlDb,
446
+ options: {
447
+ projectId: string;
448
+ environment?: string;
449
+ fingerprint?: string;
450
+ symptomEntityId?: string;
451
+ rootCauseEntityId?: string;
452
+ causalVerificationStatus?: string;
453
+ incidentId?: string;
454
+ limit?: number;
455
+ },
456
+ ): Promise<InvestigationFinding[]> {
457
+ const conditions: string[] = ["project_id = ?"];
458
+ const binds: unknown[] = [options.projectId];
459
+
460
+ if (options.environment) {
461
+ conditions.push("environment = ?");
462
+ binds.push(options.environment.toLowerCase());
463
+ }
464
+ if (options.fingerprint) {
465
+ conditions.push("fingerprint = ?");
466
+ binds.push(options.fingerprint);
467
+ }
468
+ if (options.symptomEntityId) {
469
+ conditions.push("symptom_entity_id = ?");
470
+ binds.push(options.symptomEntityId);
471
+ }
472
+ if (options.rootCauseEntityId) {
473
+ conditions.push("(root_cause_entity_id = ? OR fault_locus_entity_id = ?)");
474
+ binds.push(options.rootCauseEntityId, options.rootCauseEntityId);
475
+ }
476
+ if (options.causalVerificationStatus) {
477
+ conditions.push("causal_verification_status = ?");
478
+ binds.push(options.causalVerificationStatus);
479
+ }
480
+ if (options.incidentId) {
481
+ conditions.push("incident_id = ?");
482
+ binds.push(options.incidentId);
483
+ }
484
+
485
+ const limit = Math.max(1, Math.min(200, options.limit ?? 25));
486
+ binds.push(limit);
487
+
488
+ const rows = await db
489
+ .prepare(
490
+ `SELECT * FROM investigation_findings
491
+ WHERE ${conditions.join(" AND ")}
492
+ ORDER BY created_at DESC
493
+ LIMIT ?`,
494
+ )
495
+ .bind(...binds)
496
+ .all<FindingRow>();
497
+
498
+ return rows.results.map(mapFindingRow);
499
+ }
500
+
501
+ export class CounterfactualVerificationError extends Error {
502
+ constructor(message: string) {
503
+ super(message);
504
+ this.name = "CounterfactualVerificationError";
505
+ }
506
+ }
507
+
508
+ interface JoinedEvalRunResultRow {
509
+ run_id: string;
510
+ eval_case_id: string | null;
511
+ service_version: string | null;
512
+ commit_sha: string | null;
513
+ candidate_agent_version: string | null;
514
+ candidate_prompt_version: string | null;
515
+ run_input_hash: string | null;
516
+ run_failure_fp: string | null;
517
+ pass_count: number;
518
+ fail_count: number;
519
+ result_passed: number | null;
520
+ result_input_hash: string | null;
521
+ result_failure_fp: string | null;
522
+ }
523
+
524
+ /**
525
+ * Verifies a PairedCounterfactualProof in SQL against `eval_runs` and `eval_case_results`
526
+ * (RFC 0013 § 5.A & P1 #3).
527
+ *
528
+ * Requires:
529
+ * 1. `failingRunId` and all `passingRunIds` belong to `projectId` and ran against `evalCaseId`
530
+ * with the identical non-null `input_payload_hash`.
531
+ * 2. `failingRunId` failed (`passed = 0`) and its `failure_fingerprint` matches `finding.fingerprint`.
532
+ * 3. All `passingRunIds` passed (`passed = 1`) and `passingRunIds.length >= requiredTrials`.
533
+ * 4. Failing and passing runs have distinct recorded version/commit/prompt lineage.
534
+ */
535
+ export async function verifyPairedCounterfactualProof(
536
+ db: SqlDb,
537
+ projectId: string,
538
+ findingId: string,
539
+ input: {
540
+ environment?: string;
541
+ evalCaseId: string;
542
+ failingRunId: string;
543
+ passingRunIds: string[];
544
+ requiredTrials?: number;
545
+ },
546
+ ): Promise<InvestigationFinding> {
547
+ const finding = await getInvestigationFinding(
548
+ db,
549
+ projectId,
550
+ findingId,
551
+ input.environment,
552
+ );
553
+ if (!finding) {
554
+ throw new CounterfactualVerificationError(
555
+ `Finding ${findingId} not found in project ${projectId}`,
556
+ );
557
+ }
558
+
559
+ const requiredTrials = Math.max(1, input.requiredTrials ?? 1);
560
+ const uniquePassingIds = Array.from(new Set(input.passingRunIds));
561
+ if (uniquePassingIds.length < requiredTrials) {
562
+ throw new CounterfactualVerificationError(
563
+ `Counterfactual requires at least ${requiredTrials} passing trial run(s), got ${uniquePassingIds.length}`,
564
+ );
565
+ }
566
+ if (uniquePassingIds.includes(input.failingRunId)) {
567
+ throw new CounterfactualVerificationError(
568
+ "failingRunId cannot also be listed in passingRunIds",
569
+ );
570
+ }
571
+
572
+ const loadRunVerification = async (
573
+ runId: string,
574
+ ): Promise<JoinedEvalRunResultRow | null> => {
575
+ const row = await db
576
+ .prepare(
577
+ `SELECT
578
+ r.id AS run_id,
579
+ COALESCE(cr.eval_case_id, r.eval_case_id) AS eval_case_id,
580
+ r.service_version,
581
+ r.commit_sha,
582
+ r.candidate_agent_version,
583
+ r.candidate_prompt_version,
584
+ r.input_payload_hash AS run_input_hash,
585
+ r.failure_fingerprint AS run_failure_fp,
586
+ r.pass_count,
587
+ r.fail_count,
588
+ cr.passed AS result_passed,
589
+ cr.input_payload_hash AS result_input_hash,
590
+ cr.failure_fingerprint AS result_failure_fp
591
+ FROM eval_runs r
592
+ LEFT JOIN eval_case_results cr
593
+ ON cr.project_id = r.project_id AND cr.run_id = r.id AND cr.eval_case_id = ?
594
+ WHERE r.project_id = ? AND r.id = ?
595
+ LIMIT 1`,
596
+ )
597
+ .bind(input.evalCaseId, projectId, runId)
598
+ .first<JoinedEvalRunResultRow>();
599
+ return row;
600
+ };
601
+
602
+ const failingRow = await loadRunVerification(input.failingRunId);
603
+ if (!failingRow) {
604
+ throw new CounterfactualVerificationError(
605
+ `Failing eval run ${input.failingRunId} not found in project ${projectId}`,
606
+ );
607
+ }
608
+ if (failingRow.eval_case_id !== input.evalCaseId) {
609
+ throw new CounterfactualVerificationError(
610
+ `Failing eval run ${input.failingRunId} does not match evalCaseId ${input.evalCaseId}`,
611
+ );
612
+ }
613
+
614
+ const failingPassed =
615
+ failingRow.result_passed !== null
616
+ ? Number(failingRow.result_passed) === 1
617
+ : Number(failingRow.fail_count) === 0 &&
618
+ Number(failingRow.pass_count) > 0;
619
+ if (failingPassed) {
620
+ throw new CounterfactualVerificationError(
621
+ `Baseline run ${input.failingRunId} did not fail (passed=1)`,
622
+ );
623
+ }
624
+
625
+ const failingInputHash =
626
+ failingRow.result_input_hash ?? failingRow.run_input_hash;
627
+ if (!failingInputHash) {
628
+ throw new CounterfactualVerificationError(
629
+ `Failing eval run ${input.failingRunId} is missing input_payload_hash`,
630
+ );
631
+ }
632
+
633
+ const failingFp = failingRow.result_failure_fp ?? failingRow.run_failure_fp;
634
+ if (failingFp !== finding.fingerprint) {
635
+ throw new CounterfactualVerificationError(
636
+ `Failing run fingerprint (${failingFp ?? "null"}) does not match finding fingerprint (${finding.fingerprint})`,
637
+ );
638
+ }
639
+
640
+ const failingVersion =
641
+ failingRow.service_version ??
642
+ failingRow.candidate_agent_version ??
643
+ failingRow.candidate_prompt_version;
644
+
645
+ let passingVersion: string | null = null;
646
+ let passingCommitSha: string | null = null;
647
+
648
+ for (const passRunId of uniquePassingIds) {
649
+ const passRow = await loadRunVerification(passRunId);
650
+ if (!passRow) {
651
+ throw new CounterfactualVerificationError(
652
+ `Passing eval run ${passRunId} not found in project ${projectId}`,
653
+ );
654
+ }
655
+ if (passRow.eval_case_id !== input.evalCaseId) {
656
+ throw new CounterfactualVerificationError(
657
+ `Passing eval run ${passRunId} does not match evalCaseId ${input.evalCaseId}`,
658
+ );
659
+ }
660
+ const didPass =
661
+ passRow.result_passed !== null
662
+ ? Number(passRow.result_passed) === 1
663
+ : Number(passRow.pass_count) > 0 && Number(passRow.fail_count) === 0;
664
+ if (!didPass) {
665
+ throw new CounterfactualVerificationError(
666
+ `Candidate run ${passRunId} did not pass`,
667
+ );
668
+ }
669
+ const passInputHash = passRow.result_input_hash ?? passRow.run_input_hash;
670
+ if (passInputHash !== failingInputHash) {
671
+ throw new CounterfactualVerificationError(
672
+ `Candidate run ${passRunId} input_payload_hash (${passInputHash ?? "null"}) does not match failing run (${failingInputHash})`,
673
+ );
674
+ }
675
+ passingVersion =
676
+ passRow.service_version ??
677
+ passRow.candidate_agent_version ??
678
+ passRow.candidate_prompt_version ??
679
+ passingVersion;
680
+ passingCommitSha = passRow.commit_sha ?? passingCommitSha;
681
+ }
682
+
683
+ if (
684
+ !failingVersion &&
685
+ !failingRow.commit_sha &&
686
+ !passingVersion &&
687
+ !passingCommitSha
688
+ ) {
689
+ throw new CounterfactualVerificationError(
690
+ "Counterfactual runs must record version, commit_sha, or candidate version lineage",
691
+ );
692
+ }
693
+
694
+ const nowIso = new Date().toISOString();
695
+ const proof: PairedCounterfactualProof = {
696
+ evalCaseId: input.evalCaseId,
697
+ inputPayloadHash: failingInputHash,
698
+ failingRunId: input.failingRunId,
699
+ failingVersion: failingVersion ?? null,
700
+ failingCommitSha: failingRow.commit_sha ?? null,
701
+ failureFingerprint: finding.fingerprint,
702
+ passingRunIds: uniquePassingIds,
703
+ passingVersion,
704
+ passingCommitSha,
705
+ requiredTrials,
706
+ verifiedAt: nowIso,
707
+ };
708
+
709
+ const updatedChain: CausalTriple[] = [
710
+ ...finding.causalChain,
711
+ {
712
+ subjectKind: "finding",
713
+ subjectId: finding.id,
714
+ predicate: "verified_by_counterfactual",
715
+ objectKind: "eval",
716
+ objectId: input.evalCaseId,
717
+ relationshipCertainty: "exact",
718
+ causalProofTier: "counterfactual_verified",
719
+ confidence: 0.99,
720
+ reason: `Paired counterfactual verified on evalCase ${input.evalCaseId}: run ${input.failingRunId} (${failingVersion ?? "baseline"}) failed with ${finding.fingerprint} and ${uniquePassingIds.join(", ")} (${passingVersion ?? "candidate"}) passed on payload ${failingInputHash}`,
721
+ },
722
+ ];
723
+
724
+ const updatedBreakdown: ConfidenceBreakdown = {
725
+ ...finding.confidenceBreakdown,
726
+ counterfactualBonus: 0.25,
727
+ finalConfidence: 0.99,
728
+ };
729
+
730
+ await db
731
+ .prepare(
732
+ `UPDATE investigation_findings
733
+ SET causal_proof_tier = 'counterfactual_verified',
734
+ causal_verification_status = 'counterfactual_verified',
735
+ confidence = 0.99,
736
+ confidence_breakdown_json = ?,
737
+ counterfactual_proof_json = ?,
738
+ causal_chain_json = ?,
739
+ updated_at = ?
740
+ WHERE project_id = ? AND environment = ? AND id = ?`,
741
+ )
742
+ .bind(
743
+ JSON.stringify(updatedBreakdown),
744
+ JSON.stringify(proof),
745
+ JSON.stringify(updatedChain),
746
+ nowIso,
747
+ projectId,
748
+ finding.environment,
749
+ finding.id,
750
+ )
751
+ .run();
752
+
753
+ const updated = await getInvestigationFinding(
754
+ db,
755
+ projectId,
756
+ finding.id,
757
+ finding.environment,
758
+ );
759
+ if (!updated) {
760
+ throw new Error(`Failed to reload finding ${finding.id}`);
761
+ }
762
+ return updated;
763
+ }