@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,2222 @@
1
+ import {
2
+ buildCanonicalNodeId,
3
+ buildFindingFingerprint,
4
+ } from "@obsunified/types";
5
+ import { Hono } from "hono";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { CollectorRuntime } from "../framework/collector";
8
+ import type { CollectorEnv } from "../framework/env";
9
+ import {
10
+ computeChangeBlastRadius,
11
+ findCrossTraceContention,
12
+ recordChangeEvent,
13
+ } from "../lib/autosre-change-correlation";
14
+ import { recordInvestigationFinding } from "../lib/autosre-findings";
15
+ import {
16
+ computeTarjanScc,
17
+ extractSpanLineageAndTopology,
18
+ localizeTraceFaultLocus,
19
+ upsertOntologyFromSpans,
20
+ } from "../lib/autosre-ontology";
21
+ import {
22
+ acquireIncidentLease,
23
+ claimRemediationGrant,
24
+ completeRemediationProposal,
25
+ dispatchIncidentWebhook,
26
+ evaluateRemediationWatchdogs,
27
+ failRemediationProposal,
28
+ openOrDeduplicateIncident,
29
+ proposeRemediationAction,
30
+ RemediationGateError,
31
+ rejectRemediationProposal,
32
+ } from "../lib/autosre-remediation-gate";
33
+ import {
34
+ createMigratedSqliteDb,
35
+ type NodeSqliteDb,
36
+ } from "../lib/test-utils/node-sqlite-db";
37
+ import { autosreRoutesPlugin } from "../plugins/autosre-routes";
38
+ import { connectedRoutesPlugin } from "../plugins/connected-routes";
39
+ import { evalCasesRoutesPlugin } from "../plugins/eval-cases-routes";
40
+ import { evidenceRetrievalRoutesPlugin } from "../plugins/evidence-retrieval-routes";
41
+
42
+ const createTestApp = (db: NodeSqliteDb) => {
43
+ const app = new Hono<{ Bindings: CollectorEnv }>();
44
+ const runtime = new CollectorRuntime();
45
+ autosreRoutesPlugin.register(app, runtime);
46
+ evalCasesRoutesPlugin.register(app, runtime);
47
+ connectedRoutesPlugin.register(app, runtime);
48
+ evidenceRetrievalRoutesPlugin.register(app, runtime);
49
+ const env: CollectorEnv = {
50
+ DB: db as unknown as D1Database,
51
+ };
52
+ return { app, env };
53
+ };
54
+
55
+ const insertSpan = (
56
+ db: NodeSqliteDb,
57
+ opts: {
58
+ traceId: string;
59
+ spanId: string;
60
+ parentSpanId?: string | null;
61
+ serviceName: string;
62
+ spanName: string;
63
+ spanKind?: number;
64
+ statusCode?: number;
65
+ statusMessage?: string | null;
66
+ startTime: string;
67
+ endTime: string;
68
+ durationMs: number;
69
+ environment?: string;
70
+ serviceVersion?: string | null;
71
+ commitSha?: string | null;
72
+ targetNodeId?: string | null;
73
+ errorSignature?: string | null;
74
+ attributes?: Record<string, unknown>;
75
+ resourceAttributes?: Record<string, unknown>;
76
+ events?: unknown[];
77
+ },
78
+ ) => {
79
+ db.raw
80
+ .prepare(
81
+ `INSERT INTO telemetry_spans (
82
+ project_id, trace_id, span_id, parent_span_id, service_name,
83
+ span_name, span_kind, start_time, end_time, duration_ms,
84
+ status_code, status_message, attributes_json, resource_attributes_json,
85
+ events_json, links_json, received_at, expires_at,
86
+ environment, service_version, commit_sha, target_node_id, error_signature
87
+ ) VALUES (
88
+ 'default', ?, ?, ?, ?,
89
+ ?, ?, ?, ?, ?,
90
+ ?, ?, ?, ?,
91
+ ?, '[]', ?, ?,
92
+ ?, ?, ?, ?, ?
93
+ )`,
94
+ )
95
+ .run(
96
+ opts.traceId,
97
+ opts.spanId,
98
+ opts.parentSpanId ?? null,
99
+ opts.serviceName,
100
+ opts.spanName,
101
+ opts.spanKind ?? 1,
102
+ opts.startTime,
103
+ opts.endTime,
104
+ opts.durationMs,
105
+ opts.statusCode ?? 1,
106
+ opts.statusMessage ?? null,
107
+ JSON.stringify(opts.attributes ?? {}),
108
+ JSON.stringify(opts.resourceAttributes ?? {}),
109
+ JSON.stringify(opts.events ?? []),
110
+ opts.startTime,
111
+ new Date(Date.parse(opts.startTime) + 72 * 3600_000).toISOString(),
112
+ opts.environment ?? "prod",
113
+ opts.serviceVersion ?? null,
114
+ opts.commitSha ?? null,
115
+ opts.targetNodeId ?? null,
116
+ opts.errorSignature ?? null,
117
+ );
118
+ };
119
+
120
+ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop Incident Memory", () => {
121
+ it("Criterion 1 (P1 #5): isolates environment and host:port in canonical node IDs and caps unscoped DB contention at inferred certainty", async () => {
122
+ const db = createMigratedSqliteDb();
123
+
124
+ const prodCheckout = buildCanonicalNodeId({
125
+ kind: "service",
126
+ environment: "prod",
127
+ namespace: "default",
128
+ serviceName: "checkout-api",
129
+ });
130
+ const stagingCheckout = buildCanonicalNodeId({
131
+ kind: "service",
132
+ environment: "staging",
133
+ namespace: "default",
134
+ serviceName: "checkout-api",
135
+ });
136
+ expect(prodCheckout.id).toBe("service:prod:default:checkout-api");
137
+ expect(stagingCheckout.id).toBe("service:staging:default:checkout-api");
138
+ expect(prodCheckout.id).not.toBe(stagingCheckout.id);
139
+
140
+ // Two DBs named 'orders' on different hosts must not collide
141
+ const primarySpan = {
142
+ projectId: "default",
143
+ traceId: "tr_db_1",
144
+ spanId: "sp_db_1",
145
+ parentSpanId: null,
146
+ traceState: null,
147
+ serviceName: "checkout-api",
148
+ scopeName: "db-client",
149
+ scopeVersion: "1.0.0",
150
+ spanName: "SELECT orders",
151
+ spanKind: 3,
152
+ startTime: "2026-10-15T01:00:00.000Z",
153
+ endTime: "2026-10-15T01:00:00.050Z",
154
+ durationMs: 50,
155
+ statusCode: 1,
156
+ statusMessage: null,
157
+ attributesJson: JSON.stringify({
158
+ "db.system": "postgresql",
159
+ "db.name": "orders",
160
+ "server.address": "pg-primary.internal",
161
+ "server.port": 5432,
162
+ }),
163
+ resourceAttributesJson: JSON.stringify({
164
+ "deployment.environment.name": "prod",
165
+ "service.version": "v2.4.1",
166
+ }),
167
+ eventsJson: "[]",
168
+ linksJson: "[]",
169
+ droppedAttributesCount: 0,
170
+ droppedEventsCount: 0,
171
+ droppedLinksCount: 0,
172
+ receivedAt: "2026-10-15T01:00:00.050Z",
173
+ expiresAt: "2026-10-18T01:00:00.050Z",
174
+ };
175
+ const replicaSpan = {
176
+ ...primarySpan,
177
+ traceId: "tr_db_2",
178
+ spanId: "sp_db_2",
179
+ serviceName: "analytics-worker",
180
+ attributesJson: JSON.stringify({
181
+ "db.system": "postgresql",
182
+ "db.name": "orders",
183
+ "server.address": "pg-replica.internal",
184
+ "server.port": 5432,
185
+ }),
186
+ };
187
+ const unscopedSpan = {
188
+ ...primarySpan,
189
+ traceId: "tr_db_unscoped",
190
+ spanId: "sp_db_unscoped",
191
+ serviceName: "legacy-worker",
192
+ attributesJson: JSON.stringify({
193
+ "db.system": "postgresql",
194
+ "db.name": "orders",
195
+ }),
196
+ };
197
+
198
+ const primaryCtx = extractSpanLineageAndTopology(primarySpan);
199
+ const replicaCtx = extractSpanLineageAndTopology(replicaSpan);
200
+ const unscopedCtx = extractSpanLineageAndTopology(unscopedSpan);
201
+
202
+ expect(primaryCtx.targetNode?.id).toBe(
203
+ "database:prod:postgresql:pg-primary.internal:5432/orders",
204
+ );
205
+ expect(primaryCtx.targetNode?.isScopedIdentity).toBe(true);
206
+ expect(replicaCtx.targetNode?.id).toBe(
207
+ "database:prod:postgresql:pg-replica.internal:5432/orders",
208
+ );
209
+ expect(replicaCtx.targetNode?.isScopedIdentity).toBe(true);
210
+ expect(primaryCtx.targetNode?.id).not.toBe(replicaCtx.targetNode?.id);
211
+
212
+ expect(unscopedCtx.targetNode?.id).toBe(
213
+ "database:prod:unscoped/postgresql:orders",
214
+ );
215
+ expect(unscopedCtx.targetNode?.isScopedIdentity).toBe(false);
216
+
217
+ await upsertOntologyFromSpans(
218
+ db,
219
+ [primarySpan, replicaSpan, unscopedSpan],
220
+ { bypassCache: true },
221
+ );
222
+
223
+ // Verify unscoped DB contention caps relationshipCertainty at "inferred"
224
+ const baseMs = Date.parse("2026-10-15T01:10:00.000Z");
225
+ const unscopedNodeId = unscopedCtx.targetNode?.id;
226
+ if (!unscopedNodeId) throw new Error("expected an unscoped target node");
227
+ for (let i = 0; i < 6; i++) {
228
+ insertSpan(db, {
229
+ traceId: "tr_aggressor_unscoped",
230
+ spanId: `sp_agg_${i}`,
231
+ serviceName: "checkout-api",
232
+ spanName: "UPDATE orders",
233
+ spanKind: 3,
234
+ statusCode: 1,
235
+ startTime: new Date(baseMs + i * 50).toISOString(),
236
+ endTime: new Date(baseMs + i * 50 + 400).toISOString(),
237
+ durationMs: 400,
238
+ environment: "prod",
239
+ serviceVersion: "v2.4.1",
240
+ targetNodeId: unscopedNodeId,
241
+ });
242
+ }
243
+ insertSpan(db, {
244
+ traceId: "tr_victim_unscoped",
245
+ spanId: "sp_victim_1",
246
+ serviceName: "checkout-api",
247
+ spanName: "SELECT orders",
248
+ spanKind: 3,
249
+ statusCode: 2,
250
+ statusMessage: "Lock wait timeout",
251
+ startTime: new Date(baseMs + 100).toISOString(),
252
+ endTime: new Date(baseMs + 900).toISOString(),
253
+ durationMs: 800,
254
+ environment: "prod",
255
+ serviceVersion: "v2.4.0",
256
+ targetNodeId: unscopedNodeId,
257
+ });
258
+
259
+ const contention = await findCrossTraceContention(db, {
260
+ projectId: "default",
261
+ environment: "prod",
262
+ targetNodeId: unscopedNodeId,
263
+ excludeTraceId: "tr_victim_unscoped",
264
+ startTime: new Date(baseMs - 1000).toISOString(),
265
+ endTime: new Date(baseMs + 5000).toISOString(),
266
+ });
267
+ expect(contention.length).toBeGreaterThan(0);
268
+ expect(contention[0]?.aggressorTraceId).toBe("tr_aggressor_unscoped");
269
+ expect(contention[0]?.relationshipCertainty).toBe("inferred");
270
+
271
+ db.close();
272
+ });
273
+
274
+ 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 () => {
275
+ const db = createMigratedSqliteDb();
276
+ const { app, env } = createTestApp(db);
277
+
278
+ // Cyclic graph: service A -> service B -> service A
279
+ const sccResult = computeTarjanScc(
280
+ [
281
+ {
282
+ id: "service:prod:default:svc-a",
283
+ projectId: "default",
284
+ environment: "prod",
285
+ kind: "service",
286
+ name: "svc-a",
287
+ namespaceOrEndpoint: "default",
288
+ isScopedIdentity: true,
289
+ currentVersion: "v1",
290
+ currentCommitSha: null,
291
+ currentConfigDigest: null,
292
+ attributes: {},
293
+ firstSeenAt: "2026-10-15T01:00:00.000Z",
294
+ lastSeenAt: "2026-10-15T01:00:00.000Z",
295
+ },
296
+ {
297
+ id: "service:prod:default:svc-b",
298
+ projectId: "default",
299
+ environment: "prod",
300
+ kind: "service",
301
+ name: "svc-b",
302
+ namespaceOrEndpoint: "default",
303
+ isScopedIdentity: true,
304
+ currentVersion: "v1",
305
+ currentCommitSha: null,
306
+ currentConfigDigest: null,
307
+ attributes: {},
308
+ firstSeenAt: "2026-10-15T01:00:00.000Z",
309
+ lastSeenAt: "2026-10-15T01:00:00.000Z",
310
+ },
311
+ ],
312
+ [
313
+ {
314
+ id: "e1",
315
+ projectId: "default",
316
+ environment: "prod",
317
+ sourceNodeId: "service:prod:default:svc-a",
318
+ predicate: "calls",
319
+ targetNodeId: "service:prod:default:svc-b",
320
+ relationshipCertainty: "exact",
321
+ callCount: 10,
322
+ errorCount: 2,
323
+ lastDurationMs: 100,
324
+ firstSeenAt: "2026-10-15T01:00:00.000Z",
325
+ lastSeenAt: "2026-10-15T01:00:00.000Z",
326
+ },
327
+ {
328
+ id: "e2",
329
+ projectId: "default",
330
+ environment: "prod",
331
+ sourceNodeId: "service:prod:default:svc-b",
332
+ predicate: "calls",
333
+ targetNodeId: "service:prod:default:svc-a",
334
+ relationshipCertainty: "exact",
335
+ callCount: 10,
336
+ errorCount: 2,
337
+ lastDurationMs: 90,
338
+ firstSeenAt: "2026-10-15T01:00:00.000Z",
339
+ lastSeenAt: "2026-10-15T01:00:00.000Z",
340
+ },
341
+ ],
342
+ );
343
+
344
+ expect(sccResult.components).toHaveLength(1);
345
+ expect(sccResult.components[0]?.isCyclic).toBe(true);
346
+ expect(sccResult.annotatedNodes.every((n) => n.inCycle === true)).toBe(
347
+ true,
348
+ );
349
+
350
+ // Cyclic span tree within a trace must also terminate deterministically
351
+ const locus = localizeTraceFaultLocus("tr_cyclic", [
352
+ {
353
+ span_id: "sp_root",
354
+ parent_span_id: "sp_leaf", // cycle in span parent pointers
355
+ service_name: "svc-a",
356
+ span_name: "POST /checkout",
357
+ span_kind: 1,
358
+ status_code: 2,
359
+ status_message: "500 Internal Error",
360
+ start_time: "2026-10-15T01:00:00.000Z",
361
+ end_time: "2026-10-15T01:00:01.000Z",
362
+ duration_ms: 1000,
363
+ environment: "prod",
364
+ },
365
+ {
366
+ span_id: "sp_leaf",
367
+ parent_span_id: "sp_root",
368
+ service_name: "svc-b",
369
+ span_name: "SELECT inventory",
370
+ span_kind: 3,
371
+ status_code: 2,
372
+ status_message: "Deadlock detected",
373
+ start_time: "2026-10-15T01:00:00.100Z",
374
+ end_time: "2026-10-15T01:00:00.900Z",
375
+ duration_ms: 800,
376
+ environment: "prod",
377
+ },
378
+ ]);
379
+
380
+ expect(locus.faultLocusSpan?.span_id).toBe("sp_leaf");
381
+ expect(locus.relationshipCertainty).toBe("exact");
382
+ expect(locus.causalProofTier).toBe("unverified_locus");
383
+
384
+ // Record an unverified_locus finding and verify rollback_deploy is blocked_by_policy (P1 #1)
385
+ const incRes = await app.request(
386
+ "/internal/incidents",
387
+ {
388
+ method: "POST",
389
+ headers: { "Content-Type": "application/json" },
390
+ body: JSON.stringify({
391
+ environment: "prod",
392
+ targetNodeId: "service:prod:default:svc-a",
393
+ symptomAnchorKind: "trace",
394
+ symptomAnchorId: "tr_cyclic",
395
+ }),
396
+ },
397
+ env,
398
+ );
399
+ expect(incRes.status).toBe(201);
400
+ const { incident } = (await incRes.json()) as {
401
+ incident: { id: string };
402
+ };
403
+
404
+ const findingRes = await app.request(
405
+ "/internal/findings",
406
+ {
407
+ method: "POST",
408
+ headers: { "Content-Type": "application/json" },
409
+ body: JSON.stringify({
410
+ environment: "prod",
411
+ incidentId: incident.id,
412
+ title: "Fault locus at sp_leaf",
413
+ summary: "Error surfaced at sp_leaf without change correlation",
414
+ causalProofTier: "unverified_locus",
415
+ symptomEntityKind: "trace",
416
+ symptomEntityId: "tr_cyclic",
417
+ faultLocusEntityKind: "span",
418
+ faultLocusEntityId: "sp_leaf",
419
+ causalChain: locus.causalTriples,
420
+ citedEvidenceIds: ["tr_cyclic", "sp_leaf"],
421
+ }),
422
+ },
423
+ env,
424
+ );
425
+ expect(findingRes.status).toBe(201);
426
+ const { finding } = (await findingRes.json()) as {
427
+ finding: { id: string; causalProofTier: string };
428
+ };
429
+ expect(finding.causalProofTier).toBe("unverified_locus");
430
+
431
+ const proposeRes = await app.request(
432
+ "/internal/remediations/propose",
433
+ {
434
+ method: "POST",
435
+ headers: { "Content-Type": "application/json" },
436
+ body: JSON.stringify({
437
+ environment: "prod",
438
+ incidentId: incident.id,
439
+ findingId: finding.id,
440
+ idempotencyKey: "idem_unverified_rollback",
441
+ actionKind: "rollback_deploy",
442
+ targetNodeId: "service:prod:default:svc-a",
443
+ expectedCurrentState: { version: "v1" },
444
+ desiredTargetState: { version: "v0" },
445
+ requestedAutonomy: "autonomous_write",
446
+ }),
447
+ },
448
+ env,
449
+ );
450
+ expect(proposeRes.status).toBe(201);
451
+ const { proposal, grantToken } = (await proposeRes.json()) as {
452
+ proposal: { effectiveAutonomy: string; status: string };
453
+ grantToken: string | null;
454
+ };
455
+ expect(proposal.effectiveAutonomy).toBe("blocked_by_policy");
456
+ expect(proposal.status).toBe("rejected");
457
+ expect(grantToken).toBeNull();
458
+
459
+ db.close();
460
+ });
461
+
462
+ 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 () => {
463
+ const db = createMigratedSqliteDb();
464
+
465
+ const t0 = Date.parse("2026-10-15T02:00:00.000Z");
466
+ const insertCohortSpans = (
467
+ serviceName: string,
468
+ version: string,
469
+ total: number,
470
+ errors: number,
471
+ dbCallsPerTrace = 0,
472
+ offsetMs = 1000,
473
+ ) => {
474
+ for (let i = 0; i < total; i++) {
475
+ const traceId = `tr_${serviceName}_${version}_${i}`;
476
+ const isError = i < errors;
477
+ const startIso = new Date(t0 + offsetMs + i * 50).toISOString();
478
+ const endIso = new Date(t0 + offsetMs + i * 50 + 40).toISOString();
479
+ insertSpan(db, {
480
+ traceId,
481
+ spanId: `sp_root_${serviceName}_${version}_${i}`,
482
+ serviceName,
483
+ spanName: "POST /checkout",
484
+ spanKind: 1, // SERVER span
485
+ statusCode: isError ? 2 : 1,
486
+ statusMessage: isError ? "Error" : null,
487
+ startTime: startIso,
488
+ endTime: endIso,
489
+ durationMs: 40,
490
+ environment: "prod",
491
+ serviceVersion: version,
492
+ });
493
+ for (let d = 0; d < dbCallsPerTrace; d++) {
494
+ insertSpan(db, {
495
+ traceId,
496
+ spanId: `sp_db_${serviceName}_${version}_${i}_${d}`,
497
+ parentSpanId: `sp_root_${serviceName}_${version}_${i}`,
498
+ serviceName,
499
+ spanName: "SELECT inventory",
500
+ spanKind: 2, // CLIENT span
501
+ statusCode: 1,
502
+ startTime: startIso,
503
+ endTime: endIso,
504
+ durationMs: 20,
505
+ environment: "prod",
506
+ serviceVersion: version,
507
+ targetNodeId: "database:prod:postgresql:pg.internal:5432/orders",
508
+ });
509
+ }
510
+ }
511
+ };
512
+
513
+ // 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%)
514
+ // Must NOT blame v2.4.1 just because it has 95% of raw errors!
515
+ await recordChangeEvent(db, "default", {
516
+ id: "chg_equal_rate",
517
+ environment: "prod",
518
+ kind: "deploy",
519
+ serviceName: "svc-equal-rate",
520
+ version: "v2.4.1",
521
+ previousVersion: "v2.4.0",
522
+ summary: "Deploy svc-equal-rate v2.4.1",
523
+ occurredAt: new Date(t0).toISOString(),
524
+ });
525
+ insertCohortSpans("svc-equal-rate", "v2.4.1", 950, 95);
526
+ insertCohortSpans("svc-equal-rate", "v2.4.0", 50, 5);
527
+
528
+ const equalRateResult = await computeChangeBlastRadius(
529
+ db,
530
+ "default",
531
+ "chg_equal_rate",
532
+ { environment: "prod" },
533
+ );
534
+ expect(equalRateResult?.cohortVerdict).toBe("no_regression_detected");
535
+ expect(equalRateResult?.relativeRisk).toBeCloseTo(1.0, 2);
536
+ expect(equalRateResult?.causalProofTier).toBe("unverified_locus");
537
+
538
+ // Case B: Insufficient sample size (N < 50)
539
+ await recordChangeEvent(db, "default", {
540
+ id: "chg_small_sample",
541
+ environment: "prod",
542
+ kind: "deploy",
543
+ serviceName: "svc-small-sample",
544
+ version: "v2.4.1",
545
+ previousVersion: "v2.4.0",
546
+ summary: "Deploy svc-small-sample v2.4.1",
547
+ occurredAt: new Date(t0).toISOString(),
548
+ });
549
+ insertCohortSpans("svc-small-sample", "v2.4.1", 20, 10);
550
+ insertCohortSpans("svc-small-sample", "v2.4.0", 20, 0);
551
+
552
+ const smallSampleResult = await computeChangeBlastRadius(
553
+ db,
554
+ "default",
555
+ "chg_small_sample",
556
+ { environment: "prod" },
557
+ );
558
+ expect(smallSampleResult?.cohortVerdict).toBe("insufficient_sample");
559
+
560
+ // Case C: Shared downstream degradation where BOTH v2.4.1 and v2.4.0 fail at 20%,
561
+ // but v2.4.1 has 5x downstream DB call fanout per trace (noisy-neighbor aggressor!)
562
+ await recordChangeEvent(db, "default", {
563
+ id: "chg_noisy",
564
+ environment: "prod",
565
+ kind: "deploy",
566
+ serviceName: "svc-noisy",
567
+ version: "v2.4.1",
568
+ previousVersion: "v2.4.0",
569
+ summary: "Deploy svc-noisy v2.4.1",
570
+ occurredAt: new Date(t0).toISOString(),
571
+ });
572
+ insertCohortSpans("svc-noisy", "v2.4.1", 60, 12, 5);
573
+ insertCohortSpans("svc-noisy", "v2.4.0", 60, 12, 1);
574
+
575
+ const noisyResult = await computeChangeBlastRadius(
576
+ db,
577
+ "default",
578
+ "chg_noisy",
579
+ { environment: "prod" },
580
+ );
581
+ expect(noisyResult?.cohortVerdict).toBe(
582
+ "shared_degradation_new_version_aggressor",
583
+ );
584
+ expect(noisyResult?.causalProofTier).toBe("corroborated_candidate");
585
+ expect(noisyResult?.sharedResourceAggressors.length).toBeGreaterThan(0);
586
+
587
+ // Case D: Isolated regression on v2.4.1 (20% error rate vs 1.6% on v2.4.0, N >= 50)
588
+ await recordChangeEvent(db, "default", {
589
+ id: "chg_isolated",
590
+ environment: "prod",
591
+ kind: "deploy",
592
+ serviceName: "svc-isolated",
593
+ version: "v2.4.1",
594
+ previousVersion: "v2.4.0",
595
+ summary: "Deploy svc-isolated v2.4.1",
596
+ occurredAt: new Date(t0).toISOString(),
597
+ });
598
+ insertCohortSpans("svc-isolated", "v2.4.1", 60, 12);
599
+ insertCohortSpans("svc-isolated", "v2.4.0", 60, 1);
600
+
601
+ const isolatedResult = await computeChangeBlastRadius(
602
+ db,
603
+ "default",
604
+ "chg_isolated",
605
+ { environment: "prod" },
606
+ );
607
+ expect(isolatedResult?.cohortVerdict).toBe("isolated_to_new_version");
608
+ expect(isolatedResult?.causalProofTier).toBe("cohort_rate_isolated");
609
+
610
+ db.close();
611
+ });
612
+
613
+ it("Criterion 6 (P2 #8): normalizes dynamic IDs in buildFindingFingerprint so recurring incidents produce the exact same fingerprint", () => {
614
+ const fp1 = buildFindingFingerprint({
615
+ environment: "prod",
616
+ originatingNodeId: "service:prod:default:checkout-api",
617
+ normalizedOperation:
618
+ "POST /v1/orders/123e4567-e89b-12d3-a456-426614174000",
619
+ errorSignature:
620
+ "LockWaitTimeoutError: Lock wait timeout 5012ms for order 123e4567-e89b-12d3-a456-426614174000 at 2026-10-15T01:00:00Z",
621
+ downstreamTargetNodeId:
622
+ "database:prod:postgresql:pg-primary.internal:5432/orders",
623
+ });
624
+
625
+ const fp2 = buildFindingFingerprint({
626
+ environment: "prod",
627
+ originatingNodeId: "service:prod:default:checkout-api",
628
+ normalizedOperation:
629
+ "POST /v1/orders/987fcdeb-51a2-43d7-9012-345678901234",
630
+ errorSignature:
631
+ "LockWaitTimeoutError: Lock wait timeout 5489ms for order 987fcdeb-51a2-43d7-9012-345678901234 at 2026-10-15T03:15:22Z",
632
+ downstreamTargetNodeId:
633
+ "database:prod:postgresql:pg-primary.internal:5432/orders",
634
+ });
635
+
636
+ expect(fp1.fingerprint).toBe(fp2.fingerprint);
637
+ expect(fp1.normalizedInput).toEqual(fp2.normalizedInput);
638
+ });
639
+
640
+ 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 () => {
641
+ const db = createMigratedSqliteDb();
642
+ const { app, env } = createTestApp(db);
643
+
644
+ const deployTime = Date.parse("2026-10-15T03:00:00.000Z");
645
+
646
+ // 1. Seed concurrent v2.4.0 traffic (60 spans, 1 error = 1.67% error rate)
647
+ // and canary v2.4.1 traffic (60 spans, 15 errors = 25% error rate) with code stackframe in src/checkout/inventory.ts
648
+ const dbNodeId = "database:prod:postgresql:pg-primary.internal:5432/orders";
649
+ const serviceNodeId = "service:prod:default:checkout-api";
650
+
651
+ for (let i = 0; i < 60; i++) {
652
+ const startIso = new Date(deployTime + (i + 1) * 1000).toISOString();
653
+ const endIso = new Date(deployTime + (i + 1) * 1000 + 40).toISOString();
654
+ insertSpan(db, {
655
+ traceId: `tr_base_${i}`,
656
+ spanId: `sp_base_${i}`,
657
+ serviceName: "checkout-api",
658
+ spanName: "POST /checkout",
659
+ spanKind: 1,
660
+ statusCode: i === 0 ? 2 : 1,
661
+ statusMessage: i === 0 ? "Transient error" : null,
662
+ startTime: startIso,
663
+ endTime: endIso,
664
+ durationMs: 40,
665
+ environment: "prod",
666
+ serviceVersion: "v2.4.0",
667
+ commitSha: "1111111",
668
+ targetNodeId: dbNodeId,
669
+ });
670
+ }
671
+
672
+ for (let i = 0; i < 60; i++) {
673
+ const isError = i < 15;
674
+ const startIso = new Date(deployTime + (i + 1) * 1000).toISOString();
675
+ const endIso = new Date(deployTime + (i + 1) * 1000 + 850).toISOString();
676
+ insertSpan(db, {
677
+ traceId: `tr_new_${i}`,
678
+ spanId: `sp_new_${i}`,
679
+ serviceName: "checkout-api",
680
+ spanName: "POST /checkout",
681
+ spanKind: 1,
682
+ statusCode: isError ? 2 : 1,
683
+ statusMessage: isError
684
+ ? `LockWaitTimeoutError: timeout 5000ms on order 123e4567-e89b-12d3-a456-426614174000`
685
+ : null,
686
+ startTime: startIso,
687
+ endTime: endIso,
688
+ durationMs: isError ? 850 : 45,
689
+ environment: "prod",
690
+ serviceVersion: "v2.4.1",
691
+ commitSha: "8f3a1b2",
692
+ targetNodeId: dbNodeId,
693
+ errorSignature: isError
694
+ ? "LockWaitTimeoutError: timeout <num> on order <uuid>"
695
+ : null,
696
+ attributes: isError
697
+ ? {
698
+ "code.filepath": "src/checkout/inventory.ts",
699
+ "code.function": "reserveInventoryBatch",
700
+ }
701
+ : {},
702
+ });
703
+ }
704
+
705
+ // Record explicit deploy ChangeEvent via POST /v1/changes
706
+ const changeRes = await app.request(
707
+ "/v1/changes",
708
+ {
709
+ method: "POST",
710
+ headers: { "Content-Type": "application/json" },
711
+ body: JSON.stringify({
712
+ id: "chg_deploy_241",
713
+ environment: "prod",
714
+ kind: "deploy",
715
+ source: "ci_webhook",
716
+ serviceName: "checkout-api",
717
+ version: "v2.4.1",
718
+ previousVersion: "v2.4.0",
719
+ commitSha: "8f3a1b2",
720
+ previousCommitSha: "1111111",
721
+ summary: "Deploy checkout-api v2.4.1 (batch inventory lock)",
722
+ touchedPaths: ["src/checkout/inventory.ts", "src/checkout/routes.ts"],
723
+ occurredAt: new Date(deployTime).toISOString(),
724
+ }),
725
+ },
726
+ env,
727
+ );
728
+ expect(changeRes.status).toBe(201);
729
+
730
+ // Verify GET /internal/changes/:id/blast-radius computes cohort lift + code-path overlap
731
+ const blastRes = await app.request(
732
+ "/internal/changes/chg_deploy_241/blast-radius?environment=prod",
733
+ { method: "GET" },
734
+ env,
735
+ );
736
+ expect(blastRes.status).toBe(200);
737
+ const { blastRadius } = (await blastRes.json()) as {
738
+ blastRadius: NonNullable<
739
+ Awaited<ReturnType<typeof computeChangeBlastRadius>>
740
+ >;
741
+ };
742
+ expect(blastRadius.cohortVerdict).toBe("isolated_to_new_version");
743
+ expect(blastRadius.causalProofTier).toBe("cohort_rate_isolated");
744
+ expect(blastRadius.overlappingCodePaths).toContain(
745
+ "src/checkout/inventory.ts",
746
+ );
747
+
748
+ // 2. Incident deduplication & runner lease coordination (P2 #7)
749
+ const inc1Res = await app.request(
750
+ "/internal/incidents",
751
+ {
752
+ method: "POST",
753
+ headers: { "Content-Type": "application/json" },
754
+ body: JSON.stringify({
755
+ environment: "prod",
756
+ targetNodeId: serviceNodeId,
757
+ symptomAnchorKind: "trace",
758
+ symptomAnchorId: "tr_new_0",
759
+ symptomFingerprint: "fp_checkout_lock_timeout",
760
+ }),
761
+ },
762
+ env,
763
+ );
764
+ expect(inc1Res.status).toBe(201);
765
+ const inc1Body = (await inc1Res.json()) as {
766
+ incident: { id: string; alertCount: number };
767
+ deduplicated: boolean;
768
+ };
769
+ expect(inc1Body.deduplicated).toBe(false);
770
+ expect(inc1Body.incident.alertCount).toBe(1);
771
+
772
+ // Second identical alert coalesces into the same active incident
773
+ const inc2Res = await app.request(
774
+ "/internal/incidents",
775
+ {
776
+ method: "POST",
777
+ headers: { "Content-Type": "application/json" },
778
+ body: JSON.stringify({
779
+ environment: "prod",
780
+ targetNodeId: serviceNodeId,
781
+ symptomAnchorKind: "trace",
782
+ symptomAnchorId: "tr_new_1",
783
+ symptomFingerprint: "fp_checkout_lock_timeout",
784
+ }),
785
+ },
786
+ env,
787
+ );
788
+ expect(inc2Res.status).toBe(200);
789
+ const inc2Body = (await inc2Res.json()) as {
790
+ incident: { id: string; alertCount: number };
791
+ deduplicated: boolean;
792
+ };
793
+ expect(inc2Body.deduplicated).toBe(true);
794
+ expect(inc2Body.incident.id).toBe(inc1Body.incident.id);
795
+ expect(inc2Body.incident.alertCount).toBe(2);
796
+
797
+ const incidentId = inc1Body.incident.id;
798
+
799
+ // Runner 1 acquires lease; Runner 2 gets 409 Conflict
800
+ const lease1 = await app.request(
801
+ `/internal/incidents/${incidentId}/lease`,
802
+ {
803
+ method: "POST",
804
+ headers: { "Content-Type": "application/json" },
805
+ body: JSON.stringify({
806
+ environment: "prod",
807
+ runnerId: "runner-alpha",
808
+ ttlSeconds: 300,
809
+ }),
810
+ },
811
+ env,
812
+ );
813
+ expect(lease1.status).toBe(200);
814
+
815
+ const lease2 = await app.request(
816
+ `/internal/incidents/${incidentId}/lease`,
817
+ {
818
+ method: "POST",
819
+ headers: { "Content-Type": "application/json" },
820
+ body: JSON.stringify({
821
+ environment: "prod",
822
+ runnerId: "runner-beta",
823
+ ttlSeconds: 300,
824
+ }),
825
+ },
826
+ env,
827
+ );
828
+ expect(lease2.status).toBe(409);
829
+
830
+ // 3. Record InvestigationFinding at corroborated_candidate first to test human approval gate (P1 #4)
831
+ const findingRes = await app.request(
832
+ "/internal/findings",
833
+ {
834
+ method: "POST",
835
+ headers: { "Content-Type": "application/json" },
836
+ body: JSON.stringify({
837
+ environment: "prod",
838
+ incidentId,
839
+ title:
840
+ "Checkout lock timeout caused by v2.4.1 batch lock in inventory.ts",
841
+ summary:
842
+ "Deploy v2.4.1 introduced batch lock contention in reserveInventoryBatch",
843
+ causalProofTier: "corroborated_candidate",
844
+ symptomEntityKind: "trace",
845
+ symptomEntityId: "tr_new_0",
846
+ faultLocusEntityKind: "span",
847
+ faultLocusEntityId: "sp_new_0",
848
+ rootCauseEntityKind: "change",
849
+ rootCauseEntityId: "chg_deploy_241",
850
+ // No contributingChangeId: the server cannot recompute a cohort
851
+ // verdict, so the proof tier stays corroborated_candidate.
852
+ cohortVerdict: "shared_degradation_new_version_aggressor",
853
+ causalChain: [
854
+ {
855
+ subjectKind: "trace",
856
+ subjectId: "tr_new_0",
857
+ predicate: "originated_fault_in_span",
858
+ objectKind: "span",
859
+ objectId: "sp_new_0",
860
+ relationshipCertainty: "exact",
861
+ causalProofTier: "unverified_locus",
862
+ confidence: 0.9,
863
+ reason: "Fault locus",
864
+ },
865
+ {
866
+ subjectKind: "span",
867
+ subjectId: "sp_new_0",
868
+ predicate: "touched_same_code_path",
869
+ objectKind: "change",
870
+ objectId: "chg_deploy_241",
871
+ relationshipCertainty: "exact",
872
+ causalProofTier: "corroborated_candidate",
873
+ confidence: 0.85,
874
+ reason: "Stackframe overlaps src/checkout/inventory.ts",
875
+ },
876
+ ],
877
+ citedEvidenceIds: ["tr_new_0", "sp_new_0", "chg_deploy_241"],
878
+ }),
879
+ },
880
+ env,
881
+ );
882
+ expect(findingRes.status).toBe(201);
883
+ const { finding } = (await findingRes.json()) as {
884
+ finding: { id: string; fingerprint: string; causalProofTier: string };
885
+ };
886
+ expect(finding.causalProofTier).toBe("corroborated_candidate");
887
+
888
+ // Proposing rollback_deploy on corroborated_candidate must require human_approved_write
889
+ const propHumanRes = await app.request(
890
+ "/internal/remediations/propose",
891
+ {
892
+ method: "POST",
893
+ headers: { "Content-Type": "application/json" },
894
+ body: JSON.stringify({
895
+ environment: "prod",
896
+ incidentId,
897
+ findingId: finding.id,
898
+ idempotencyKey: "idem_human_rollback_1",
899
+ actionKind: "rollback_deploy",
900
+ targetNodeId: serviceNodeId,
901
+ expectedCurrentState: { version: "v2.4.1" },
902
+ desiredTargetState: { version: "v2.4.0" },
903
+ requestedAutonomy: "autonomous_write",
904
+ }),
905
+ },
906
+ env,
907
+ );
908
+ expect(propHumanRes.status).toBe(201);
909
+ const propHumanBody = (await propHumanRes.json()) as {
910
+ proposal: { id: string; effectiveAutonomy: string; status: string };
911
+ grantToken: string | null;
912
+ };
913
+ expect(propHumanBody.proposal.effectiveAutonomy).toBe(
914
+ "human_approved_write",
915
+ );
916
+ expect(propHumanBody.proposal.status).toBe("pending_human_approval");
917
+ expect(propHumanBody.grantToken).toBeNull();
918
+
919
+ // Agent attempting to self-approve with Authorization: Bearer must get 403 Forbidden (P1 #4)
920
+ const agentApproveRes = await app.request(
921
+ `/internal/remediations/${propHumanBody.proposal.id}/approve`,
922
+ {
923
+ method: "POST",
924
+ headers: {
925
+ "Content-Type": "application/json",
926
+ Authorization: "Bearer obs_tok_agent_secret",
927
+ },
928
+ body: JSON.stringify({ environment: "prod" }),
929
+ },
930
+ env,
931
+ );
932
+ expect(agentApproveRes.status).toBe(403);
933
+
934
+ // Human session with obs_session cookie approves -> mints grantToken
935
+ const humanApproveRes = await app.request(
936
+ `/internal/remediations/${propHumanBody.proposal.id}/approve`,
937
+ {
938
+ method: "POST",
939
+ headers: {
940
+ "Content-Type": "application/json",
941
+ Cookie: "obs_session=sess_human_operator_123",
942
+ // Client-supplied identities must be ignored (B15).
943
+ "X-Obs-User-Actor": "sawan@obsunified.dev",
944
+ },
945
+ body: JSON.stringify({
946
+ environment: "prod",
947
+ approvedByUserId: "ceo@example.com",
948
+ }),
949
+ },
950
+ env,
951
+ );
952
+ expect(humanApproveRes.status).toBe(200);
953
+ const humanApproveBody = (await humanApproveRes.json()) as {
954
+ proposal: { status: string; approvedByUserId: string };
955
+ grantToken: string;
956
+ };
957
+ expect(humanApproveBody.proposal.status).toBe("grant_issued");
958
+ expect(humanApproveBody.proposal.approvedByUserId).toMatch(
959
+ /^dashboard_session:[0-9a-f]{16}$/,
960
+ );
961
+ expect(humanApproveBody.proposal.approvedByUserId).not.toContain(
962
+ "sess_human_operator_123",
963
+ );
964
+ expect(humanApproveBody.grantToken).toMatch(/^rgt_/);
965
+
966
+ // 4. Paired Counterfactual Gate (P1 #3)
967
+ // Create EvalCase from failing trace tr_new_0
968
+ const evalCaseRes = await app.request(
969
+ "/internal/eval-cases",
970
+ {
971
+ method: "POST",
972
+ headers: { "Content-Type": "application/json" },
973
+ body: JSON.stringify({
974
+ sourceEntityType: "trace",
975
+ sourceEntityId: "tr_new_0",
976
+ name: "Reproduce checkout lock timeout",
977
+ expectedOutcome:
978
+ "Checkout completes under 200ms without lock timeout",
979
+ }),
980
+ },
981
+ env,
982
+ );
983
+ expect(evalCaseRes.status).toBe(201);
984
+ const { evalCase } = (await evalCaseRes.json()) as {
985
+ evalCase: { id: string };
986
+ };
987
+
988
+ // Create failing baseline EvalRun (v2.4.1) and passing candidate EvalRun (v2.4.0)
989
+ const failRunRes = await app.request(
990
+ "/internal/eval-runs",
991
+ {
992
+ method: "POST",
993
+ headers: { "Content-Type": "application/json" },
994
+ body: JSON.stringify({
995
+ id: "run_baseline_fail_241",
996
+ evalCaseId: evalCase.id,
997
+ status: "failed",
998
+ environment: "prod",
999
+ serviceVersion: "v2.4.1",
1000
+ commitSha: "8f3a1b2",
1001
+ inputPayloadHash: "sha256_payload_checkout_99",
1002
+ failureFingerprint: finding.fingerprint,
1003
+ totalCount: 1,
1004
+ passCount: 0,
1005
+ failCount: 1,
1006
+ }),
1007
+ },
1008
+ env,
1009
+ );
1010
+ expect(failRunRes.status).toBe(201);
1011
+
1012
+ const passRunRes = await app.request(
1013
+ "/internal/eval-runs",
1014
+ {
1015
+ method: "POST",
1016
+ headers: { "Content-Type": "application/json" },
1017
+ body: JSON.stringify({
1018
+ id: "run_candidate_pass_240",
1019
+ evalCaseId: evalCase.id,
1020
+ status: "completed",
1021
+ environment: "prod",
1022
+ serviceVersion: "v2.4.0",
1023
+ commitSha: "1111111",
1024
+ inputPayloadHash: "sha256_payload_checkout_99",
1025
+ totalCount: 1,
1026
+ passCount: 1,
1027
+ failCount: 0,
1028
+ }),
1029
+ },
1030
+ env,
1031
+ );
1032
+ expect(passRunRes.status).toBe(201);
1033
+
1034
+ // Attempting to verify counterfactual when baseline also passed (or same run passed for both) must fail with 422
1035
+ const invalidCfRes = await app.request(
1036
+ `/internal/findings/${finding.id}/counterfactual`,
1037
+ {
1038
+ method: "POST",
1039
+ headers: { "Content-Type": "application/json" },
1040
+ body: JSON.stringify({
1041
+ environment: "prod",
1042
+ evalCaseId: evalCase.id,
1043
+ failingRunId: "run_candidate_pass_240", // passing run used as baseline -> must fail!
1044
+ passingRunIds: ["run_candidate_pass_240"],
1045
+ }),
1046
+ },
1047
+ env,
1048
+ );
1049
+ expect(invalidCfRes.status).toBe(422);
1050
+
1051
+ // Valid paired counterfactual verification upgrades finding to counterfactual_verified
1052
+ const validCfRes = await app.request(
1053
+ `/internal/findings/${finding.id}/counterfactual`,
1054
+ {
1055
+ method: "POST",
1056
+ headers: { "Content-Type": "application/json" },
1057
+ body: JSON.stringify({
1058
+ environment: "prod",
1059
+ evalCaseId: evalCase.id,
1060
+ failingRunId: "run_baseline_fail_241",
1061
+ passingRunIds: ["run_candidate_pass_240"],
1062
+ }),
1063
+ },
1064
+ env,
1065
+ );
1066
+ expect(validCfRes.status).toBe(200);
1067
+ const validCfBody = (await validCfRes.json()) as {
1068
+ finding: {
1069
+ causalProofTier: string;
1070
+ causalVerificationStatus: string;
1071
+ confidence: number;
1072
+ };
1073
+ };
1074
+ expect(validCfBody.finding.causalProofTier).toBe("counterfactual_verified");
1075
+ expect(validCfBody.finding.causalVerificationStatus).toBe(
1076
+ "counterfactual_verified",
1077
+ );
1078
+ expect(validCfBody.finding.confidence).toBeGreaterThanOrEqual(0.95);
1079
+
1080
+ // 5. Autonomous Remediation Proposal, State-Mismatch Check, Single-Use Claim & Complete
1081
+ const autoPropRes = await app.request(
1082
+ "/internal/remediations/propose",
1083
+ {
1084
+ method: "POST",
1085
+ headers: { "Content-Type": "application/json" },
1086
+ body: JSON.stringify({
1087
+ environment: "prod",
1088
+ incidentId,
1089
+ findingId: finding.id,
1090
+ idempotencyKey: "idem_auto_rollback_241",
1091
+ actionKind: "rollback_deploy",
1092
+ targetNodeId: serviceNodeId,
1093
+ expectedCurrentState: { version: "v2.4.1" },
1094
+ desiredTargetState: { version: "v2.4.0" },
1095
+ requestedAutonomy: "autonomous_write",
1096
+ }),
1097
+ },
1098
+ env,
1099
+ );
1100
+ expect(autoPropRes.status).toBe(201);
1101
+ const autoPropBody = (await autoPropRes.json()) as {
1102
+ proposal: { id: string; effectiveAutonomy: string; status: string };
1103
+ grantToken: string;
1104
+ };
1105
+ expect(autoPropBody.proposal.effectiveAutonomy).toBe("autonomous_write");
1106
+ expect(autoPropBody.proposal.status).toBe("grant_issued");
1107
+ expect(autoPropBody.grantToken).toMatch(/^rgt_/);
1108
+
1109
+ // Claiming with single-use grantToken succeeds once and fails on replay
1110
+ const claim1Res = await app.request(
1111
+ `/internal/remediations/${autoPropBody.proposal.id}/claim`,
1112
+ {
1113
+ method: "POST",
1114
+ headers: { "Content-Type": "application/json" },
1115
+ body: JSON.stringify({
1116
+ environment: "prod",
1117
+ grantToken: autoPropBody.grantToken,
1118
+ runnerId: "runner-alpha",
1119
+ }),
1120
+ },
1121
+ env,
1122
+ );
1123
+ expect(claim1Res.status).toBe(200);
1124
+
1125
+ const claimReplayRes = await app.request(
1126
+ `/internal/remediations/${autoPropBody.proposal.id}/claim`,
1127
+ {
1128
+ method: "POST",
1129
+ headers: { "Content-Type": "application/json" },
1130
+ body: JSON.stringify({
1131
+ environment: "prod",
1132
+ grantToken: autoPropBody.grantToken,
1133
+ runnerId: "runner-alpha",
1134
+ }),
1135
+ },
1136
+ env,
1137
+ );
1138
+ expect(claimReplayRes.status).toBe(409);
1139
+
1140
+ // Record the rollback change event and complete remediation
1141
+ const rollbackChange = await recordChangeEvent(db, "default", {
1142
+ id: "chg_rollback_240",
1143
+ environment: "prod",
1144
+ kind: "rollback",
1145
+ source: "autosre_remediation",
1146
+ serviceName: "checkout-api",
1147
+ version: "v2.4.0",
1148
+ previousVersion: "v2.4.1",
1149
+ summary: "Autonomous rollback checkout-api v2.4.1 -> v2.4.0",
1150
+ occurredAt: new Date(deployTime + 120_000).toISOString(),
1151
+ });
1152
+
1153
+ const completeRes = await app.request(
1154
+ `/internal/remediations/${autoPropBody.proposal.id}/complete`,
1155
+ {
1156
+ method: "POST",
1157
+ headers: { "Content-Type": "application/json" },
1158
+ body: JSON.stringify({
1159
+ environment: "prod",
1160
+ runnerId: "runner-alpha",
1161
+ resultingChangeId: rollbackChange.id,
1162
+ remediationAgentRunId: "arun_autosre_1",
1163
+ }),
1164
+ },
1165
+ env,
1166
+ );
1167
+ expect(completeRes.status).toBe(200);
1168
+
1169
+ // 6. Post-Remediation Watchdog: Low Traffic vs Normal Recovery (P1 #3)
1170
+ // First force watchdog_step = 3 (+15m check) due when < 50 post-remediation spans exist -> inconclusive_low_traffic
1171
+ db.raw
1172
+ .prepare(
1173
+ `UPDATE investigation_findings SET watchdog_step = 3, next_watchdog_check_at = ? WHERE id = ?`,
1174
+ )
1175
+ .run(new Date(deployTime + 200_000).toISOString(), finding.id);
1176
+
1177
+ const watchdogLow = await evaluateRemediationWatchdogs(db, {
1178
+ nowIso: new Date(deployTime + 300_000).toISOString(),
1179
+ });
1180
+ expect(watchdogLow.evaluated).toBe(1);
1181
+ expect(watchdogLow.inconclusiveLowTraffic).toBe(1);
1182
+
1183
+ // Now seed 60 healthy post-rollback spans on v2.4.0 and advance watchdog
1184
+ const postRollbackBase = deployTime + 600_000;
1185
+ for (let i = 0; i < 60; i++) {
1186
+ const sIso = new Date(postRollbackBase + i * 500).toISOString();
1187
+ const eIso = new Date(postRollbackBase + i * 500 + 35).toISOString();
1188
+ insertSpan(db, {
1189
+ traceId: `tr_recovered_${i}`,
1190
+ spanId: `sp_recovered_${i}`,
1191
+ serviceName: "checkout-api",
1192
+ spanName: "POST /checkout",
1193
+ spanKind: 1,
1194
+ statusCode: 1,
1195
+ startTime: sIso,
1196
+ endTime: eIso,
1197
+ durationMs: 35,
1198
+ environment: "prod",
1199
+ serviceVersion: "v2.4.0",
1200
+ });
1201
+ }
1202
+
1203
+ // Force next_watchdog_check_at due and evaluate
1204
+ db.raw
1205
+ .prepare(
1206
+ `UPDATE investigation_findings SET recovery_status = 'monitoring', watchdog_step = 3, next_watchdog_check_at = ? WHERE id = ?`,
1207
+ )
1208
+ .run(new Date(postRollbackBase + 40_000).toISOString(), finding.id);
1209
+
1210
+ // A single healthy checkpoint is not enough (B11): two consecutive
1211
+ // sufficient-traffic ok checkpoints are required before `recovered`.
1212
+ const watchdogFirstOk = await evaluateRemediationWatchdogs(db, {
1213
+ nowIso: new Date(postRollbackBase + 50_000).toISOString(),
1214
+ });
1215
+ expect(watchdogFirstOk.recovered).toBe(0);
1216
+ db.raw
1217
+ .prepare(
1218
+ `UPDATE investigation_findings SET next_watchdog_check_at = ? WHERE id = ?`,
1219
+ )
1220
+ .run(new Date(postRollbackBase + 60_000).toISOString(), finding.id);
1221
+ const watchdogRecovered = await evaluateRemediationWatchdogs(db, {
1222
+ nowIso: new Date(postRollbackBase + 70_000).toISOString(),
1223
+ });
1224
+ expect(watchdogRecovered.recovered).toBe(1);
1225
+
1226
+ // Verify incident is now resolved and finding recoveryStatus === 'recovered'
1227
+ const incCheckRes = await app.request(
1228
+ `/internal/incidents/${incidentId}?environment=prod`,
1229
+ { method: "GET" },
1230
+ env,
1231
+ );
1232
+ expect(incCheckRes.status).toBe(200);
1233
+ const incCheckBody = (await incCheckRes.json()) as {
1234
+ incident: { status: string };
1235
+ findings: Array<{
1236
+ id: string;
1237
+ recoveryStatus: string;
1238
+ causalVerificationStatus: string;
1239
+ }>;
1240
+ };
1241
+ expect(incCheckBody.incident.status).toBe("resolved");
1242
+ expect(incCheckBody.findings[0]?.recoveryStatus).toBe("recovered");
1243
+
1244
+ // 7. Connected Signals & Evidence Bundle (triage_incident)
1245
+ const connectedRes = await app.request(
1246
+ `/internal/connected/incident/${incidentId}`,
1247
+ { method: "GET" },
1248
+ env,
1249
+ );
1250
+ expect(connectedRes.status).toBe(200);
1251
+ const connectedBody = (await connectedRes.json()) as {
1252
+ across: Array<{
1253
+ label: string;
1254
+ links: Array<{
1255
+ entityKind?: string;
1256
+ entityId?: string;
1257
+ relationshipCertainty?: string;
1258
+ causalProofTier?: string;
1259
+ }>;
1260
+ }>;
1261
+ };
1262
+ const allAcrossLinks = connectedBody.across.flatMap((s) => s.links);
1263
+ const findingLink = allAcrossLinks.find((l) => l.entityKind === "finding");
1264
+ expect(findingLink).toBeDefined();
1265
+ expect(findingLink?.entityId).toBe(finding.id);
1266
+ expect(findingLink?.relationshipCertainty).toBe("exact");
1267
+ expect(findingLink?.causalProofTier).toBe("counterfactual_verified");
1268
+
1269
+ const bundleRes = await app.request(
1270
+ `/internal/evidence/bundle`,
1271
+ {
1272
+ method: "POST",
1273
+ headers: { "Content-Type": "application/json" },
1274
+ body: JSON.stringify({
1275
+ intent: "triage_incident",
1276
+ anchor: {
1277
+ entityKind: "incident",
1278
+ entityId: incidentId,
1279
+ },
1280
+ }),
1281
+ },
1282
+ env,
1283
+ );
1284
+ expect(bundleRes.status).toBe(200);
1285
+ const bundleBody = (await bundleRes.json()) as {
1286
+ intent: string;
1287
+ autosre?: {
1288
+ faultLocus?: { nodeId: string; spanId?: string };
1289
+ candidateChanges?: Array<{ changeId: string }>;
1290
+ priorFindings?: Array<{ findingId: string }>;
1291
+ };
1292
+ };
1293
+ expect(bundleBody.intent).toBe("triage_incident");
1294
+ expect(bundleBody.autosre).toBeDefined();
1295
+ expect(
1296
+ bundleBody.autosre?.candidateChanges?.some(
1297
+ (c) => c.changeId === "chg_deploy_241",
1298
+ ),
1299
+ ).toBe(true);
1300
+ expect(
1301
+ bundleBody.autosre?.priorFindings?.some(
1302
+ (f) => f.findingId === finding.id,
1303
+ ),
1304
+ ).toBe(true);
1305
+
1306
+ const changeBundleRes = await app.request(
1307
+ `/internal/evidence/bundle`,
1308
+ {
1309
+ method: "POST",
1310
+ headers: { "Content-Type": "application/json" },
1311
+ body: JSON.stringify({
1312
+ intent: "triage_incident",
1313
+ anchor: {
1314
+ entityKind: "change",
1315
+ entityId: "chg_deploy_241",
1316
+ },
1317
+ }),
1318
+ },
1319
+ env,
1320
+ );
1321
+ expect(changeBundleRes.status).toBe(200);
1322
+ const changeBundleBody = (await changeBundleRes.json()) as {
1323
+ anchor: { entityKind: string; entityId: string };
1324
+ summary: string;
1325
+ };
1326
+ expect(changeBundleBody.anchor).toEqual({
1327
+ entityKind: "change",
1328
+ entityId: "chg_deploy_241",
1329
+ });
1330
+ expect(changeBundleBody.summary).toContain("chg_deploy_241");
1331
+
1332
+ const findingBundleRes = await app.request(
1333
+ `/internal/evidence/bundle`,
1334
+ {
1335
+ method: "POST",
1336
+ headers: { "Content-Type": "application/json" },
1337
+ body: JSON.stringify({
1338
+ intent: "triage_incident",
1339
+ anchor: {
1340
+ entityKind: "finding",
1341
+ entityId: finding.id,
1342
+ },
1343
+ }),
1344
+ },
1345
+ env,
1346
+ );
1347
+ expect(findingBundleRes.status).toBe(200);
1348
+ const findingBundleBody = (await findingBundleRes.json()) as {
1349
+ anchor: { entityKind: string; entityId: string };
1350
+ findings: Array<{ title: string }>;
1351
+ };
1352
+ expect(findingBundleBody.anchor).toEqual({
1353
+ entityKind: "finding",
1354
+ entityId: finding.id,
1355
+ });
1356
+ expect(
1357
+ findingBundleBody.findings.some((f) =>
1358
+ f.title.includes("Remediation proposal"),
1359
+ ),
1360
+ ).toBe(true);
1361
+
1362
+ db.close();
1363
+ });
1364
+ });
1365
+
1366
+ describe("AutoSRE governance hardening", () => {
1367
+ const DEPLOY = Date.parse("2026-10-15T03:00:00.000Z");
1368
+ const SERVICE_NODE = "service:prod:default:checkout-api";
1369
+
1370
+ afterEach(() => {
1371
+ vi.unstubAllGlobals();
1372
+ });
1373
+
1374
+ /** v2.4.0 healthy, v2.4.1 at 25% errors, plus the deploy change event. */
1375
+ const seedIsolatedCohort = async (db: NodeSqliteDb) => {
1376
+ for (let i = 0; i < 60; i++) {
1377
+ const t = new Date(DEPLOY + (i + 1) * 1000).toISOString();
1378
+ const end = new Date(DEPLOY + (i + 1) * 1000 + 40).toISOString();
1379
+ insertSpan(db, {
1380
+ traceId: `g_base_${i}`,
1381
+ spanId: `g_base_sp_${i}`,
1382
+ serviceName: "checkout-api",
1383
+ spanName: "POST /checkout",
1384
+ statusCode: i === 0 ? 2 : 1,
1385
+ startTime: t,
1386
+ endTime: end,
1387
+ durationMs: 40,
1388
+ serviceVersion: "v2.4.0",
1389
+ });
1390
+ insertSpan(db, {
1391
+ traceId: `g_new_${i}`,
1392
+ spanId: `g_new_sp_${i}`,
1393
+ serviceName: "checkout-api",
1394
+ spanName: "POST /checkout",
1395
+ statusCode: i < 15 ? 2 : 1,
1396
+ startTime: t,
1397
+ endTime: end,
1398
+ durationMs: 40,
1399
+ serviceVersion: "v2.4.1",
1400
+ });
1401
+ }
1402
+ await recordChangeEvent(db, "default", {
1403
+ id: "chg_gov",
1404
+ environment: "prod",
1405
+ kind: "deploy",
1406
+ serviceName: "checkout-api",
1407
+ version: "v2.4.1",
1408
+ previousVersion: "v2.4.0",
1409
+ summary: "Deploy v2.4.1",
1410
+ occurredAt: new Date(DEPLOY).toISOString(),
1411
+ });
1412
+ };
1413
+
1414
+ const newIncident = async (db: NodeSqliteDb, fingerprint = "fp_gov") =>
1415
+ (
1416
+ await openOrDeduplicateIncident(db, "default", {
1417
+ environment: "prod",
1418
+ targetNodeId: SERVICE_NODE,
1419
+ symptomAnchorKind: "trace",
1420
+ symptomAnchorId: "t1",
1421
+ symptomFingerprint: fingerprint,
1422
+ symptomCategory: fingerprint,
1423
+ })
1424
+ ).incident;
1425
+
1426
+ const newFinding = (
1427
+ db: NodeSqliteDb,
1428
+ incidentId: string,
1429
+ extra: Partial<Parameters<typeof recordInvestigationFinding>[2]> = {},
1430
+ ) =>
1431
+ recordInvestigationFinding(db, "default", {
1432
+ environment: "prod",
1433
+ incidentId,
1434
+ title: "Checkout regression",
1435
+ summary: "Errors after deploy",
1436
+ causalProofTier: "corroborated_candidate",
1437
+ symptomEntityKind: "trace",
1438
+ symptomEntityId: "t1",
1439
+ faultLocusEntityKind: "service",
1440
+ faultLocusEntityId: SERVICE_NODE,
1441
+ causalChain: [],
1442
+ citedEvidenceIds: [],
1443
+ ...extra,
1444
+ });
1445
+
1446
+ const propose = (
1447
+ db: NodeSqliteDb,
1448
+ incidentId: string,
1449
+ findingId: string,
1450
+ key: string,
1451
+ extra: Record<string, unknown> = {},
1452
+ ) =>
1453
+ proposeRemediationAction(db, "default", {
1454
+ environment: "prod",
1455
+ incidentId,
1456
+ findingId,
1457
+ idempotencyKey: key,
1458
+ actionKind: "rollback_deploy",
1459
+ targetNodeId: SERVICE_NODE,
1460
+ expectedCurrentState: {},
1461
+ desiredTargetState: { version: "v2.4.0" },
1462
+ requestedAutonomy: "autonomous_write",
1463
+ ...extra,
1464
+ } as Parameters<typeof proposeRemediationAction>[2]);
1465
+
1466
+ const grantedProposal = async (db: NodeSqliteDb, key = "idem_granted") => {
1467
+ await seedIsolatedCohort(db);
1468
+ const incident = await newIncident(db);
1469
+ const finding = await newFinding(db, incident.id, {
1470
+ contributingChangeId: "chg_gov",
1471
+ });
1472
+ const proposal = await propose(db, incident.id, finding.id, key);
1473
+ return { incident, finding, proposal };
1474
+ };
1475
+
1476
+ it("B1: a client-asserted isolated_to_new_version verdict cannot buy an autonomous rollback grant", async () => {
1477
+ const db = createMigratedSqliteDb();
1478
+ const incident = await newIncident(db);
1479
+ const finding = await newFinding(db, incident.id, {
1480
+ cohortVerdict: "isolated_to_new_version",
1481
+ causalProofTier: "cohort_rate_isolated",
1482
+ causalChain: [
1483
+ {
1484
+ subjectKind: "service",
1485
+ subjectId: SERVICE_NODE,
1486
+ predicate: "isolated_to_version_cohort",
1487
+ objectKind: "change",
1488
+ objectId: "chg_missing",
1489
+ relationshipCertainty: "exact",
1490
+ causalProofTier: "cohort_rate_isolated",
1491
+ confidence: 0.99,
1492
+ reason: "asserted by client",
1493
+ },
1494
+ ],
1495
+ });
1496
+ expect(finding.causalProofTier).toBe("corroborated_candidate");
1497
+ expect(finding.cohortVerdict).toBeNull();
1498
+
1499
+ const proposal = await propose(db, incident.id, finding.id, "idem_b1");
1500
+ expect(proposal.status).toBe("pending_human_approval");
1501
+ expect(proposal.grantToken).toBeUndefined();
1502
+
1503
+ // Pointing at a real change whose telemetry does not show isolation
1504
+ // (no spans at all) does not help either.
1505
+ await recordChangeEvent(db, "default", {
1506
+ id: "chg_empty",
1507
+ environment: "prod",
1508
+ kind: "deploy",
1509
+ serviceName: "checkout-api",
1510
+ version: "v9",
1511
+ summary: "no telemetry",
1512
+ });
1513
+ const f2 = await newFinding(db, incident.id, {
1514
+ cohortVerdict: "isolated_to_new_version",
1515
+ contributingChangeId: "chg_empty",
1516
+ });
1517
+ expect(f2.causalProofTier).toBe("corroborated_candidate");
1518
+ db.close();
1519
+ });
1520
+
1521
+ it("B1: the server-recomputed isolated verdict is what yields cohort_rate_isolated and an autonomous grant", async () => {
1522
+ const db = createMigratedSqliteDb();
1523
+ const { finding, proposal } = await grantedProposal(db);
1524
+ expect(finding.causalProofTier).toBe("cohort_rate_isolated");
1525
+ expect(finding.cohortVerdict).toBe("isolated_to_new_version");
1526
+ expect(proposal.status).toBe("grant_issued");
1527
+ expect(proposal.grantToken).toMatch(/^rgt_/);
1528
+ db.close();
1529
+ });
1530
+
1531
+ it("B2: a grant token is single-use, sequentially and under concurrent claims", async () => {
1532
+ const db = createMigratedSqliteDb();
1533
+ const { proposal } = await grantedProposal(db);
1534
+ const token = proposal.grantToken as string;
1535
+
1536
+ await claimRemediationGrant(db, "default", proposal.id, {
1537
+ environment: "prod",
1538
+ grantToken: token,
1539
+ runnerId: "r1",
1540
+ });
1541
+ await expect(
1542
+ claimRemediationGrant(db, "default", proposal.id, {
1543
+ environment: "prod",
1544
+ grantToken: token,
1545
+ runnerId: "r2",
1546
+ }),
1547
+ ).rejects.toMatchObject({ statusCode: 409 });
1548
+
1549
+ // Concurrent claims on a fresh grant: exactly one wins.
1550
+ const second = await propose(
1551
+ db,
1552
+ proposal.incidentId,
1553
+ proposal.findingId,
1554
+ "idem_b2_conc",
1555
+ { actionKind: "toggle_feature_flag" },
1556
+ );
1557
+ const results = await Promise.allSettled(
1558
+ ["a", "b", "c", "d"].map((runnerId) =>
1559
+ claimRemediationGrant(db, "default", second.id, {
1560
+ environment: "prod",
1561
+ grantToken: second.grantToken as string,
1562
+ runnerId,
1563
+ }),
1564
+ ),
1565
+ );
1566
+ expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
1567
+ for (const r of results) {
1568
+ if (r.status === "rejected") {
1569
+ expect(r.reason).toBeInstanceOf(RemediationGateError);
1570
+ expect((r.reason as RemediationGateError).statusCode).toBe(409);
1571
+ }
1572
+ }
1573
+ db.close();
1574
+ });
1575
+
1576
+ it("B2: a wrong token is rejected with 403 and does not consume the grant", async () => {
1577
+ const db = createMigratedSqliteDb();
1578
+ const { proposal } = await grantedProposal(db);
1579
+ await expect(
1580
+ claimRemediationGrant(db, "default", proposal.id, {
1581
+ environment: "prod",
1582
+ grantToken: "rgt_wrong",
1583
+ }),
1584
+ ).rejects.toMatchObject({ statusCode: 403 });
1585
+ const ok = await claimRemediationGrant(db, "default", proposal.id, {
1586
+ environment: "prod",
1587
+ grantToken: proposal.grantToken as string,
1588
+ });
1589
+ expect(ok.status).toBe("claimed_executing");
1590
+ db.close();
1591
+ });
1592
+
1593
+ it("B3: concurrent lease acquisition has exactly one winner; expired leases can be taken over", async () => {
1594
+ const db = createMigratedSqliteDb();
1595
+ const incident = await newIncident(db);
1596
+ const results = await Promise.all(
1597
+ ["a", "b", "c"].map((runnerId) =>
1598
+ acquireIncidentLease(db, "default", incident.id, {
1599
+ environment: "prod",
1600
+ runnerId,
1601
+ }),
1602
+ ),
1603
+ );
1604
+ const winners = results.filter((r) => r.acquired);
1605
+ expect(winners).toHaveLength(1);
1606
+ const owner = (winners[0] as { incident: { leaseOwnerId: string } })
1607
+ .incident.leaseOwnerId;
1608
+ // The owner can renew; a rival cannot until expiry.
1609
+ expect(
1610
+ (
1611
+ await acquireIncidentLease(db, "default", incident.id, {
1612
+ environment: "prod",
1613
+ runnerId: owner,
1614
+ })
1615
+ ).acquired,
1616
+ ).toBe(true);
1617
+ const rival = owner === "a" ? "b" : "a";
1618
+ expect(
1619
+ (
1620
+ await acquireIncidentLease(db, "default", incident.id, {
1621
+ environment: "prod",
1622
+ runnerId: rival,
1623
+ })
1624
+ ).acquired,
1625
+ ).toBe(false);
1626
+ const later = new Date(Date.now() + 3600_000).toISOString();
1627
+ const takeover = await acquireIncidentLease(db, "default", incident.id, {
1628
+ environment: "prod",
1629
+ runnerId: rival,
1630
+ nowIso: later,
1631
+ });
1632
+ expect(takeover.acquired).toBe(true);
1633
+ db.close();
1634
+ });
1635
+
1636
+ it("B4: concurrent alerts for one symptom open exactly one active incident", async () => {
1637
+ const db = createMigratedSqliteDb();
1638
+ const results = await Promise.all(
1639
+ Array.from({ length: 6 }, () =>
1640
+ openOrDeduplicateIncident(db, "default", {
1641
+ environment: "prod",
1642
+ targetNodeId: SERVICE_NODE,
1643
+ symptomAnchorKind: "alert",
1644
+ symptomAnchorId: "a1",
1645
+ symptomCategory: "race",
1646
+ }),
1647
+ ),
1648
+ );
1649
+ expect(results.filter((r) => !r.deduplicated)).toHaveLength(1);
1650
+ expect(new Set(results.map((r) => r.incident.id)).size).toBe(1);
1651
+ const count = db.raw
1652
+ .prepare(`SELECT COUNT(*) AS n FROM incident_investigations`)
1653
+ .get() as { n: number };
1654
+ expect(count.n).toBe(1);
1655
+ expect(results[0].incident.alertCount).toBeGreaterThanOrEqual(1);
1656
+
1657
+ // The database itself refuses a second active row for the same key.
1658
+ expect(() =>
1659
+ db.raw
1660
+ .prepare(
1661
+ `INSERT INTO incident_investigations (project_id, environment, id, dedup_key, status, target_node_id, symptom_anchor_kind, symptom_anchor_id, opened_at, updated_at)
1662
+ SELECT project_id, environment, 'dupe', dedup_key, 'open', target_node_id, symptom_anchor_kind, symptom_anchor_id, opened_at, updated_at FROM incident_investigations`,
1663
+ )
1664
+ .run(),
1665
+ ).toThrow(/UNIQUE/i);
1666
+ // Once resolved, a new incident for the same symptom is allowed.
1667
+ db.raw
1668
+ .prepare(`UPDATE incident_investigations SET status = 'resolved'`)
1669
+ .run();
1670
+ const reopened = await openOrDeduplicateIncident(db, "default", {
1671
+ environment: "prod",
1672
+ targetNodeId: SERVICE_NODE,
1673
+ symptomAnchorKind: "alert",
1674
+ symptomAnchorId: "a1",
1675
+ symptomCategory: "race",
1676
+ });
1677
+ expect(reopened.deduplicated).toBe(false);
1678
+ db.close();
1679
+ });
1680
+
1681
+ it("B5: concurrent proposals with one idempotency key return the same row instead of a DB error", async () => {
1682
+ const db = createMigratedSqliteDb();
1683
+ const incident = await newIncident(db);
1684
+ const finding = await newFinding(db, incident.id);
1685
+ const results = await Promise.all(
1686
+ Array.from({ length: 4 }, () =>
1687
+ propose(db, incident.id, finding.id, "idem_same"),
1688
+ ),
1689
+ );
1690
+ expect(new Set(results.map((r) => r.id)).size).toBe(1);
1691
+ await expect(
1692
+ propose(db, incident.id, finding.id, "idem_same", {
1693
+ actionKind: "apply_config",
1694
+ }),
1695
+ ).rejects.toMatchObject({ statusCode: 409 });
1696
+ db.close();
1697
+ });
1698
+
1699
+ it("B6: the watchdog route ignores a client clock and only evaluates the caller's project", async () => {
1700
+ const db = createMigratedSqliteDb();
1701
+ const { app, env } = createTestApp(db);
1702
+ db.raw
1703
+ .prepare(
1704
+ `INSERT INTO investigation_findings (id, project_id, environment, fingerprint, fingerprint_input_json, title, summary, causal_proof_tier, causal_verification_status, confidence, confidence_breakdown_json, symptom_entity_kind, symptom_entity_id, fault_locus_entity_kind, fault_locus_entity_id, causal_chain_json, cited_evidence_ids_json, actor_type, actor_id, created_at, updated_at, recovery_status, watchdog_step, next_watchdog_check_at)
1705
+ VALUES ('fnd_other', 'other-project', 'prod', 'fp', '{}', 't', 's', 'corroborated_candidate', 'unverified', 0.5, '{}', 'trace', 't', 'service', 'service:prod:default:x', '[]', '[]', 'agent', 'a', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', 'monitoring', 1, '2026-01-01T00:02:00.000Z')`,
1706
+ )
1707
+ .run();
1708
+ const res = await app.request(
1709
+ "/internal/remediations/watchdog",
1710
+ {
1711
+ method: "POST",
1712
+ headers: { "Content-Type": "application/json" },
1713
+ body: JSON.stringify({
1714
+ nowIso: "2099-01-01T00:00:00.000Z",
1715
+ minComparableRequests: 0,
1716
+ }),
1717
+ },
1718
+ env,
1719
+ );
1720
+ expect(res.status).toBe(200);
1721
+ expect(((await res.json()) as { evaluated: number }).evaluated).toBe(0);
1722
+ const row = db.raw
1723
+ .prepare(
1724
+ `SELECT recovery_status FROM investigation_findings WHERE id = 'fnd_other'`,
1725
+ )
1726
+ .get() as { recovery_status: string };
1727
+ expect(row.recovery_status).toBe("monitoring");
1728
+ db.close();
1729
+ });
1730
+
1731
+ it("B7: only the claiming runner can complete or fail, and only while claimed (and, for complete, unexpired)", async () => {
1732
+ const db = createMigratedSqliteDb();
1733
+ const { proposal } = await grantedProposal(db);
1734
+ // Not yet claimed.
1735
+ await expect(
1736
+ completeRemediationProposal(db, "default", proposal.id, {
1737
+ environment: "prod",
1738
+ runnerId: "r1",
1739
+ }),
1740
+ ).rejects.toMatchObject({ statusCode: 409 });
1741
+ await expect(
1742
+ failRemediationProposal(db, "default", proposal.id, {
1743
+ environment: "prod",
1744
+ runnerId: "r1",
1745
+ failureReason: "x",
1746
+ }),
1747
+ ).rejects.toMatchObject({ statusCode: 409 });
1748
+
1749
+ const t0 = new Date().toISOString();
1750
+ await claimRemediationGrant(db, "default", proposal.id, {
1751
+ environment: "prod",
1752
+ grantToken: proposal.grantToken as string,
1753
+ runnerId: "r1",
1754
+ nowIso: t0,
1755
+ });
1756
+ await expect(
1757
+ completeRemediationProposal(db, "default", proposal.id, {
1758
+ environment: "prod",
1759
+ runnerId: "someone-else",
1760
+ }),
1761
+ ).rejects.toMatchObject({ statusCode: 409 });
1762
+ await expect(
1763
+ completeRemediationProposal(db, "default", proposal.id, {
1764
+ environment: "prod",
1765
+ }),
1766
+ ).rejects.toMatchObject({ statusCode: 409 });
1767
+ await expect(
1768
+ failRemediationProposal(db, "default", proposal.id, {
1769
+ environment: "prod",
1770
+ runnerId: "someone-else",
1771
+ failureReason: "x",
1772
+ }),
1773
+ ).rejects.toMatchObject({ statusCode: 409 });
1774
+ await expect(
1775
+ completeRemediationProposal(db, "default", proposal.id, {
1776
+ environment: "prod",
1777
+ runnerId: "r1",
1778
+ nowIso: new Date(Date.parse(t0) + 6 * 60_000).toISOString(),
1779
+ }),
1780
+ ).rejects.toMatchObject({ statusCode: 409 });
1781
+
1782
+ const failed = await failRemediationProposal(db, "default", proposal.id, {
1783
+ environment: "prod",
1784
+ runnerId: "r1",
1785
+ failureReason: "boom",
1786
+ });
1787
+ expect(failed.status).toBe("failed_execution");
1788
+ // Terminal: cannot be failed or completed again.
1789
+ await expect(
1790
+ failRemediationProposal(db, "default", proposal.id, {
1791
+ environment: "prod",
1792
+ runnerId: "r1",
1793
+ failureReason: "again",
1794
+ }),
1795
+ ).rejects.toMatchObject({ statusCode: 409 });
1796
+ db.close();
1797
+ });
1798
+
1799
+ it("B8: incident webhooks refuse private targets and redirects, never follow redirects, and time out", async () => {
1800
+ const db = createMigratedSqliteDb();
1801
+ const incident = await newIncident(db);
1802
+ const calls: Array<{ url: string; init: RequestInit }> = [];
1803
+ const okFetch = (async (url: string, init: RequestInit) => {
1804
+ calls.push({ url, init });
1805
+ return new Response("ok", { status: 200 });
1806
+ }) as unknown as typeof fetch;
1807
+
1808
+ const privateLookup = vi.fn(async () => ["10.0.0.5"]);
1809
+ const refused = await dispatchIncidentWebhook(db, "default", incident.id, {
1810
+ environment: "prod",
1811
+ url: "https://internal.example.com/hook",
1812
+ hostLookup: privateLookup,
1813
+ fetchImpl: okFetch,
1814
+ });
1815
+ expect(refused.delivered).toBe(false);
1816
+ expect(refused.error).toMatch(/private/);
1817
+ const literal = await dispatchIncidentWebhook(db, "default", incident.id, {
1818
+ environment: "prod",
1819
+ url: "http://127.0.0.1:8080/hook",
1820
+ fetchImpl: okFetch,
1821
+ });
1822
+ expect(literal.delivered).toBe(false);
1823
+ const unresolvable = await dispatchIncidentWebhook(
1824
+ db,
1825
+ "default",
1826
+ incident.id,
1827
+ {
1828
+ environment: "prod",
1829
+ url: "https://nowhere.example.com/hook",
1830
+ hostLookup: async () => {
1831
+ throw new Error("ENOTFOUND");
1832
+ },
1833
+ fetchImpl: okFetch,
1834
+ },
1835
+ );
1836
+ expect(unresolvable.delivered).toBe(false);
1837
+ expect(calls).toHaveLength(0);
1838
+
1839
+ const delivered = await dispatchIncidentWebhook(
1840
+ db,
1841
+ "default",
1842
+ incident.id,
1843
+ {
1844
+ environment: "prod",
1845
+ url: "https://hooks.example.com/hook",
1846
+ secret: "s3cret",
1847
+ hostLookup: async () => ["93.184.216.34"],
1848
+ fetchImpl: okFetch,
1849
+ },
1850
+ );
1851
+ expect(delivered.delivered).toBe(true);
1852
+ expect(calls).toHaveLength(1);
1853
+ expect(calls[0].init.redirect).toBe("manual");
1854
+ expect(calls[0].init.signal).toBeInstanceOf(AbortSignal);
1855
+ expect(
1856
+ (calls[0].init.headers as Record<string, string>)["X-Obs-Signature-256"],
1857
+ ).toMatch(/^sha256=[0-9a-f]{64}$/);
1858
+
1859
+ const redirected = await dispatchIncidentWebhook(
1860
+ db,
1861
+ "default",
1862
+ incident.id,
1863
+ {
1864
+ environment: "prod",
1865
+ url: "https://hooks.example.com/hook",
1866
+ hostLookup: async () => ["93.184.216.34"],
1867
+ fetchImpl: (async () =>
1868
+ new Response(null, {
1869
+ status: 302,
1870
+ headers: { Location: "http://169.254.169.254/" },
1871
+ })) as unknown as typeof fetch,
1872
+ },
1873
+ );
1874
+ expect(redirected.delivered).toBe(false);
1875
+ expect(redirected.error).toMatch(/Redirect/);
1876
+ db.close();
1877
+ });
1878
+
1879
+ it("B8: the dispatch route cannot disable the SSRF guard from the request body", async () => {
1880
+ const db = createMigratedSqliteDb();
1881
+ const { app, env } = createTestApp(db);
1882
+ const incident = await newIncident(db);
1883
+ const fetchSpy = vi.fn(async () => new Response("ok"));
1884
+ vi.stubGlobal("fetch", fetchSpy);
1885
+ const res = await app.request(
1886
+ `/internal/incidents/${incident.id}/dispatch`,
1887
+ {
1888
+ method: "POST",
1889
+ headers: { "Content-Type": "application/json" },
1890
+ body: JSON.stringify({
1891
+ environment: "prod",
1892
+ webhookUrl: "http://127.0.0.1:9/hook",
1893
+ allowPrivateNetworks: true,
1894
+ allowPrivateWebhookTargets: true,
1895
+ }),
1896
+ },
1897
+ env,
1898
+ );
1899
+ expect(res.status).toBe(502);
1900
+ expect(fetchSpy).not.toHaveBeenCalled();
1901
+
1902
+ // The operator-level env flag is the only way to allow private targets.
1903
+ const allowed = await app.request(
1904
+ `/internal/incidents/${incident.id}/dispatch`,
1905
+ {
1906
+ method: "POST",
1907
+ headers: { "Content-Type": "application/json" },
1908
+ body: JSON.stringify({
1909
+ environment: "prod",
1910
+ webhookUrl: "http://127.0.0.1:9/hook",
1911
+ }),
1912
+ },
1913
+ { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" },
1914
+ );
1915
+ expect(allowed.status).toBe(200);
1916
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
1917
+ db.close();
1918
+ });
1919
+
1920
+ it("B9: approve -> grant token pushed over the webhook -> claim succeeds once -> second claim 409", async () => {
1921
+ const db = createMigratedSqliteDb();
1922
+ const { app, env } = createTestApp(db);
1923
+ const incident = await newIncident(db);
1924
+ const finding = await newFinding(db, incident.id); // corroborated: needs a human
1925
+ const proposal = await propose(db, incident.id, finding.id, "idem_b9");
1926
+ expect(proposal.status).toBe("pending_human_approval");
1927
+
1928
+ const bodies: Array<Record<string, unknown>> = [];
1929
+ vi.stubGlobal(
1930
+ "fetch",
1931
+ vi.fn(async (_url: string, init: RequestInit) => {
1932
+ bodies.push(JSON.parse(String(init.body)));
1933
+ return new Response("ok", { status: 200 });
1934
+ }),
1935
+ );
1936
+ const approveEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
1937
+ const approve = await app.request(
1938
+ `/internal/remediations/${proposal.id}/approve`,
1939
+ {
1940
+ method: "POST",
1941
+ headers: {
1942
+ "Content-Type": "application/json",
1943
+ Cookie: "obs_session=sess_b9",
1944
+ },
1945
+ body: JSON.stringify({
1946
+ environment: "prod",
1947
+ webhookUrl: "https://runner.example.com/grants",
1948
+ secret: "hmac-secret",
1949
+ }),
1950
+ },
1951
+ approveEnv,
1952
+ );
1953
+ expect(approve.status).toBe(200);
1954
+ const approved = (await approve.json()) as {
1955
+ grantToken: string;
1956
+ webhookDelivery?: { delivered: boolean };
1957
+ };
1958
+ expect(approved.grantToken).toMatch(/^rgt_/);
1959
+ expect(approved.webhookDelivery?.delivered).toBe(true);
1960
+ expect(bodies).toHaveLength(1);
1961
+ expect(bodies[0]).toMatchObject({
1962
+ event: "autosre.remediation.grant_issued",
1963
+ proposalId: proposal.id,
1964
+ status: "grant_issued",
1965
+ grantToken: approved.grantToken,
1966
+ incidentId: incident.id,
1967
+ });
1968
+
1969
+ const claim = (runnerId: string) =>
1970
+ app.request(
1971
+ `/internal/remediations/${proposal.id}/claim`,
1972
+ {
1973
+ method: "POST",
1974
+ headers: { "Content-Type": "application/json" },
1975
+ body: JSON.stringify({
1976
+ environment: "prod",
1977
+ grantToken: approved.grantToken,
1978
+ runnerId,
1979
+ }),
1980
+ },
1981
+ env,
1982
+ );
1983
+ expect((await claim("runner-x")).status).toBe(200);
1984
+ expect((await claim("runner-y")).status).toBe(409);
1985
+ db.close();
1986
+ });
1987
+
1988
+ it("B12: the circuit breaker is scoped to fingerprint + action kind and ignores rejected proposals", async () => {
1989
+ const db = createMigratedSqliteDb();
1990
+ const incident = await newIncident(db);
1991
+ const finding = await newFinding(db, incident.id);
1992
+ const mk = (key: string, extra: Record<string, unknown> = {}) =>
1993
+ propose(db, incident.id, finding.id, key, extra);
1994
+
1995
+ const a = await mk("k1");
1996
+ await mk("k2");
1997
+ await mk("k3");
1998
+ // A different action kind has its own budget.
1999
+ expect(
2000
+ (await mk("k_other", { actionKind: "apply_config" })).status,
2001
+ ).not.toBe("rejected");
2002
+ // Rejecting one frees a slot, so the next proposal is not blocked.
2003
+ await rejectRemediationProposal(db, "default", a.id, {
2004
+ environment: "prod",
2005
+ rejectedByUserId: "tester",
2006
+ });
2007
+ expect((await mk("k4")).status).toBe("pending_human_approval");
2008
+ // Three live ones now exist: the next is blocked by policy.
2009
+ const blocked = await mk("k5");
2010
+ expect(blocked.status).toBe("rejected");
2011
+ expect(blocked.policyDecisionReason).toMatch(/Circuit breaker/);
2012
+ db.close();
2013
+ });
2014
+
2015
+ it("B18: unknown incidents 404 and read_only/suggested_action requests never become grants", async () => {
2016
+ const db = createMigratedSqliteDb();
2017
+ const { incident, finding } = await (async () => {
2018
+ await seedIsolatedCohort(db);
2019
+ const incident = await newIncident(db);
2020
+ const finding = await newFinding(db, incident.id, {
2021
+ contributingChangeId: "chg_gov",
2022
+ });
2023
+ return { incident, finding };
2024
+ })();
2025
+ await expect(
2026
+ propose(db, "inc_missing", finding.id, "idem_404"),
2027
+ ).rejects.toMatchObject({ statusCode: 404 });
2028
+ for (const level of ["read_only", "suggested_action"]) {
2029
+ const p = await propose(db, incident.id, finding.id, `idem_${level}`, {
2030
+ requestedAutonomy: level,
2031
+ });
2032
+ expect(p.status).toBe("pending_human_approval");
2033
+ expect(p.effectiveAutonomy).toBe(level);
2034
+ expect(p.grantToken).toBeUndefined();
2035
+ }
2036
+ db.close();
2037
+ });
2038
+
2039
+ it("B11: watchdog checkpoints are anchored to completion time and recovery needs two consecutive healthy checkpoints", async () => {
2040
+ const db = createMigratedSqliteDb();
2041
+ const { proposal, incident, finding } = await grantedProposal(db);
2042
+ const t0 = Date.now();
2043
+ const at = (offsetMs: number) => new Date(t0 + offsetMs).toISOString();
2044
+ await claimRemediationGrant(db, "default", proposal.id, {
2045
+ environment: "prod",
2046
+ grantToken: proposal.grantToken as string,
2047
+ runnerId: "r1",
2048
+ nowIso: at(0),
2049
+ });
2050
+ await completeRemediationProposal(db, "default", proposal.id, {
2051
+ environment: "prod",
2052
+ runnerId: "r1",
2053
+ nowIso: at(60_000),
2054
+ });
2055
+ const completedAt = t0 + 60_000;
2056
+ const state = () =>
2057
+ db.raw
2058
+ .prepare(
2059
+ `SELECT recovery_status, watchdog_step, consecutive_ok_checks, next_watchdog_check_at FROM investigation_findings WHERE id = ?`,
2060
+ )
2061
+ .get(finding.id) as {
2062
+ recovery_status: string;
2063
+ watchdog_step: number;
2064
+ consecutive_ok_checks: number;
2065
+ next_watchdog_check_at: string | null;
2066
+ };
2067
+ expect(state().next_watchdog_check_at).toBe(
2068
+ new Date(completedAt + 2 * 60_000).toISOString(),
2069
+ );
2070
+
2071
+ for (let i = 0; i < 60; i++) {
2072
+ const t = new Date(completedAt + 30_000 + i * 100).toISOString();
2073
+ insertSpan(db, {
2074
+ traceId: `g_ok_${i}`,
2075
+ spanId: `g_ok_sp_${i}`,
2076
+ serviceName: "checkout-api",
2077
+ spanName: "POST /checkout",
2078
+ statusCode: 1,
2079
+ startTime: t,
2080
+ endTime: t,
2081
+ durationMs: 20,
2082
+ serviceVersion: "v2.4.0",
2083
+ });
2084
+ }
2085
+
2086
+ // Checkpoint 1 runs late (+10m): next checkpoint is still anchored on
2087
+ // completion (+5m), not on "now + offset".
2088
+ const first = await evaluateRemediationWatchdogs(db, {
2089
+ projectId: "default",
2090
+ nowIso: at(60_000 + 10 * 60_000),
2091
+ });
2092
+ expect(first.recovered).toBe(0);
2093
+ expect(state()).toMatchObject({
2094
+ recovery_status: "monitoring",
2095
+ watchdog_step: 2,
2096
+ consecutive_ok_checks: 1,
2097
+ next_watchdog_check_at: new Date(completedAt + 5 * 60_000).toISOString(),
2098
+ });
2099
+
2100
+ const second = await evaluateRemediationWatchdogs(db, {
2101
+ projectId: "default",
2102
+ nowIso: at(60_000 + 11 * 60_000),
2103
+ });
2104
+ expect(second.recovered).toBe(1);
2105
+ expect(state().recovery_status).toBe("recovered");
2106
+ const inc = db.raw
2107
+ .prepare(`SELECT status FROM incident_investigations WHERE id = ?`)
2108
+ .get(incident.id) as { status: string };
2109
+ expect(inc.status).toBe("resolved");
2110
+ db.close();
2111
+ });
2112
+
2113
+ it("B14/B17: routes reject unknown predicates and invalid timestamps", async () => {
2114
+ const db = createMigratedSqliteDb();
2115
+ const { app, env } = createTestApp(db);
2116
+ const post = (path: string, body: unknown) =>
2117
+ app.request(
2118
+ path,
2119
+ {
2120
+ method: "POST",
2121
+ headers: { "Content-Type": "application/json" },
2122
+ body: JSON.stringify(body),
2123
+ },
2124
+ env,
2125
+ );
2126
+
2127
+ const badPredicate = await post("/internal/findings", {
2128
+ title: "t",
2129
+ summary: "s",
2130
+ symptomEntityKind: "trace",
2131
+ symptomEntityId: "t1",
2132
+ faultLocusEntityKind: "service",
2133
+ faultLocusEntityId: SERVICE_NODE,
2134
+ causalChain: [
2135
+ {
2136
+ subjectKind: "trace",
2137
+ subjectId: "t1",
2138
+ predicate: "made_up_predicate",
2139
+ objectKind: "span",
2140
+ objectId: "s1",
2141
+ },
2142
+ ],
2143
+ });
2144
+ expect(badPredicate.status).toBe(400);
2145
+
2146
+ const badDate = await post("/v1/changes", {
2147
+ kind: "deploy",
2148
+ summary: "x",
2149
+ serviceName: "checkout-api",
2150
+ occurredAt: "yesterday-ish",
2151
+ });
2152
+ expect(badDate.status).toBe(400);
2153
+ const badKind = await post("/v1/changes", { kind: "nope", summary: "x" });
2154
+ expect(badKind.status).toBe(400);
2155
+
2156
+ const offset = await post("/v1/changes", {
2157
+ id: "chg_tz",
2158
+ kind: "deploy",
2159
+ summary: "x",
2160
+ serviceName: "checkout-api",
2161
+ environment: "prod",
2162
+ source: "ci_webhook",
2163
+ occurredAt: "2026-10-15T05:00:00+02:00",
2164
+ });
2165
+ expect(offset.status).toBe(201);
2166
+ const created = (await offset.json()) as {
2167
+ changeEvent: { occurredAt: string; source: string };
2168
+ };
2169
+ expect(created.changeEvent.occurredAt).toBe("2026-10-15T03:00:00.000Z");
2170
+ expect(created.changeEvent.source).toBe("ci_webhook");
2171
+
2172
+ const badSince = await app.request(
2173
+ "/internal/changes?since=garbage",
2174
+ {},
2175
+ env,
2176
+ );
2177
+ expect(badSince.status).toBe(400);
2178
+ const okSince = await app.request(
2179
+ "/internal/changes?since=2026-10-15T04:30:00%2B02:00",
2180
+ {},
2181
+ env,
2182
+ );
2183
+ expect(okSince.status).toBe(200);
2184
+ expect(
2185
+ ((await okSince.json()) as { changes: unknown[] }).changes,
2186
+ ).toHaveLength(1);
2187
+ db.close();
2188
+ });
2189
+
2190
+ it("B15: the approver is derived from the session, never from client-supplied identity", async () => {
2191
+ const db = createMigratedSqliteDb();
2192
+ const { app, env } = createTestApp(db);
2193
+ const incident = await newIncident(db);
2194
+ const finding = await newFinding(db, incident.id);
2195
+ const proposal = await propose(db, incident.id, finding.id, "idem_b15");
2196
+ const res = await app.request(
2197
+ `/internal/remediations/${proposal.id}/approve`,
2198
+ {
2199
+ method: "POST",
2200
+ headers: {
2201
+ "Content-Type": "application/json",
2202
+ Cookie: "obs_session=sess_secret_cookie_value",
2203
+ "X-Obs-User-Actor": "vp-of-engineering",
2204
+ },
2205
+ body: JSON.stringify({
2206
+ environment: "prod",
2207
+ approvedByUserId: "cto",
2208
+ approvedBySession: "cfo",
2209
+ }),
2210
+ },
2211
+ env,
2212
+ );
2213
+ expect(res.status).toBe(200);
2214
+ const body = (await res.json()) as {
2215
+ proposal: { approvedByUserId: string };
2216
+ };
2217
+ expect(body.proposal.approvedByUserId).toMatch(
2218
+ /^dashboard_session:[0-9a-f]{16}$/,
2219
+ );
2220
+ db.close();
2221
+ });
2222
+ });