@obsunified/collector 2.0.1 → 2.2.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 (126) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -0,0 +1,1275 @@
1
+ import { buildCanonicalNodeId, buildFindingFingerprint } from "@obsunified/types";
2
+ import { Hono } from "hono";
3
+ import { describe, expect, it } from "vitest";
4
+ import { CollectorRuntime } from "../framework/collector";
5
+ import type { CollectorEnv } from "../framework/env";
6
+ import {
7
+ computeChangeBlastRadius,
8
+ findCrossTraceContention,
9
+ recordChangeEvent,
10
+ } from "../lib/autosre-change-correlation";
11
+ import {
12
+ computeTarjanScc,
13
+ extractSpanLineageAndTopology,
14
+ localizeTraceFaultLocus,
15
+ upsertOntologyFromSpans,
16
+ } from "../lib/autosre-ontology";
17
+ import { evaluateRemediationWatchdogs } from "../lib/autosre-remediation-gate";
18
+ import {
19
+ createMigratedSqliteDb,
20
+ type NodeSqliteDb,
21
+ } from "../lib/test-utils/node-sqlite-db";
22
+ import { autosreRoutesPlugin } from "../plugins/autosre-routes";
23
+ import { connectedRoutesPlugin } from "../plugins/connected-routes";
24
+ import { evalCasesRoutesPlugin } from "../plugins/eval-cases-routes";
25
+ import { evidenceRetrievalRoutesPlugin } from "../plugins/evidence-retrieval-routes";
26
+
27
+ const createTestApp = (db: NodeSqliteDb) => {
28
+ const app = new Hono<{ Bindings: CollectorEnv }>();
29
+ const runtime = new CollectorRuntime();
30
+ autosreRoutesPlugin.register(app, runtime);
31
+ evalCasesRoutesPlugin.register(app, runtime);
32
+ connectedRoutesPlugin.register(app, runtime);
33
+ evidenceRetrievalRoutesPlugin.register(app, runtime);
34
+ const env: CollectorEnv = {
35
+ DB: db as unknown as D1Database,
36
+ };
37
+ return { app, env };
38
+ };
39
+
40
+ const insertSpan = (
41
+ db: NodeSqliteDb,
42
+ opts: {
43
+ traceId: string;
44
+ spanId: string;
45
+ parentSpanId?: string | null;
46
+ serviceName: string;
47
+ spanName: string;
48
+ spanKind?: number;
49
+ statusCode?: number;
50
+ statusMessage?: string | null;
51
+ startTime: string;
52
+ endTime: string;
53
+ durationMs: number;
54
+ environment?: string;
55
+ serviceVersion?: string | null;
56
+ commitSha?: string | null;
57
+ targetNodeId?: string | null;
58
+ errorSignature?: string | null;
59
+ attributes?: Record<string, unknown>;
60
+ resourceAttributes?: Record<string, unknown>;
61
+ events?: unknown[];
62
+ },
63
+ ) => {
64
+ db.raw
65
+ .prepare(
66
+ `INSERT INTO telemetry_spans (
67
+ project_id, trace_id, span_id, parent_span_id, service_name,
68
+ span_name, span_kind, start_time, end_time, duration_ms,
69
+ status_code, status_message, attributes_json, resource_attributes_json,
70
+ events_json, links_json, received_at, expires_at,
71
+ environment, service_version, commit_sha, target_node_id, error_signature
72
+ ) VALUES (
73
+ 'default', ?, ?, ?, ?,
74
+ ?, ?, ?, ?, ?,
75
+ ?, ?, ?, ?,
76
+ ?, '[]', ?, ?,
77
+ ?, ?, ?, ?, ?
78
+ )`,
79
+ )
80
+ .run(
81
+ opts.traceId,
82
+ opts.spanId,
83
+ opts.parentSpanId ?? null,
84
+ opts.serviceName,
85
+ opts.spanName,
86
+ opts.spanKind ?? 1,
87
+ opts.startTime,
88
+ opts.endTime,
89
+ opts.durationMs,
90
+ opts.statusCode ?? 1,
91
+ opts.statusMessage ?? null,
92
+ JSON.stringify(opts.attributes ?? {}),
93
+ JSON.stringify(opts.resourceAttributes ?? {}),
94
+ JSON.stringify(opts.events ?? []),
95
+ opts.startTime,
96
+ new Date(Date.parse(opts.startTime) + 72 * 3600_000).toISOString(),
97
+ opts.environment ?? "prod",
98
+ opts.serviceVersion ?? null,
99
+ opts.commitSha ?? null,
100
+ opts.targetNodeId ?? null,
101
+ opts.errorSignature ?? null,
102
+ );
103
+ };
104
+
105
+ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop Incident Memory", () => {
106
+ it("Criterion 1 (P1 #5): isolates environment and host:port in canonical node IDs and caps unscoped DB contention at inferred certainty", async () => {
107
+ const db = createMigratedSqliteDb();
108
+
109
+ const prodCheckout = buildCanonicalNodeId({
110
+ kind: "service",
111
+ environment: "prod",
112
+ namespace: "default",
113
+ serviceName: "checkout-api",
114
+ });
115
+ const stagingCheckout = buildCanonicalNodeId({
116
+ kind: "service",
117
+ environment: "staging",
118
+ namespace: "default",
119
+ serviceName: "checkout-api",
120
+ });
121
+ expect(prodCheckout.id).toBe("service:prod:default:checkout-api");
122
+ expect(stagingCheckout.id).toBe("service:staging:default:checkout-api");
123
+ expect(prodCheckout.id).not.toBe(stagingCheckout.id);
124
+
125
+ // Two DBs named 'orders' on different hosts must not collide
126
+ const primarySpan = {
127
+ projectId: "default",
128
+ traceId: "tr_db_1",
129
+ spanId: "sp_db_1",
130
+ parentSpanId: null,
131
+ traceState: null,
132
+ serviceName: "checkout-api",
133
+ scopeName: "db-client",
134
+ scopeVersion: "1.0.0",
135
+ spanName: "SELECT orders",
136
+ spanKind: 3,
137
+ startTime: "2026-10-15T01:00:00.000Z",
138
+ endTime: "2026-10-15T01:00:00.050Z",
139
+ durationMs: 50,
140
+ statusCode: 1,
141
+ statusMessage: null,
142
+ attributesJson: JSON.stringify({
143
+ "db.system": "postgresql",
144
+ "db.name": "orders",
145
+ "server.address": "pg-primary.internal",
146
+ "server.port": 5432,
147
+ }),
148
+ resourceAttributesJson: JSON.stringify({
149
+ "deployment.environment.name": "prod",
150
+ "service.version": "v2.4.1",
151
+ }),
152
+ eventsJson: "[]",
153
+ linksJson: "[]",
154
+ droppedAttributesCount: 0,
155
+ droppedEventsCount: 0,
156
+ droppedLinksCount: 0,
157
+ receivedAt: "2026-10-15T01:00:00.050Z",
158
+ expiresAt: "2026-10-18T01:00:00.050Z",
159
+ };
160
+ const replicaSpan = {
161
+ ...primarySpan,
162
+ traceId: "tr_db_2",
163
+ spanId: "sp_db_2",
164
+ serviceName: "analytics-worker",
165
+ attributesJson: JSON.stringify({
166
+ "db.system": "postgresql",
167
+ "db.name": "orders",
168
+ "server.address": "pg-replica.internal",
169
+ "server.port": 5432,
170
+ }),
171
+ };
172
+ const unscopedSpan = {
173
+ ...primarySpan,
174
+ traceId: "tr_db_unscoped",
175
+ spanId: "sp_db_unscoped",
176
+ serviceName: "legacy-worker",
177
+ attributesJson: JSON.stringify({
178
+ "db.system": "postgresql",
179
+ "db.name": "orders",
180
+ }),
181
+ };
182
+
183
+ const primaryCtx = extractSpanLineageAndTopology(primarySpan);
184
+ const replicaCtx = extractSpanLineageAndTopology(replicaSpan);
185
+ const unscopedCtx = extractSpanLineageAndTopology(unscopedSpan);
186
+
187
+ expect(primaryCtx.targetNode?.id).toBe(
188
+ "database:prod:postgresql:pg-primary.internal:5432/orders",
189
+ );
190
+ expect(primaryCtx.targetNode?.isScopedIdentity).toBe(true);
191
+ expect(replicaCtx.targetNode?.id).toBe(
192
+ "database:prod:postgresql:pg-replica.internal:5432/orders",
193
+ );
194
+ expect(replicaCtx.targetNode?.isScopedIdentity).toBe(true);
195
+ expect(primaryCtx.targetNode?.id).not.toBe(replicaCtx.targetNode?.id);
196
+
197
+ expect(unscopedCtx.targetNode?.id).toBe(
198
+ "database:prod:unscoped/postgresql:orders",
199
+ );
200
+ expect(unscopedCtx.targetNode?.isScopedIdentity).toBe(false);
201
+
202
+ await upsertOntologyFromSpans(
203
+ db,
204
+ [primarySpan, replicaSpan, unscopedSpan],
205
+ { bypassCache: true },
206
+ );
207
+
208
+ // Verify unscoped DB contention caps relationshipCertainty at "inferred"
209
+ const baseMs = Date.parse("2026-10-15T01:10:00.000Z");
210
+ const unscopedNodeId = unscopedCtx.targetNode!.id;
211
+ for (let i = 0; i < 6; i++) {
212
+ insertSpan(db, {
213
+ traceId: "tr_aggressor_unscoped",
214
+ spanId: `sp_agg_${i}`,
215
+ serviceName: "checkout-api",
216
+ spanName: "UPDATE orders",
217
+ spanKind: 3,
218
+ statusCode: 1,
219
+ startTime: new Date(baseMs + i * 50).toISOString(),
220
+ endTime: new Date(baseMs + i * 50 + 400).toISOString(),
221
+ durationMs: 400,
222
+ environment: "prod",
223
+ serviceVersion: "v2.4.1",
224
+ targetNodeId: unscopedNodeId,
225
+ });
226
+ }
227
+ insertSpan(db, {
228
+ traceId: "tr_victim_unscoped",
229
+ spanId: "sp_victim_1",
230
+ serviceName: "checkout-api",
231
+ spanName: "SELECT orders",
232
+ spanKind: 3,
233
+ statusCode: 2,
234
+ statusMessage: "Lock wait timeout",
235
+ startTime: new Date(baseMs + 100).toISOString(),
236
+ endTime: new Date(baseMs + 900).toISOString(),
237
+ durationMs: 800,
238
+ environment: "prod",
239
+ serviceVersion: "v2.4.0",
240
+ targetNodeId: unscopedNodeId,
241
+ });
242
+
243
+ const contention = await findCrossTraceContention(db, {
244
+ projectId: "default",
245
+ environment: "prod",
246
+ targetNodeId: unscopedNodeId,
247
+ excludeTraceId: "tr_victim_unscoped",
248
+ startTime: new Date(baseMs - 1000).toISOString(),
249
+ endTime: new Date(baseMs + 5000).toISOString(),
250
+ });
251
+ expect(contention.length).toBeGreaterThan(0);
252
+ expect(contention[0]?.aggressorTraceId).toBe("tr_aggressor_unscoped");
253
+ expect(contention[0]?.relationshipCertainty).toBe("inferred");
254
+
255
+ db.close();
256
+ });
257
+
258
+ it("Criterion 2 (P2 #6 & P1 #1): condenses cyclic service topology via Tarjan SCC and separates exact structural locus from unverified_locus causal proof", async () => {
259
+ const db = createMigratedSqliteDb();
260
+ const { app, env } = createTestApp(db);
261
+
262
+ // Cyclic graph: service A -> service B -> service A
263
+ const sccResult = computeTarjanScc(
264
+ [
265
+ {
266
+ id: "service:prod:default:svc-a",
267
+ projectId: "default",
268
+ environment: "prod",
269
+ kind: "service",
270
+ name: "svc-a",
271
+ namespaceOrEndpoint: "default",
272
+ isScopedIdentity: true,
273
+ currentVersion: "v1",
274
+ currentCommitSha: null,
275
+ currentConfigDigest: null,
276
+ attributes: {},
277
+ firstSeenAt: "2026-10-15T01:00:00.000Z",
278
+ lastSeenAt: "2026-10-15T01:00:00.000Z",
279
+ },
280
+ {
281
+ id: "service:prod:default:svc-b",
282
+ projectId: "default",
283
+ environment: "prod",
284
+ kind: "service",
285
+ name: "svc-b",
286
+ namespaceOrEndpoint: "default",
287
+ isScopedIdentity: true,
288
+ currentVersion: "v1",
289
+ currentCommitSha: null,
290
+ currentConfigDigest: null,
291
+ attributes: {},
292
+ firstSeenAt: "2026-10-15T01:00:00.000Z",
293
+ lastSeenAt: "2026-10-15T01:00:00.000Z",
294
+ },
295
+ ],
296
+ [
297
+ {
298
+ id: "e1",
299
+ projectId: "default",
300
+ environment: "prod",
301
+ sourceNodeId: "service:prod:default:svc-a",
302
+ predicate: "calls",
303
+ targetNodeId: "service:prod:default:svc-b",
304
+ relationshipCertainty: "exact",
305
+ callCount: 10,
306
+ errorCount: 2,
307
+ lastDurationMs: 100,
308
+ firstSeenAt: "2026-10-15T01:00:00.000Z",
309
+ lastSeenAt: "2026-10-15T01:00:00.000Z",
310
+ },
311
+ {
312
+ id: "e2",
313
+ projectId: "default",
314
+ environment: "prod",
315
+ sourceNodeId: "service:prod:default:svc-b",
316
+ predicate: "calls",
317
+ targetNodeId: "service:prod:default:svc-a",
318
+ relationshipCertainty: "exact",
319
+ callCount: 10,
320
+ errorCount: 2,
321
+ lastDurationMs: 90,
322
+ firstSeenAt: "2026-10-15T01:00:00.000Z",
323
+ lastSeenAt: "2026-10-15T01:00:00.000Z",
324
+ },
325
+ ],
326
+ );
327
+
328
+ expect(sccResult.components).toHaveLength(1);
329
+ expect(sccResult.components[0]?.isCyclic).toBe(true);
330
+ expect(sccResult.annotatedNodes.every((n) => n.inCycle === true)).toBe(
331
+ true,
332
+ );
333
+
334
+ // Cyclic span tree within a trace must also terminate deterministically
335
+ const locus = localizeTraceFaultLocus("tr_cyclic", [
336
+ {
337
+ span_id: "sp_root",
338
+ parent_span_id: "sp_leaf", // cycle in span parent pointers
339
+ service_name: "svc-a",
340
+ span_name: "POST /checkout",
341
+ span_kind: 1,
342
+ status_code: 2,
343
+ status_message: "500 Internal Error",
344
+ start_time: "2026-10-15T01:00:00.000Z",
345
+ end_time: "2026-10-15T01:00:01.000Z",
346
+ duration_ms: 1000,
347
+ environment: "prod",
348
+ },
349
+ {
350
+ span_id: "sp_leaf",
351
+ parent_span_id: "sp_root",
352
+ service_name: "svc-b",
353
+ span_name: "SELECT inventory",
354
+ span_kind: 3,
355
+ status_code: 2,
356
+ status_message: "Deadlock detected",
357
+ start_time: "2026-10-15T01:00:00.100Z",
358
+ end_time: "2026-10-15T01:00:00.900Z",
359
+ duration_ms: 800,
360
+ environment: "prod",
361
+ },
362
+ ]);
363
+
364
+ expect(locus.faultLocusSpan?.span_id).toBe("sp_leaf");
365
+ expect(locus.relationshipCertainty).toBe("exact");
366
+ expect(locus.causalProofTier).toBe("unverified_locus");
367
+
368
+ // Record an unverified_locus finding and verify rollback_deploy is blocked_by_policy (P1 #1)
369
+ const incRes = await app.request(
370
+ "/internal/incidents",
371
+ {
372
+ method: "POST",
373
+ headers: { "Content-Type": "application/json" },
374
+ body: JSON.stringify({
375
+ environment: "prod",
376
+ targetNodeId: "service:prod:default:svc-a",
377
+ symptomAnchorKind: "trace",
378
+ symptomAnchorId: "tr_cyclic",
379
+ }),
380
+ },
381
+ env,
382
+ );
383
+ expect(incRes.status).toBe(201);
384
+ const { incident } = (await incRes.json()) as {
385
+ incident: { id: string };
386
+ };
387
+
388
+ const findingRes = await app.request(
389
+ "/internal/findings",
390
+ {
391
+ method: "POST",
392
+ headers: { "Content-Type": "application/json" },
393
+ body: JSON.stringify({
394
+ environment: "prod",
395
+ incidentId: incident.id,
396
+ title: "Fault locus at sp_leaf",
397
+ summary: "Error surfaced at sp_leaf without change correlation",
398
+ causalProofTier: "unverified_locus",
399
+ symptomEntityKind: "trace",
400
+ symptomEntityId: "tr_cyclic",
401
+ faultLocusEntityKind: "span",
402
+ faultLocusEntityId: "sp_leaf",
403
+ causalChain: locus.causalTriples,
404
+ citedEvidenceIds: ["tr_cyclic", "sp_leaf"],
405
+ }),
406
+ },
407
+ env,
408
+ );
409
+ expect(findingRes.status).toBe(201);
410
+ const { finding } = (await findingRes.json()) as {
411
+ finding: { id: string; causalProofTier: string };
412
+ };
413
+ expect(finding.causalProofTier).toBe("unverified_locus");
414
+
415
+ const proposeRes = await app.request(
416
+ "/internal/remediations/propose",
417
+ {
418
+ method: "POST",
419
+ headers: { "Content-Type": "application/json" },
420
+ body: JSON.stringify({
421
+ environment: "prod",
422
+ incidentId: incident.id,
423
+ findingId: finding.id,
424
+ idempotencyKey: "idem_unverified_rollback",
425
+ actionKind: "rollback_deploy",
426
+ targetNodeId: "service:prod:default:svc-a",
427
+ expectedCurrentState: { version: "v1" },
428
+ desiredTargetState: { version: "v0" },
429
+ requestedAutonomy: "autonomous_write",
430
+ }),
431
+ },
432
+ env,
433
+ );
434
+ expect(proposeRes.status).toBe(201);
435
+ const { proposal, grantToken } = (await proposeRes.json()) as {
436
+ proposal: { effectiveAutonomy: string; status: string };
437
+ grantToken: string | null;
438
+ };
439
+ expect(proposal.effectiveAutonomy).toBe("blocked_by_policy");
440
+ expect(proposal.status).toBe("rejected");
441
+ expect(grantToken).toBeNull();
442
+
443
+ db.close();
444
+ });
445
+
446
+ it("Criterion 3 (P1 #2): computes traffic-normalized cohort error rate lift and handles equal error rates, low sample sizes, and shared DB degradation", async () => {
447
+ const db = createMigratedSqliteDb();
448
+
449
+ const t0 = Date.parse("2026-10-15T02:00:00.000Z");
450
+ const insertCohortSpans = (
451
+ serviceName: string,
452
+ version: string,
453
+ total: number,
454
+ errors: number,
455
+ dbCallsPerTrace = 0,
456
+ offsetMs = 1000,
457
+ ) => {
458
+ for (let i = 0; i < total; i++) {
459
+ const traceId = `tr_${serviceName}_${version}_${i}`;
460
+ const isError = i < errors;
461
+ const startIso = new Date(t0 + offsetMs + i * 50).toISOString();
462
+ const endIso = new Date(t0 + offsetMs + i * 50 + 40).toISOString();
463
+ insertSpan(db, {
464
+ traceId,
465
+ spanId: `sp_root_${serviceName}_${version}_${i}`,
466
+ serviceName,
467
+ spanName: "POST /checkout",
468
+ spanKind: 1, // SERVER span
469
+ statusCode: isError ? 2 : 1,
470
+ statusMessage: isError ? "Error" : null,
471
+ startTime: startIso,
472
+ endTime: endIso,
473
+ durationMs: 40,
474
+ environment: "prod",
475
+ serviceVersion: version,
476
+ });
477
+ for (let d = 0; d < dbCallsPerTrace; d++) {
478
+ insertSpan(db, {
479
+ traceId,
480
+ spanId: `sp_db_${serviceName}_${version}_${i}_${d}`,
481
+ parentSpanId: `sp_root_${serviceName}_${version}_${i}`,
482
+ serviceName,
483
+ spanName: "SELECT inventory",
484
+ spanKind: 2, // CLIENT span
485
+ statusCode: 1,
486
+ startTime: startIso,
487
+ endTime: endIso,
488
+ durationMs: 20,
489
+ environment: "prod",
490
+ serviceVersion: version,
491
+ targetNodeId: "database:prod:postgresql:pg.internal:5432/orders",
492
+ });
493
+ }
494
+ }
495
+ };
496
+
497
+ // Case A: 95% traffic on v2.4.1 (950 spans, 95 errors = 10%) vs 5% traffic on v2.4.0 (50 spans, 5 errors = 10%)
498
+ // Must NOT blame v2.4.1 just because it has 95% of raw errors!
499
+ await recordChangeEvent(db, "default", {
500
+ id: "chg_equal_rate",
501
+ environment: "prod",
502
+ kind: "deploy",
503
+ serviceName: "svc-equal-rate",
504
+ version: "v2.4.1",
505
+ previousVersion: "v2.4.0",
506
+ summary: "Deploy svc-equal-rate v2.4.1",
507
+ occurredAt: new Date(t0).toISOString(),
508
+ });
509
+ insertCohortSpans("svc-equal-rate", "v2.4.1", 950, 95);
510
+ insertCohortSpans("svc-equal-rate", "v2.4.0", 50, 5);
511
+
512
+ const equalRateResult = await computeChangeBlastRadius(
513
+ db,
514
+ "default",
515
+ "chg_equal_rate",
516
+ { environment: "prod" },
517
+ );
518
+ expect(equalRateResult?.cohortVerdict).toBe("no_regression_detected");
519
+ expect(equalRateResult?.relativeRisk).toBeCloseTo(1.0, 2);
520
+ expect(equalRateResult?.causalProofTier).toBe("unverified_locus");
521
+
522
+ // Case B: Insufficient sample size (N < 50)
523
+ await recordChangeEvent(db, "default", {
524
+ id: "chg_small_sample",
525
+ environment: "prod",
526
+ kind: "deploy",
527
+ serviceName: "svc-small-sample",
528
+ version: "v2.4.1",
529
+ previousVersion: "v2.4.0",
530
+ summary: "Deploy svc-small-sample v2.4.1",
531
+ occurredAt: new Date(t0).toISOString(),
532
+ });
533
+ insertCohortSpans("svc-small-sample", "v2.4.1", 20, 10);
534
+ insertCohortSpans("svc-small-sample", "v2.4.0", 20, 0);
535
+
536
+ const smallSampleResult = await computeChangeBlastRadius(
537
+ db,
538
+ "default",
539
+ "chg_small_sample",
540
+ { environment: "prod" },
541
+ );
542
+ expect(smallSampleResult?.cohortVerdict).toBe("insufficient_sample");
543
+
544
+ // Case C: Shared downstream degradation where BOTH v2.4.1 and v2.4.0 fail at 20%,
545
+ // but v2.4.1 has 5x downstream DB call fanout per trace (noisy-neighbor aggressor!)
546
+ await recordChangeEvent(db, "default", {
547
+ id: "chg_noisy",
548
+ environment: "prod",
549
+ kind: "deploy",
550
+ serviceName: "svc-noisy",
551
+ version: "v2.4.1",
552
+ previousVersion: "v2.4.0",
553
+ summary: "Deploy svc-noisy v2.4.1",
554
+ occurredAt: new Date(t0).toISOString(),
555
+ });
556
+ insertCohortSpans("svc-noisy", "v2.4.1", 60, 12, 5);
557
+ insertCohortSpans("svc-noisy", "v2.4.0", 60, 12, 1);
558
+
559
+ const noisyResult = await computeChangeBlastRadius(
560
+ db,
561
+ "default",
562
+ "chg_noisy",
563
+ { environment: "prod" },
564
+ );
565
+ expect(noisyResult?.cohortVerdict).toBe(
566
+ "shared_degradation_new_version_aggressor",
567
+ );
568
+ expect(noisyResult?.causalProofTier).toBe("corroborated_candidate");
569
+ expect(noisyResult?.sharedResourceAggressors.length).toBeGreaterThan(0);
570
+
571
+ // Case D: Isolated regression on v2.4.1 (20% error rate vs 1.6% on v2.4.0, N >= 50)
572
+ await recordChangeEvent(db, "default", {
573
+ id: "chg_isolated",
574
+ environment: "prod",
575
+ kind: "deploy",
576
+ serviceName: "svc-isolated",
577
+ version: "v2.4.1",
578
+ previousVersion: "v2.4.0",
579
+ summary: "Deploy svc-isolated v2.4.1",
580
+ occurredAt: new Date(t0).toISOString(),
581
+ });
582
+ insertCohortSpans("svc-isolated", "v2.4.1", 60, 12);
583
+ insertCohortSpans("svc-isolated", "v2.4.0", 60, 1);
584
+
585
+ const isolatedResult = await computeChangeBlastRadius(
586
+ db,
587
+ "default",
588
+ "chg_isolated",
589
+ { environment: "prod" },
590
+ );
591
+ expect(isolatedResult?.cohortVerdict).toBe("isolated_to_new_version");
592
+ expect(isolatedResult?.causalProofTier).toBe("cohort_rate_isolated");
593
+
594
+ db.close();
595
+ });
596
+
597
+ it("Criterion 6 (P2 #8): normalizes dynamic IDs in buildFindingFingerprint so recurring incidents produce the exact same fingerprint", () => {
598
+ const fp1 = buildFindingFingerprint({
599
+ environment: "prod",
600
+ originatingNodeId: "service:prod:default:checkout-api",
601
+ normalizedOperation:
602
+ "POST /v1/orders/123e4567-e89b-12d3-a456-426614174000",
603
+ errorSignature:
604
+ "LockWaitTimeoutError: Lock wait timeout 5012ms for order 123e4567-e89b-12d3-a456-426614174000 at 2026-10-15T01:00:00Z",
605
+ downstreamTargetNodeId:
606
+ "database:prod:postgresql:pg-primary.internal:5432/orders",
607
+ });
608
+
609
+ const fp2 = buildFindingFingerprint({
610
+ environment: "prod",
611
+ originatingNodeId: "service:prod:default:checkout-api",
612
+ normalizedOperation:
613
+ "POST /v1/orders/987fcdeb-51a2-43d7-9012-345678901234",
614
+ errorSignature:
615
+ "LockWaitTimeoutError: Lock wait timeout 5489ms for order 987fcdeb-51a2-43d7-9012-345678901234 at 2026-10-15T03:15:22Z",
616
+ downstreamTargetNodeId:
617
+ "database:prod:postgresql:pg-primary.internal:5432/orders",
618
+ });
619
+
620
+ expect(fp1.fingerprint).toBe(fp2.fingerprint);
621
+ expect(fp1.normalizedInput).toEqual(fp2.normalizedInput);
622
+ });
623
+
624
+ it("Criteria 4, 5 & End-to-End 6-Hop Closed-Loop Flow (P1 #1-#5, P2 #6-#8): executes full symptom -> fault locus -> cohort & contention -> paired counterfactual -> governed remediation -> post-remediation watchdog & recurrence memory", async () => {
625
+ const db = createMigratedSqliteDb();
626
+ const { app, env } = createTestApp(db);
627
+
628
+ const deployTime = Date.parse("2026-10-15T03:00:00.000Z");
629
+
630
+ // 1. Seed concurrent v2.4.0 traffic (60 spans, 1 error = 1.67% error rate)
631
+ // and canary v2.4.1 traffic (60 spans, 15 errors = 25% error rate) with code stackframe in src/checkout/inventory.ts
632
+ const dbNodeId =
633
+ "database:prod:postgresql:pg-primary.internal:5432/orders";
634
+ const serviceNodeId = "service:prod:default:checkout-api";
635
+
636
+ for (let i = 0; i < 60; i++) {
637
+ const startIso = new Date(deployTime + (i + 1) * 1000).toISOString();
638
+ const endIso = new Date(deployTime + (i + 1) * 1000 + 40).toISOString();
639
+ insertSpan(db, {
640
+ traceId: `tr_base_${i}`,
641
+ spanId: `sp_base_${i}`,
642
+ serviceName: "checkout-api",
643
+ spanName: "POST /checkout",
644
+ spanKind: 1,
645
+ statusCode: i === 0 ? 2 : 1,
646
+ statusMessage: i === 0 ? "Transient error" : null,
647
+ startTime: startIso,
648
+ endTime: endIso,
649
+ durationMs: 40,
650
+ environment: "prod",
651
+ serviceVersion: "v2.4.0",
652
+ commitSha: "1111111",
653
+ targetNodeId: dbNodeId,
654
+ });
655
+ }
656
+
657
+ for (let i = 0; i < 60; i++) {
658
+ const isError = i < 15;
659
+ const startIso = new Date(deployTime + (i + 1) * 1000).toISOString();
660
+ const endIso = new Date(deployTime + (i + 1) * 1000 + 850).toISOString();
661
+ insertSpan(db, {
662
+ traceId: `tr_new_${i}`,
663
+ spanId: `sp_new_${i}`,
664
+ serviceName: "checkout-api",
665
+ spanName: "POST /checkout",
666
+ spanKind: 1,
667
+ statusCode: isError ? 2 : 1,
668
+ statusMessage: isError
669
+ ? `LockWaitTimeoutError: timeout 5000ms on order 123e4567-e89b-12d3-a456-426614174000`
670
+ : null,
671
+ startTime: startIso,
672
+ endTime: endIso,
673
+ durationMs: isError ? 850 : 45,
674
+ environment: "prod",
675
+ serviceVersion: "v2.4.1",
676
+ commitSha: "8f3a1b2",
677
+ targetNodeId: dbNodeId,
678
+ errorSignature: isError
679
+ ? "LockWaitTimeoutError: timeout <num> on order <uuid>"
680
+ : null,
681
+ attributes: isError
682
+ ? {
683
+ "code.filepath": "src/checkout/inventory.ts",
684
+ "code.function": "reserveInventoryBatch",
685
+ }
686
+ : {},
687
+ });
688
+ }
689
+
690
+ // Record explicit deploy ChangeEvent via POST /v1/changes
691
+ const changeRes = await app.request(
692
+ "/v1/changes",
693
+ {
694
+ method: "POST",
695
+ headers: { "Content-Type": "application/json" },
696
+ body: JSON.stringify({
697
+ id: "chg_deploy_241",
698
+ environment: "prod",
699
+ kind: "deploy",
700
+ source: "ci_webhook",
701
+ serviceName: "checkout-api",
702
+ version: "v2.4.1",
703
+ previousVersion: "v2.4.0",
704
+ commitSha: "8f3a1b2",
705
+ previousCommitSha: "1111111",
706
+ summary: "Deploy checkout-api v2.4.1 (batch inventory lock)",
707
+ touchedPaths: [
708
+ "src/checkout/inventory.ts",
709
+ "src/checkout/routes.ts",
710
+ ],
711
+ occurredAt: new Date(deployTime).toISOString(),
712
+ }),
713
+ },
714
+ env,
715
+ );
716
+ expect(changeRes.status).toBe(201);
717
+
718
+ // Verify GET /internal/changes/:id/blast-radius computes cohort lift + code-path overlap
719
+ const blastRes = await app.request(
720
+ "/internal/changes/chg_deploy_241/blast-radius?environment=prod",
721
+ { method: "GET" },
722
+ env,
723
+ );
724
+ expect(blastRes.status).toBe(200);
725
+ const { blastRadius } = (await blastRes.json()) as {
726
+ blastRadius: NonNullable<
727
+ Awaited<ReturnType<typeof computeChangeBlastRadius>>
728
+ >;
729
+ };
730
+ expect(blastRadius.cohortVerdict).toBe("isolated_to_new_version");
731
+ expect(blastRadius.causalProofTier).toBe("cohort_rate_isolated");
732
+ expect(blastRadius.overlappingCodePaths).toContain(
733
+ "src/checkout/inventory.ts",
734
+ );
735
+
736
+ // 2. Incident deduplication & runner lease coordination (P2 #7)
737
+ const inc1Res = await app.request(
738
+ "/internal/incidents",
739
+ {
740
+ method: "POST",
741
+ headers: { "Content-Type": "application/json" },
742
+ body: JSON.stringify({
743
+ environment: "prod",
744
+ targetNodeId: serviceNodeId,
745
+ symptomAnchorKind: "trace",
746
+ symptomAnchorId: "tr_new_0",
747
+ symptomFingerprint: "fp_checkout_lock_timeout",
748
+ }),
749
+ },
750
+ env,
751
+ );
752
+ expect(inc1Res.status).toBe(201);
753
+ const inc1Body = (await inc1Res.json()) as {
754
+ incident: { id: string; alertCount: number };
755
+ deduplicated: boolean;
756
+ };
757
+ expect(inc1Body.deduplicated).toBe(false);
758
+ expect(inc1Body.incident.alertCount).toBe(1);
759
+
760
+ // Second identical alert coalesces into the same active incident
761
+ const inc2Res = await app.request(
762
+ "/internal/incidents",
763
+ {
764
+ method: "POST",
765
+ headers: { "Content-Type": "application/json" },
766
+ body: JSON.stringify({
767
+ environment: "prod",
768
+ targetNodeId: serviceNodeId,
769
+ symptomAnchorKind: "trace",
770
+ symptomAnchorId: "tr_new_1",
771
+ symptomFingerprint: "fp_checkout_lock_timeout",
772
+ }),
773
+ },
774
+ env,
775
+ );
776
+ expect(inc2Res.status).toBe(200);
777
+ const inc2Body = (await inc2Res.json()) as {
778
+ incident: { id: string; alertCount: number };
779
+ deduplicated: boolean;
780
+ };
781
+ expect(inc2Body.deduplicated).toBe(true);
782
+ expect(inc2Body.incident.id).toBe(inc1Body.incident.id);
783
+ expect(inc2Body.incident.alertCount).toBe(2);
784
+
785
+ const incidentId = inc1Body.incident.id;
786
+
787
+ // Runner 1 acquires lease; Runner 2 gets 409 Conflict
788
+ const lease1 = await app.request(
789
+ `/internal/incidents/${incidentId}/lease`,
790
+ {
791
+ method: "POST",
792
+ headers: { "Content-Type": "application/json" },
793
+ body: JSON.stringify({
794
+ environment: "prod",
795
+ runnerId: "runner-alpha",
796
+ ttlSeconds: 300,
797
+ }),
798
+ },
799
+ env,
800
+ );
801
+ expect(lease1.status).toBe(200);
802
+
803
+ const lease2 = await app.request(
804
+ `/internal/incidents/${incidentId}/lease`,
805
+ {
806
+ method: "POST",
807
+ headers: { "Content-Type": "application/json" },
808
+ body: JSON.stringify({
809
+ environment: "prod",
810
+ runnerId: "runner-beta",
811
+ ttlSeconds: 300,
812
+ }),
813
+ },
814
+ env,
815
+ );
816
+ expect(lease2.status).toBe(409);
817
+
818
+ // 3. Record InvestigationFinding at corroborated_candidate first to test human approval gate (P1 #4)
819
+ const findingRes = await app.request(
820
+ "/internal/findings",
821
+ {
822
+ method: "POST",
823
+ headers: { "Content-Type": "application/json" },
824
+ body: JSON.stringify({
825
+ environment: "prod",
826
+ incidentId,
827
+ title:
828
+ "Checkout lock timeout caused by v2.4.1 batch lock in inventory.ts",
829
+ summary:
830
+ "Deploy v2.4.1 introduced batch lock contention in reserveInventoryBatch",
831
+ causalProofTier: "corroborated_candidate",
832
+ symptomEntityKind: "trace",
833
+ symptomEntityId: "tr_new_0",
834
+ faultLocusEntityKind: "span",
835
+ faultLocusEntityId: "sp_new_0",
836
+ rootCauseEntityKind: "change",
837
+ rootCauseEntityId: "chg_deploy_241",
838
+ contributingChangeId: "chg_deploy_241",
839
+ cohortVerdict: "shared_degradation_new_version_aggressor",
840
+ causalChain: [
841
+ {
842
+ subjectKind: "trace",
843
+ subjectId: "tr_new_0",
844
+ predicate: "originated_fault_in_span",
845
+ objectKind: "span",
846
+ objectId: "sp_new_0",
847
+ relationshipCertainty: "exact",
848
+ causalProofTier: "unverified_locus",
849
+ confidence: 0.9,
850
+ reason: "Fault locus",
851
+ },
852
+ {
853
+ subjectKind: "span",
854
+ subjectId: "sp_new_0",
855
+ predicate: "stackframe_overlaps_changed_file",
856
+ objectKind: "change",
857
+ objectId: "chg_deploy_241",
858
+ relationshipCertainty: "exact",
859
+ causalProofTier: "corroborated_candidate",
860
+ confidence: 0.85,
861
+ reason: "Stackframe overlaps src/checkout/inventory.ts",
862
+ },
863
+ ],
864
+ citedEvidenceIds: ["tr_new_0", "sp_new_0", "chg_deploy_241"],
865
+ }),
866
+ },
867
+ env,
868
+ );
869
+ expect(findingRes.status).toBe(201);
870
+ const { finding } = (await findingRes.json()) as {
871
+ finding: { id: string; fingerprint: string; causalProofTier: string };
872
+ };
873
+ expect(finding.causalProofTier).toBe("corroborated_candidate");
874
+
875
+ // Proposing rollback_deploy on corroborated_candidate must require human_approved_write
876
+ const propHumanRes = await app.request(
877
+ "/internal/remediations/propose",
878
+ {
879
+ method: "POST",
880
+ headers: { "Content-Type": "application/json" },
881
+ body: JSON.stringify({
882
+ environment: "prod",
883
+ incidentId,
884
+ findingId: finding.id,
885
+ idempotencyKey: "idem_human_rollback_1",
886
+ actionKind: "rollback_deploy",
887
+ targetNodeId: serviceNodeId,
888
+ expectedCurrentState: { version: "v2.4.1" },
889
+ desiredTargetState: { version: "v2.4.0" },
890
+ requestedAutonomy: "autonomous_write",
891
+ }),
892
+ },
893
+ env,
894
+ );
895
+ expect(propHumanRes.status).toBe(201);
896
+ const propHumanBody = (await propHumanRes.json()) as {
897
+ proposal: { id: string; effectiveAutonomy: string; status: string };
898
+ grantToken: string | null;
899
+ };
900
+ expect(propHumanBody.proposal.effectiveAutonomy).toBe(
901
+ "human_approved_write",
902
+ );
903
+ expect(propHumanBody.proposal.status).toBe("pending_human_approval");
904
+ expect(propHumanBody.grantToken).toBeNull();
905
+
906
+ // Agent attempting to self-approve with Authorization: Bearer must get 403 Forbidden (P1 #4)
907
+ const agentApproveRes = await app.request(
908
+ `/internal/remediations/${propHumanBody.proposal.id}/approve`,
909
+ {
910
+ method: "POST",
911
+ headers: {
912
+ "Content-Type": "application/json",
913
+ Authorization: "Bearer obs_tok_agent_secret",
914
+ },
915
+ body: JSON.stringify({ environment: "prod" }),
916
+ },
917
+ env,
918
+ );
919
+ expect(agentApproveRes.status).toBe(403);
920
+
921
+ // Human session with obs_session cookie approves -> mints grantToken
922
+ const humanApproveRes = await app.request(
923
+ `/internal/remediations/${propHumanBody.proposal.id}/approve`,
924
+ {
925
+ method: "POST",
926
+ headers: {
927
+ "Content-Type": "application/json",
928
+ Cookie: "obs_session=sess_human_operator_123",
929
+ "X-Obs-User-Actor": "sawan@obsunified.dev",
930
+ },
931
+ body: JSON.stringify({ environment: "prod" }),
932
+ },
933
+ env,
934
+ );
935
+ expect(humanApproveRes.status).toBe(200);
936
+ const humanApproveBody = (await humanApproveRes.json()) as {
937
+ proposal: { status: string; approvedByUserId: string };
938
+ grantToken: string;
939
+ };
940
+ expect(humanApproveBody.proposal.status).toBe("grant_issued");
941
+ expect(humanApproveBody.proposal.approvedByUserId).toBe(
942
+ "sawan@obsunified.dev",
943
+ );
944
+ expect(humanApproveBody.grantToken).toMatch(/^rgt_/);
945
+
946
+ // 4. Paired Counterfactual Gate (P1 #3)
947
+ // Create EvalCase from failing trace tr_new_0
948
+ const evalCaseRes = await app.request(
949
+ "/internal/eval-cases",
950
+ {
951
+ method: "POST",
952
+ headers: { "Content-Type": "application/json" },
953
+ body: JSON.stringify({
954
+ sourceEntityType: "trace",
955
+ sourceEntityId: "tr_new_0",
956
+ name: "Reproduce checkout lock timeout",
957
+ expectedOutcome: "Checkout completes under 200ms without lock timeout",
958
+ }),
959
+ },
960
+ env,
961
+ );
962
+ expect(evalCaseRes.status).toBe(201);
963
+ const { evalCase } = (await evalCaseRes.json()) as {
964
+ evalCase: { id: string };
965
+ };
966
+
967
+ // Create failing baseline EvalRun (v2.4.1) and passing candidate EvalRun (v2.4.0)
968
+ const failRunRes = await app.request(
969
+ "/internal/eval-runs",
970
+ {
971
+ method: "POST",
972
+ headers: { "Content-Type": "application/json" },
973
+ body: JSON.stringify({
974
+ id: "run_baseline_fail_241",
975
+ evalCaseId: evalCase.id,
976
+ status: "failed",
977
+ environment: "prod",
978
+ serviceVersion: "v2.4.1",
979
+ commitSha: "8f3a1b2",
980
+ inputPayloadHash: "sha256_payload_checkout_99",
981
+ failureFingerprint: finding.fingerprint,
982
+ totalCount: 1,
983
+ passCount: 0,
984
+ failCount: 1,
985
+ }),
986
+ },
987
+ env,
988
+ );
989
+ expect(failRunRes.status).toBe(201);
990
+
991
+ const passRunRes = await app.request(
992
+ "/internal/eval-runs",
993
+ {
994
+ method: "POST",
995
+ headers: { "Content-Type": "application/json" },
996
+ body: JSON.stringify({
997
+ id: "run_candidate_pass_240",
998
+ evalCaseId: evalCase.id,
999
+ status: "completed",
1000
+ environment: "prod",
1001
+ serviceVersion: "v2.4.0",
1002
+ commitSha: "1111111",
1003
+ inputPayloadHash: "sha256_payload_checkout_99",
1004
+ totalCount: 1,
1005
+ passCount: 1,
1006
+ failCount: 0,
1007
+ }),
1008
+ },
1009
+ env,
1010
+ );
1011
+ expect(passRunRes.status).toBe(201);
1012
+
1013
+ // Attempting to verify counterfactual when baseline also passed (or same run passed for both) must fail with 422
1014
+ const invalidCfRes = await app.request(
1015
+ `/internal/findings/${finding.id}/counterfactual`,
1016
+ {
1017
+ method: "POST",
1018
+ headers: { "Content-Type": "application/json" },
1019
+ body: JSON.stringify({
1020
+ environment: "prod",
1021
+ evalCaseId: evalCase.id,
1022
+ failingRunId: "run_candidate_pass_240", // passing run used as baseline -> must fail!
1023
+ passingRunIds: ["run_candidate_pass_240"],
1024
+ }),
1025
+ },
1026
+ env,
1027
+ );
1028
+ expect(invalidCfRes.status).toBe(422);
1029
+
1030
+ // Valid paired counterfactual verification upgrades finding to counterfactual_verified
1031
+ const validCfRes = await app.request(
1032
+ `/internal/findings/${finding.id}/counterfactual`,
1033
+ {
1034
+ method: "POST",
1035
+ headers: { "Content-Type": "application/json" },
1036
+ body: JSON.stringify({
1037
+ environment: "prod",
1038
+ evalCaseId: evalCase.id,
1039
+ failingRunId: "run_baseline_fail_241",
1040
+ passingRunIds: ["run_candidate_pass_240"],
1041
+ }),
1042
+ },
1043
+ env,
1044
+ );
1045
+ expect(validCfRes.status).toBe(200);
1046
+ const validCfBody = (await validCfRes.json()) as {
1047
+ finding: {
1048
+ causalProofTier: string;
1049
+ causalVerificationStatus: string;
1050
+ confidence: number;
1051
+ };
1052
+ };
1053
+ expect(validCfBody.finding.causalProofTier).toBe("counterfactual_verified");
1054
+ expect(validCfBody.finding.causalVerificationStatus).toBe(
1055
+ "counterfactual_verified",
1056
+ );
1057
+ expect(validCfBody.finding.confidence).toBeGreaterThanOrEqual(0.95);
1058
+
1059
+ // 5. Autonomous Remediation Proposal, State-Mismatch Check, Single-Use Claim & Complete
1060
+ const autoPropRes = await app.request(
1061
+ "/internal/remediations/propose",
1062
+ {
1063
+ method: "POST",
1064
+ headers: { "Content-Type": "application/json" },
1065
+ body: JSON.stringify({
1066
+ environment: "prod",
1067
+ incidentId,
1068
+ findingId: finding.id,
1069
+ idempotencyKey: "idem_auto_rollback_241",
1070
+ actionKind: "rollback_deploy",
1071
+ targetNodeId: serviceNodeId,
1072
+ expectedCurrentState: { version: "v2.4.1" },
1073
+ desiredTargetState: { version: "v2.4.0" },
1074
+ requestedAutonomy: "autonomous_write",
1075
+ }),
1076
+ },
1077
+ env,
1078
+ );
1079
+ expect(autoPropRes.status).toBe(201);
1080
+ const autoPropBody = (await autoPropRes.json()) as {
1081
+ proposal: { id: string; effectiveAutonomy: string; status: string };
1082
+ grantToken: string;
1083
+ };
1084
+ expect(autoPropBody.proposal.effectiveAutonomy).toBe("autonomous_write");
1085
+ expect(autoPropBody.proposal.status).toBe("grant_issued");
1086
+ expect(autoPropBody.grantToken).toMatch(/^rgt_/);
1087
+
1088
+ // Claiming with single-use grantToken succeeds once and fails on replay
1089
+ const claim1Res = await app.request(
1090
+ `/internal/remediations/${autoPropBody.proposal.id}/claim`,
1091
+ {
1092
+ method: "POST",
1093
+ headers: { "Content-Type": "application/json" },
1094
+ body: JSON.stringify({
1095
+ environment: "prod",
1096
+ grantToken: autoPropBody.grantToken,
1097
+ runnerId: "runner-alpha",
1098
+ }),
1099
+ },
1100
+ env,
1101
+ );
1102
+ expect(claim1Res.status).toBe(200);
1103
+
1104
+ const claimReplayRes = await app.request(
1105
+ `/internal/remediations/${autoPropBody.proposal.id}/claim`,
1106
+ {
1107
+ method: "POST",
1108
+ headers: { "Content-Type": "application/json" },
1109
+ body: JSON.stringify({
1110
+ environment: "prod",
1111
+ grantToken: autoPropBody.grantToken,
1112
+ runnerId: "runner-alpha",
1113
+ }),
1114
+ },
1115
+ env,
1116
+ );
1117
+ expect(claimReplayRes.status).toBe(409);
1118
+
1119
+ // Record the rollback change event and complete remediation
1120
+ const rollbackChange = await recordChangeEvent(db, "default", {
1121
+ id: "chg_rollback_240",
1122
+ environment: "prod",
1123
+ kind: "rollback",
1124
+ source: "autosre_remediation",
1125
+ serviceName: "checkout-api",
1126
+ version: "v2.4.0",
1127
+ previousVersion: "v2.4.1",
1128
+ summary: "Autonomous rollback checkout-api v2.4.1 -> v2.4.0",
1129
+ occurredAt: new Date(deployTime + 120_000).toISOString(),
1130
+ });
1131
+
1132
+ const completeRes = await app.request(
1133
+ `/internal/remediations/${autoPropBody.proposal.id}/complete`,
1134
+ {
1135
+ method: "POST",
1136
+ headers: { "Content-Type": "application/json" },
1137
+ body: JSON.stringify({
1138
+ environment: "prod",
1139
+ resultingChangeId: rollbackChange.id,
1140
+ remediationAgentRunId: "arun_autosre_1",
1141
+ }),
1142
+ },
1143
+ env,
1144
+ );
1145
+ expect(completeRes.status).toBe(200);
1146
+
1147
+ // 6. Post-Remediation Watchdog: Low Traffic vs Normal Recovery (P1 #3)
1148
+ // First force watchdog_step = 3 (+15m check) due when < 50 post-remediation spans exist -> inconclusive_low_traffic
1149
+ db.raw
1150
+ .prepare(
1151
+ `UPDATE investigation_findings SET watchdog_step = 3, next_watchdog_check_at = ? WHERE id = ?`,
1152
+ )
1153
+ .run(new Date(deployTime + 200_000).toISOString(), finding.id);
1154
+
1155
+ const watchdogLow = await evaluateRemediationWatchdogs(db, {
1156
+ nowIso: new Date(deployTime + 300_000).toISOString(),
1157
+ });
1158
+ expect(watchdogLow.evaluated).toBe(1);
1159
+ expect(watchdogLow.inconclusiveLowTraffic).toBe(1);
1160
+
1161
+ // Now seed 60 healthy post-rollback spans on v2.4.0 and advance watchdog
1162
+ const postRollbackBase = deployTime + 600_000;
1163
+ for (let i = 0; i < 60; i++) {
1164
+ const sIso = new Date(postRollbackBase + i * 500).toISOString();
1165
+ const eIso = new Date(postRollbackBase + i * 500 + 35).toISOString();
1166
+ insertSpan(db, {
1167
+ traceId: `tr_recovered_${i}`,
1168
+ spanId: `sp_recovered_${i}`,
1169
+ serviceName: "checkout-api",
1170
+ spanName: "POST /checkout",
1171
+ spanKind: 1,
1172
+ statusCode: 1,
1173
+ startTime: sIso,
1174
+ endTime: eIso,
1175
+ durationMs: 35,
1176
+ environment: "prod",
1177
+ serviceVersion: "v2.4.0",
1178
+ });
1179
+ }
1180
+
1181
+ // Force next_watchdog_check_at due and evaluate
1182
+ db.raw
1183
+ .prepare(
1184
+ `UPDATE investigation_findings SET recovery_status = 'monitoring', watchdog_step = 3, next_watchdog_check_at = ? WHERE id = ?`,
1185
+ )
1186
+ .run(new Date(postRollbackBase + 40_000).toISOString(), finding.id);
1187
+
1188
+ const watchdogRecovered = await evaluateRemediationWatchdogs(db, {
1189
+ nowIso: new Date(postRollbackBase + 50_000).toISOString(),
1190
+ });
1191
+ expect(watchdogRecovered.recovered).toBe(1);
1192
+
1193
+ // Verify incident is now resolved and finding recoveryStatus === 'recovered'
1194
+ const incCheckRes = await app.request(
1195
+ `/internal/incidents/${incidentId}?environment=prod`,
1196
+ { method: "GET" },
1197
+ env,
1198
+ );
1199
+ expect(incCheckRes.status).toBe(200);
1200
+ const incCheckBody = (await incCheckRes.json()) as {
1201
+ incident: { status: string };
1202
+ findings: Array<{
1203
+ id: string;
1204
+ recoveryStatus: string;
1205
+ causalVerificationStatus: string;
1206
+ }>;
1207
+ };
1208
+ expect(incCheckBody.incident.status).toBe("resolved");
1209
+ expect(incCheckBody.findings[0]?.recoveryStatus).toBe("recovered");
1210
+
1211
+ // 7. Connected Signals & Evidence Bundle (triage_incident)
1212
+ const connectedRes = await app.request(
1213
+ `/internal/connected/incident/${incidentId}`,
1214
+ { method: "GET" },
1215
+ env,
1216
+ );
1217
+ expect(connectedRes.status).toBe(200);
1218
+ const connectedBody = (await connectedRes.json()) as {
1219
+ across: Array<{
1220
+ label: string;
1221
+ links: Array<{
1222
+ entityKind?: string;
1223
+ entityId?: string;
1224
+ relationshipCertainty?: string;
1225
+ causalProofTier?: string;
1226
+ }>;
1227
+ }>;
1228
+ };
1229
+ const allAcrossLinks = connectedBody.across.flatMap((s) => s.links);
1230
+ const findingLink = allAcrossLinks.find((l) => l.entityKind === "finding");
1231
+ expect(findingLink).toBeDefined();
1232
+ expect(findingLink?.entityId).toBe(finding.id);
1233
+ expect(findingLink?.relationshipCertainty).toBe("exact");
1234
+ expect(findingLink?.causalProofTier).toBe("counterfactual_verified");
1235
+
1236
+ const bundleRes = await app.request(
1237
+ `/internal/evidence/bundle`,
1238
+ {
1239
+ method: "POST",
1240
+ headers: { "Content-Type": "application/json" },
1241
+ body: JSON.stringify({
1242
+ intent: "triage_incident",
1243
+ anchor: {
1244
+ entityKind: "incident",
1245
+ entityId: incidentId,
1246
+ },
1247
+ }),
1248
+ },
1249
+ env,
1250
+ );
1251
+ expect(bundleRes.status).toBe(200);
1252
+ const bundleBody = (await bundleRes.json()) as {
1253
+ intent: string;
1254
+ autosre?: {
1255
+ faultLocus?: { nodeId: string; spanId?: string };
1256
+ candidateChanges?: Array<{ changeId: string }>;
1257
+ priorFindings?: Array<{ findingId: string }>;
1258
+ };
1259
+ };
1260
+ expect(bundleBody.intent).toBe("triage_incident");
1261
+ expect(bundleBody.autosre).toBeDefined();
1262
+ expect(
1263
+ bundleBody.autosre?.candidateChanges?.some(
1264
+ (c) => c.changeId === "chg_deploy_241",
1265
+ ),
1266
+ ).toBe(true);
1267
+ expect(
1268
+ bundleBody.autosre?.priorFindings?.some(
1269
+ (f) => f.findingId === finding.id,
1270
+ ),
1271
+ ).toBe(true);
1272
+
1273
+ db.close();
1274
+ });
1275
+ });