@obsunified/collector 2.2.0 → 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.
@@ -1,6 +1,9 @@
1
- import { buildCanonicalNodeId, buildFindingFingerprint } from "@obsunified/types";
1
+ import {
2
+ buildCanonicalNodeId,
3
+ buildFindingFingerprint,
4
+ } from "@obsunified/types";
2
5
  import { Hono } from "hono";
3
- import { describe, expect, it } from "vitest";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
7
  import { CollectorRuntime } from "../framework/collector";
5
8
  import type { CollectorEnv } from "../framework/env";
6
9
  import {
@@ -8,13 +11,25 @@ import {
8
11
  findCrossTraceContention,
9
12
  recordChangeEvent,
10
13
  } from "../lib/autosre-change-correlation";
14
+ import { recordInvestigationFinding } from "../lib/autosre-findings";
11
15
  import {
12
16
  computeTarjanScc,
13
17
  extractSpanLineageAndTopology,
14
18
  localizeTraceFaultLocus,
15
19
  upsertOntologyFromSpans,
16
20
  } from "../lib/autosre-ontology";
17
- import { evaluateRemediationWatchdogs } from "../lib/autosre-remediation-gate";
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";
18
33
  import {
19
34
  createMigratedSqliteDb,
20
35
  type NodeSqliteDb,
@@ -207,7 +222,8 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
207
222
 
208
223
  // Verify unscoped DB contention caps relationshipCertainty at "inferred"
209
224
  const baseMs = Date.parse("2026-10-15T01:10:00.000Z");
210
- const unscopedNodeId = unscopedCtx.targetNode!.id;
225
+ const unscopedNodeId = unscopedCtx.targetNode?.id;
226
+ if (!unscopedNodeId) throw new Error("expected an unscoped target node");
211
227
  for (let i = 0; i < 6; i++) {
212
228
  insertSpan(db, {
213
229
  traceId: "tr_aggressor_unscoped",
@@ -629,8 +645,7 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
629
645
 
630
646
  // 1. Seed concurrent v2.4.0 traffic (60 spans, 1 error = 1.67% error rate)
631
647
  // 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";
648
+ const dbNodeId = "database:prod:postgresql:pg-primary.internal:5432/orders";
634
649
  const serviceNodeId = "service:prod:default:checkout-api";
635
650
 
636
651
  for (let i = 0; i < 60; i++) {
@@ -704,10 +719,7 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
704
719
  commitSha: "8f3a1b2",
705
720
  previousCommitSha: "1111111",
706
721
  summary: "Deploy checkout-api v2.4.1 (batch inventory lock)",
707
- touchedPaths: [
708
- "src/checkout/inventory.ts",
709
- "src/checkout/routes.ts",
710
- ],
722
+ touchedPaths: ["src/checkout/inventory.ts", "src/checkout/routes.ts"],
711
723
  occurredAt: new Date(deployTime).toISOString(),
712
724
  }),
713
725
  },
@@ -835,7 +847,8 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
835
847
  faultLocusEntityId: "sp_new_0",
836
848
  rootCauseEntityKind: "change",
837
849
  rootCauseEntityId: "chg_deploy_241",
838
- contributingChangeId: "chg_deploy_241",
850
+ // No contributingChangeId: the server cannot recompute a cohort
851
+ // verdict, so the proof tier stays corroborated_candidate.
839
852
  cohortVerdict: "shared_degradation_new_version_aggressor",
840
853
  causalChain: [
841
854
  {
@@ -852,7 +865,7 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
852
865
  {
853
866
  subjectKind: "span",
854
867
  subjectId: "sp_new_0",
855
- predicate: "stackframe_overlaps_changed_file",
868
+ predicate: "touched_same_code_path",
856
869
  objectKind: "change",
857
870
  objectId: "chg_deploy_241",
858
871
  relationshipCertainty: "exact",
@@ -926,9 +939,13 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
926
939
  headers: {
927
940
  "Content-Type": "application/json",
928
941
  Cookie: "obs_session=sess_human_operator_123",
942
+ // Client-supplied identities must be ignored (B15).
929
943
  "X-Obs-User-Actor": "sawan@obsunified.dev",
930
944
  },
931
- body: JSON.stringify({ environment: "prod" }),
945
+ body: JSON.stringify({
946
+ environment: "prod",
947
+ approvedByUserId: "ceo@example.com",
948
+ }),
932
949
  },
933
950
  env,
934
951
  );
@@ -938,8 +955,11 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
938
955
  grantToken: string;
939
956
  };
940
957
  expect(humanApproveBody.proposal.status).toBe("grant_issued");
941
- expect(humanApproveBody.proposal.approvedByUserId).toBe(
942
- "sawan@obsunified.dev",
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",
943
963
  );
944
964
  expect(humanApproveBody.grantToken).toMatch(/^rgt_/);
945
965
 
@@ -954,7 +974,8 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
954
974
  sourceEntityType: "trace",
955
975
  sourceEntityId: "tr_new_0",
956
976
  name: "Reproduce checkout lock timeout",
957
- expectedOutcome: "Checkout completes under 200ms without lock timeout",
977
+ expectedOutcome:
978
+ "Checkout completes under 200ms without lock timeout",
958
979
  }),
959
980
  },
960
981
  env,
@@ -1136,6 +1157,7 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
1136
1157
  headers: { "Content-Type": "application/json" },
1137
1158
  body: JSON.stringify({
1138
1159
  environment: "prod",
1160
+ runnerId: "runner-alpha",
1139
1161
  resultingChangeId: rollbackChange.id,
1140
1162
  remediationAgentRunId: "arun_autosre_1",
1141
1163
  }),
@@ -1185,9 +1207,20 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
1185
1207
  )
1186
1208
  .run(new Date(postRollbackBase + 40_000).toISOString(), finding.id);
1187
1209
 
1188
- const watchdogRecovered = await evaluateRemediationWatchdogs(db, {
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, {
1189
1213
  nowIso: new Date(postRollbackBase + 50_000).toISOString(),
1190
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
+ });
1191
1224
  expect(watchdogRecovered.recovered).toBe(1);
1192
1225
 
1193
1226
  // Verify incident is now resolved and finding recoveryStatus === 'recovered'
@@ -1270,6 +1303,920 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
1270
1303
  ),
1271
1304
  ).toBe(true);
1272
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
+ );
1273
2220
  db.close();
1274
2221
  });
1275
2222
  });