@obsunified/collector 2.2.0 → 2.2.3

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 (45) hide show
  1. package/dist/auth.js +1 -1
  2. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  3. package/dist/chunk-2FALTDCE.js.map +1 -0
  4. package/dist/index.d.ts +61 -3
  5. package/dist/index.js +10933 -9769
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -3
  8. package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
  9. package/src/auth/dashboard-auth.test.ts +61 -0
  10. package/src/auth/dashboard-auth.ts +17 -2
  11. package/src/lib/ai-store/calls.ts +43 -23
  12. package/src/lib/ai-store.ts +12 -1
  13. package/src/lib/analyses-runner.ts +44 -7
  14. package/src/lib/autosre-change-correlation.ts +39 -12
  15. package/src/lib/autosre-findings.ts +237 -44
  16. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  17. package/src/lib/autosre-ontology.ts +52 -28
  18. package/src/lib/autosre-remediation-gate.ts +942 -348
  19. package/src/lib/housekeeping.test.ts +70 -0
  20. package/src/lib/housekeeping.ts +91 -83
  21. package/src/lib/projects-store.test.ts +38 -1
  22. package/src/lib/projects-store.ts +23 -4
  23. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  24. package/src/lib/store/ingest.ts +8 -56
  25. package/src/lib/usage-store.ts +9 -3
  26. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  27. package/src/migrations/052_remediation_callback.sql +10 -0
  28. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  29. package/src/migrations/alert-state-notified.test.ts +3 -3
  30. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  31. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  32. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  33. package/src/plugins/ai-call-by-id.test.ts +73 -0
  34. package/src/plugins/ai-receiver.ts +10 -0
  35. package/src/plugins/alerts-evaluator.ts +15 -3
  36. package/src/plugins/autosre-routes.ts +374 -108
  37. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  38. package/src/plugins/connected-routes/manifest.test.ts +142 -0
  39. package/src/plugins/connected-routes/manifest.ts +123 -6
  40. package/src/plugins/connected-routes-links.test.ts +96 -0
  41. package/src/plugins/connected-routes.test.ts +3 -3
  42. package/src/plugins/connected-routes.ts +56 -83
  43. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  44. package/src/plugins/evidence-retrieval-routes.ts +397 -25
  45. package/dist/chunk-KMEZKGSO.js.map +0 -1
@@ -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,1510 @@ 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
+ errorSignature: "checkout 500",
1401
+ });
1402
+ }
1403
+ await recordChangeEvent(db, "default", {
1404
+ id: "chg_gov",
1405
+ environment: "prod",
1406
+ kind: "deploy",
1407
+ serviceName: "checkout-api",
1408
+ version: "v2.4.1",
1409
+ previousVersion: "v2.4.0",
1410
+ summary: "Deploy v2.4.1",
1411
+ occurredAt: new Date(DEPLOY).toISOString(),
1412
+ });
1413
+ };
1414
+
1415
+ const newIncident = async (db: NodeSqliteDb, fingerprint = "fp_gov") =>
1416
+ (
1417
+ await openOrDeduplicateIncident(db, "default", {
1418
+ environment: "prod",
1419
+ targetNodeId: SERVICE_NODE,
1420
+ symptomAnchorKind: "trace",
1421
+ symptomAnchorId: "t1",
1422
+ symptomFingerprint: fingerprint,
1423
+ symptomCategory: fingerprint,
1424
+ })
1425
+ ).incident;
1426
+
1427
+ const newFinding = (
1428
+ db: NodeSqliteDb,
1429
+ incidentId: string,
1430
+ extra: Partial<Parameters<typeof recordInvestigationFinding>[2]> = {},
1431
+ ) =>
1432
+ recordInvestigationFinding(db, "default", {
1433
+ environment: "prod",
1434
+ incidentId,
1435
+ title: "Checkout regression",
1436
+ summary: "Errors after deploy",
1437
+ causalProofTier: "corroborated_candidate",
1438
+ symptomEntityKind: "trace",
1439
+ symptomEntityId: "t1",
1440
+ faultLocusEntityKind: "service",
1441
+ faultLocusEntityId: SERVICE_NODE,
1442
+ causalChain: [],
1443
+ citedEvidenceIds: [],
1444
+ ...extra,
1445
+ });
1446
+
1447
+ const propose = (
1448
+ db: NodeSqliteDb,
1449
+ incidentId: string,
1450
+ findingId: string,
1451
+ key: string,
1452
+ extra: Record<string, unknown> = {},
1453
+ ) =>
1454
+ proposeRemediationAction(db, "default", {
1455
+ environment: "prod",
1456
+ incidentId,
1457
+ findingId,
1458
+ idempotencyKey: key,
1459
+ actionKind: "rollback_deploy",
1460
+ targetNodeId: SERVICE_NODE,
1461
+ expectedCurrentState: {},
1462
+ desiredTargetState: { version: "v2.4.0" },
1463
+ requestedAutonomy: "autonomous_write",
1464
+ ...extra,
1465
+ } as Parameters<typeof proposeRemediationAction>[2]);
1466
+
1467
+ const grantedProposal = async (db: NodeSqliteDb, key = "idem_granted") => {
1468
+ await seedIsolatedCohort(db);
1469
+ const incident = await newIncident(db);
1470
+ const finding = await newFinding(db, incident.id, {
1471
+ contributingChangeId: "chg_gov",
1472
+ });
1473
+ const proposal = await propose(db, incident.id, finding.id, key);
1474
+ return { incident, finding, proposal };
1475
+ };
1476
+
1477
+ it("B1: a client-asserted isolated_to_new_version verdict cannot buy an autonomous rollback grant", async () => {
1478
+ const db = createMigratedSqliteDb();
1479
+ const incident = await newIncident(db);
1480
+ const finding = await newFinding(db, incident.id, {
1481
+ cohortVerdict: "isolated_to_new_version",
1482
+ causalProofTier: "cohort_rate_isolated",
1483
+ causalChain: [
1484
+ {
1485
+ subjectKind: "service",
1486
+ subjectId: SERVICE_NODE,
1487
+ predicate: "isolated_to_version_cohort",
1488
+ objectKind: "change",
1489
+ objectId: "chg_missing",
1490
+ relationshipCertainty: "exact",
1491
+ causalProofTier: "cohort_rate_isolated",
1492
+ confidence: 0.99,
1493
+ reason: "asserted by client",
1494
+ },
1495
+ ],
1496
+ });
1497
+ expect(finding.causalProofTier).toBe("corroborated_candidate");
1498
+ expect(finding.cohortVerdict).toBeNull();
1499
+
1500
+ const proposal = await propose(db, incident.id, finding.id, "idem_b1");
1501
+ expect(proposal.status).toBe("pending_human_approval");
1502
+ expect(proposal.grantToken).toBeUndefined();
1503
+
1504
+ // Pointing at a real change whose telemetry does not show isolation
1505
+ // (no spans at all) does not help either.
1506
+ await recordChangeEvent(db, "default", {
1507
+ id: "chg_empty",
1508
+ environment: "prod",
1509
+ kind: "deploy",
1510
+ serviceName: "checkout-api",
1511
+ version: "v9",
1512
+ summary: "no telemetry",
1513
+ });
1514
+ const f2 = await newFinding(db, incident.id, {
1515
+ cohortVerdict: "isolated_to_new_version",
1516
+ contributingChangeId: "chg_empty",
1517
+ });
1518
+ expect(f2.causalProofTier).toBe("corroborated_candidate");
1519
+ db.close();
1520
+ });
1521
+
1522
+ it("B1: the server-recomputed isolated verdict is what yields cohort_rate_isolated and an autonomous grant", async () => {
1523
+ const db = createMigratedSqliteDb();
1524
+ const { finding, proposal } = await grantedProposal(db);
1525
+ expect(finding.causalProofTier).toBe("cohort_rate_isolated");
1526
+ expect(finding.cohortVerdict).toBe("isolated_to_new_version");
1527
+ expect(proposal.status).toBe("grant_issued");
1528
+ expect(proposal.grantToken).toMatch(/^rgt_/);
1529
+ db.close();
1530
+ });
1531
+
1532
+ it("B2: a grant token is single-use, sequentially and under concurrent claims", async () => {
1533
+ const db = createMigratedSqliteDb();
1534
+ const { proposal } = await grantedProposal(db);
1535
+ const token = proposal.grantToken as string;
1536
+
1537
+ await claimRemediationGrant(db, "default", proposal.id, {
1538
+ environment: "prod",
1539
+ grantToken: token,
1540
+ runnerId: "r1",
1541
+ });
1542
+ await expect(
1543
+ claimRemediationGrant(db, "default", proposal.id, {
1544
+ environment: "prod",
1545
+ grantToken: token,
1546
+ runnerId: "r2",
1547
+ }),
1548
+ ).rejects.toMatchObject({ statusCode: 409 });
1549
+
1550
+ // Concurrent claims on a fresh grant: exactly one wins.
1551
+ const second = await propose(
1552
+ db,
1553
+ proposal.incidentId,
1554
+ proposal.findingId,
1555
+ "idem_b2_conc",
1556
+ { actionKind: "toggle_feature_flag" },
1557
+ );
1558
+ const results = await Promise.allSettled(
1559
+ ["a", "b", "c", "d"].map((runnerId) =>
1560
+ claimRemediationGrant(db, "default", second.id, {
1561
+ environment: "prod",
1562
+ grantToken: second.grantToken as string,
1563
+ runnerId,
1564
+ }),
1565
+ ),
1566
+ );
1567
+ expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
1568
+ for (const r of results) {
1569
+ if (r.status === "rejected") {
1570
+ expect(r.reason).toBeInstanceOf(RemediationGateError);
1571
+ expect((r.reason as RemediationGateError).statusCode).toBe(409);
1572
+ }
1573
+ }
1574
+ db.close();
1575
+ });
1576
+
1577
+ it("B2: a wrong token is rejected with 403 and does not consume the grant", async () => {
1578
+ const db = createMigratedSqliteDb();
1579
+ const { proposal } = await grantedProposal(db);
1580
+ await expect(
1581
+ claimRemediationGrant(db, "default", proposal.id, {
1582
+ environment: "prod",
1583
+ grantToken: "rgt_wrong",
1584
+ }),
1585
+ ).rejects.toMatchObject({ statusCode: 403 });
1586
+ const ok = await claimRemediationGrant(db, "default", proposal.id, {
1587
+ environment: "prod",
1588
+ grantToken: proposal.grantToken as string,
1589
+ });
1590
+ expect(ok.status).toBe("claimed_executing");
1591
+ db.close();
1592
+ });
1593
+
1594
+ it("B3: concurrent lease acquisition has exactly one winner; expired leases can be taken over", async () => {
1595
+ const db = createMigratedSqliteDb();
1596
+ const incident = await newIncident(db);
1597
+ const results = await Promise.all(
1598
+ ["a", "b", "c"].map((runnerId) =>
1599
+ acquireIncidentLease(db, "default", incident.id, {
1600
+ environment: "prod",
1601
+ runnerId,
1602
+ }),
1603
+ ),
1604
+ );
1605
+ const winners = results.filter((r) => r.acquired);
1606
+ expect(winners).toHaveLength(1);
1607
+ const owner = (winners[0] as { incident: { leaseOwnerId: string } })
1608
+ .incident.leaseOwnerId;
1609
+ // The owner can renew; a rival cannot until expiry.
1610
+ expect(
1611
+ (
1612
+ await acquireIncidentLease(db, "default", incident.id, {
1613
+ environment: "prod",
1614
+ runnerId: owner,
1615
+ })
1616
+ ).acquired,
1617
+ ).toBe(true);
1618
+ const rival = owner === "a" ? "b" : "a";
1619
+ expect(
1620
+ (
1621
+ await acquireIncidentLease(db, "default", incident.id, {
1622
+ environment: "prod",
1623
+ runnerId: rival,
1624
+ })
1625
+ ).acquired,
1626
+ ).toBe(false);
1627
+ const later = new Date(Date.now() + 3600_000).toISOString();
1628
+ const takeover = await acquireIncidentLease(db, "default", incident.id, {
1629
+ environment: "prod",
1630
+ runnerId: rival,
1631
+ nowIso: later,
1632
+ });
1633
+ expect(takeover.acquired).toBe(true);
1634
+ db.close();
1635
+ });
1636
+
1637
+ it("B4: concurrent alerts for one symptom open exactly one active incident", async () => {
1638
+ const db = createMigratedSqliteDb();
1639
+ const results = await Promise.all(
1640
+ Array.from({ length: 6 }, () =>
1641
+ openOrDeduplicateIncident(db, "default", {
1642
+ environment: "prod",
1643
+ targetNodeId: SERVICE_NODE,
1644
+ symptomAnchorKind: "alert",
1645
+ symptomAnchorId: "a1",
1646
+ symptomCategory: "race",
1647
+ }),
1648
+ ),
1649
+ );
1650
+ expect(results.filter((r) => !r.deduplicated)).toHaveLength(1);
1651
+ expect(new Set(results.map((r) => r.incident.id)).size).toBe(1);
1652
+ const count = db.raw
1653
+ .prepare(`SELECT COUNT(*) AS n FROM incident_investigations`)
1654
+ .get() as { n: number };
1655
+ expect(count.n).toBe(1);
1656
+ expect(results[0].incident.alertCount).toBeGreaterThanOrEqual(1);
1657
+
1658
+ // The database itself refuses a second active row for the same key.
1659
+ expect(() =>
1660
+ db.raw
1661
+ .prepare(
1662
+ `INSERT INTO incident_investigations (project_id, environment, id, dedup_key, status, target_node_id, symptom_anchor_kind, symptom_anchor_id, opened_at, updated_at)
1663
+ SELECT project_id, environment, 'dupe', dedup_key, 'open', target_node_id, symptom_anchor_kind, symptom_anchor_id, opened_at, updated_at FROM incident_investigations`,
1664
+ )
1665
+ .run(),
1666
+ ).toThrow(/UNIQUE/i);
1667
+ // Once resolved, a new incident for the same symptom is allowed.
1668
+ db.raw
1669
+ .prepare(`UPDATE incident_investigations SET status = 'resolved'`)
1670
+ .run();
1671
+ const reopened = await openOrDeduplicateIncident(db, "default", {
1672
+ environment: "prod",
1673
+ targetNodeId: SERVICE_NODE,
1674
+ symptomAnchorKind: "alert",
1675
+ symptomAnchorId: "a1",
1676
+ symptomCategory: "race",
1677
+ });
1678
+ expect(reopened.deduplicated).toBe(false);
1679
+ db.close();
1680
+ });
1681
+
1682
+ it("B5: concurrent proposals with one idempotency key return the same row instead of a DB error", async () => {
1683
+ const db = createMigratedSqliteDb();
1684
+ const incident = await newIncident(db);
1685
+ const finding = await newFinding(db, incident.id);
1686
+ const results = await Promise.all(
1687
+ Array.from({ length: 4 }, () =>
1688
+ propose(db, incident.id, finding.id, "idem_same"),
1689
+ ),
1690
+ );
1691
+ expect(new Set(results.map((r) => r.id)).size).toBe(1);
1692
+ await expect(
1693
+ propose(db, incident.id, finding.id, "idem_same", {
1694
+ actionKind: "apply_config",
1695
+ }),
1696
+ ).rejects.toMatchObject({ statusCode: 409 });
1697
+ db.close();
1698
+ });
1699
+
1700
+ it("B6: the watchdog route ignores a client clock and only evaluates the caller's project", async () => {
1701
+ const db = createMigratedSqliteDb();
1702
+ const { app, env } = createTestApp(db);
1703
+ db.raw
1704
+ .prepare(
1705
+ `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)
1706
+ 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')`,
1707
+ )
1708
+ .run();
1709
+ const res = await app.request(
1710
+ "/internal/remediations/watchdog",
1711
+ {
1712
+ method: "POST",
1713
+ headers: { "Content-Type": "application/json" },
1714
+ body: JSON.stringify({
1715
+ nowIso: "2099-01-01T00:00:00.000Z",
1716
+ minComparableRequests: 0,
1717
+ }),
1718
+ },
1719
+ env,
1720
+ );
1721
+ expect(res.status).toBe(200);
1722
+ expect(((await res.json()) as { evaluated: number }).evaluated).toBe(0);
1723
+ const row = db.raw
1724
+ .prepare(
1725
+ `SELECT recovery_status FROM investigation_findings WHERE id = 'fnd_other'`,
1726
+ )
1727
+ .get() as { recovery_status: string };
1728
+ expect(row.recovery_status).toBe("monitoring");
1729
+ db.close();
1730
+ });
1731
+
1732
+ it("B7: only the claiming runner can complete or fail, and only while claimed (and, for complete, unexpired)", async () => {
1733
+ const db = createMigratedSqliteDb();
1734
+ const { proposal } = await grantedProposal(db);
1735
+ // Not yet claimed.
1736
+ await expect(
1737
+ completeRemediationProposal(db, "default", proposal.id, {
1738
+ environment: "prod",
1739
+ runnerId: "r1",
1740
+ }),
1741
+ ).rejects.toMatchObject({ statusCode: 409 });
1742
+ await expect(
1743
+ failRemediationProposal(db, "default", proposal.id, {
1744
+ environment: "prod",
1745
+ runnerId: "r1",
1746
+ failureReason: "x",
1747
+ }),
1748
+ ).rejects.toMatchObject({ statusCode: 409 });
1749
+
1750
+ const t0 = new Date().toISOString();
1751
+ await claimRemediationGrant(db, "default", proposal.id, {
1752
+ environment: "prod",
1753
+ grantToken: proposal.grantToken as string,
1754
+ runnerId: "r1",
1755
+ nowIso: t0,
1756
+ });
1757
+ await expect(
1758
+ completeRemediationProposal(db, "default", proposal.id, {
1759
+ environment: "prod",
1760
+ runnerId: "someone-else",
1761
+ }),
1762
+ ).rejects.toMatchObject({ statusCode: 409 });
1763
+ await expect(
1764
+ completeRemediationProposal(db, "default", proposal.id, {
1765
+ environment: "prod",
1766
+ }),
1767
+ ).rejects.toMatchObject({ statusCode: 409 });
1768
+ await expect(
1769
+ failRemediationProposal(db, "default", proposal.id, {
1770
+ environment: "prod",
1771
+ runnerId: "someone-else",
1772
+ failureReason: "x",
1773
+ }),
1774
+ ).rejects.toMatchObject({ statusCode: 409 });
1775
+ await expect(
1776
+ completeRemediationProposal(db, "default", proposal.id, {
1777
+ environment: "prod",
1778
+ runnerId: "r1",
1779
+ nowIso: new Date(Date.parse(t0) + 6 * 60_000).toISOString(),
1780
+ }),
1781
+ ).rejects.toMatchObject({ statusCode: 409 });
1782
+
1783
+ const failed = await failRemediationProposal(db, "default", proposal.id, {
1784
+ environment: "prod",
1785
+ runnerId: "r1",
1786
+ failureReason: "boom",
1787
+ });
1788
+ expect(failed.status).toBe("failed_execution");
1789
+ // Terminal: cannot be failed or completed again.
1790
+ await expect(
1791
+ failRemediationProposal(db, "default", proposal.id, {
1792
+ environment: "prod",
1793
+ runnerId: "r1",
1794
+ failureReason: "again",
1795
+ }),
1796
+ ).rejects.toMatchObject({ statusCode: 409 });
1797
+ db.close();
1798
+ });
1799
+
1800
+ it("B8: incident webhooks refuse private targets and redirects, never follow redirects, and time out", async () => {
1801
+ const db = createMigratedSqliteDb();
1802
+ const incident = await newIncident(db);
1803
+ const calls: Array<{ url: string; init: RequestInit }> = [];
1804
+ const okFetch = (async (url: string, init: RequestInit) => {
1805
+ calls.push({ url, init });
1806
+ return new Response("ok", { status: 200 });
1807
+ }) as unknown as typeof fetch;
1808
+
1809
+ const privateLookup = vi.fn(async () => ["10.0.0.5"]);
1810
+ const refused = await dispatchIncidentWebhook(db, "default", incident.id, {
1811
+ environment: "prod",
1812
+ url: "https://internal.example.com/hook",
1813
+ hostLookup: privateLookup,
1814
+ fetchImpl: okFetch,
1815
+ });
1816
+ expect(refused.delivered).toBe(false);
1817
+ expect(refused.error).toMatch(/private/);
1818
+ const literal = await dispatchIncidentWebhook(db, "default", incident.id, {
1819
+ environment: "prod",
1820
+ url: "http://127.0.0.1:8080/hook",
1821
+ fetchImpl: okFetch,
1822
+ });
1823
+ expect(literal.delivered).toBe(false);
1824
+ const unresolvable = await dispatchIncidentWebhook(
1825
+ db,
1826
+ "default",
1827
+ incident.id,
1828
+ {
1829
+ environment: "prod",
1830
+ url: "https://nowhere.example.com/hook",
1831
+ hostLookup: async () => {
1832
+ throw new Error("ENOTFOUND");
1833
+ },
1834
+ fetchImpl: okFetch,
1835
+ },
1836
+ );
1837
+ expect(unresolvable.delivered).toBe(false);
1838
+ expect(calls).toHaveLength(0);
1839
+
1840
+ const delivered = await dispatchIncidentWebhook(
1841
+ db,
1842
+ "default",
1843
+ incident.id,
1844
+ {
1845
+ environment: "prod",
1846
+ url: "https://hooks.example.com/hook",
1847
+ secret: "s3cret",
1848
+ hostLookup: async () => ["93.184.216.34"],
1849
+ fetchImpl: okFetch,
1850
+ },
1851
+ );
1852
+ expect(delivered.delivered).toBe(true);
1853
+ expect(calls).toHaveLength(1);
1854
+ expect(calls[0].init.redirect).toBe("manual");
1855
+ expect(calls[0].init.signal).toBeInstanceOf(AbortSignal);
1856
+ expect(
1857
+ (calls[0].init.headers as Record<string, string>)["X-Obs-Signature-256"],
1858
+ ).toMatch(/^sha256=[0-9a-f]{64}$/);
1859
+
1860
+ const redirected = await dispatchIncidentWebhook(
1861
+ db,
1862
+ "default",
1863
+ incident.id,
1864
+ {
1865
+ environment: "prod",
1866
+ url: "https://hooks.example.com/hook",
1867
+ hostLookup: async () => ["93.184.216.34"],
1868
+ fetchImpl: (async () =>
1869
+ new Response(null, {
1870
+ status: 302,
1871
+ headers: { Location: "http://169.254.169.254/" },
1872
+ })) as unknown as typeof fetch,
1873
+ },
1874
+ );
1875
+ expect(redirected.delivered).toBe(false);
1876
+ expect(redirected.error).toMatch(/Redirect/);
1877
+ db.close();
1878
+ });
1879
+
1880
+ it("B8: the dispatch route cannot disable the SSRF guard from the request body", async () => {
1881
+ const db = createMigratedSqliteDb();
1882
+ const { app, env } = createTestApp(db);
1883
+ const incident = await newIncident(db);
1884
+ const fetchSpy = vi.fn(async () => new Response("ok"));
1885
+ vi.stubGlobal("fetch", fetchSpy);
1886
+ const res = await app.request(
1887
+ `/internal/incidents/${incident.id}/dispatch`,
1888
+ {
1889
+ method: "POST",
1890
+ headers: { "Content-Type": "application/json" },
1891
+ body: JSON.stringify({
1892
+ environment: "prod",
1893
+ webhookUrl: "http://127.0.0.1:9/hook",
1894
+ allowPrivateNetworks: true,
1895
+ allowPrivateWebhookTargets: true,
1896
+ }),
1897
+ },
1898
+ env,
1899
+ );
1900
+ expect(res.status).toBe(502);
1901
+ expect(fetchSpy).not.toHaveBeenCalled();
1902
+
1903
+ // The operator-level env flag is the only way to allow private targets.
1904
+ const allowed = await app.request(
1905
+ `/internal/incidents/${incident.id}/dispatch`,
1906
+ {
1907
+ method: "POST",
1908
+ headers: { "Content-Type": "application/json" },
1909
+ body: JSON.stringify({
1910
+ environment: "prod",
1911
+ webhookUrl: "http://127.0.0.1:9/hook",
1912
+ }),
1913
+ },
1914
+ { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" },
1915
+ );
1916
+ expect(allowed.status).toBe(200);
1917
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
1918
+ db.close();
1919
+ });
1920
+
1921
+ it("B9: approve -> grant token pushed over the webhook -> claim succeeds once -> second claim 409", async () => {
1922
+ const db = createMigratedSqliteDb();
1923
+ const { app, env } = createTestApp(db);
1924
+ const incident = await newIncident(db);
1925
+ const finding = await newFinding(db, incident.id); // corroborated: needs a human
1926
+ const proposal = await propose(db, incident.id, finding.id, "idem_b9");
1927
+ expect(proposal.status).toBe("pending_human_approval");
1928
+
1929
+ const bodies: Array<Record<string, unknown>> = [];
1930
+ vi.stubGlobal(
1931
+ "fetch",
1932
+ vi.fn(async (_url: string, init: RequestInit) => {
1933
+ bodies.push(JSON.parse(String(init.body)));
1934
+ return new Response("ok", { status: 200 });
1935
+ }),
1936
+ );
1937
+ const approveEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
1938
+ const approve = await app.request(
1939
+ `/internal/remediations/${proposal.id}/approve`,
1940
+ {
1941
+ method: "POST",
1942
+ headers: {
1943
+ "Content-Type": "application/json",
1944
+ Cookie: "obs_session=sess_b9",
1945
+ },
1946
+ body: JSON.stringify({
1947
+ environment: "prod",
1948
+ webhookUrl: "https://runner.example.com/grants",
1949
+ secret: "hmac-secret",
1950
+ }),
1951
+ },
1952
+ approveEnv,
1953
+ );
1954
+ expect(approve.status).toBe(200);
1955
+ const approved = (await approve.json()) as {
1956
+ grantToken: string;
1957
+ webhookDelivery?: { delivered: boolean };
1958
+ };
1959
+ expect(approved.grantToken).toMatch(/^rgt_/);
1960
+ expect(approved.webhookDelivery?.delivered).toBe(true);
1961
+ expect(bodies).toHaveLength(1);
1962
+ expect(bodies[0]).toMatchObject({
1963
+ event: "autosre.remediation.grant_issued",
1964
+ proposalId: proposal.id,
1965
+ status: "grant_issued",
1966
+ grantToken: approved.grantToken,
1967
+ incidentId: incident.id,
1968
+ });
1969
+
1970
+ const claim = (runnerId: string) =>
1971
+ app.request(
1972
+ `/internal/remediations/${proposal.id}/claim`,
1973
+ {
1974
+ method: "POST",
1975
+ headers: { "Content-Type": "application/json" },
1976
+ body: JSON.stringify({
1977
+ environment: "prod",
1978
+ grantToken: approved.grantToken,
1979
+ runnerId,
1980
+ }),
1981
+ },
1982
+ env,
1983
+ );
1984
+ expect((await claim("runner-x")).status).toBe(200);
1985
+ expect((await claim("runner-y")).status).toBe(409);
1986
+ db.close();
1987
+ });
1988
+
1989
+ it("B12: the circuit breaker is scoped to fingerprint + action kind and ignores rejected proposals", async () => {
1990
+ const db = createMigratedSqliteDb();
1991
+ const incident = await newIncident(db);
1992
+ const finding = await newFinding(db, incident.id);
1993
+ const mk = (key: string, extra: Record<string, unknown> = {}) =>
1994
+ propose(db, incident.id, finding.id, key, extra);
1995
+
1996
+ const a = await mk("k1");
1997
+ await mk("k2");
1998
+ await mk("k3");
1999
+ // A different action kind has its own budget.
2000
+ expect(
2001
+ (await mk("k_other", { actionKind: "apply_config" })).status,
2002
+ ).not.toBe("rejected");
2003
+ // Rejecting one frees a slot, so the next proposal is not blocked.
2004
+ await rejectRemediationProposal(db, "default", a.id, {
2005
+ environment: "prod",
2006
+ rejectedByUserId: "tester",
2007
+ });
2008
+ expect((await mk("k4")).status).toBe("pending_human_approval");
2009
+ // Three live ones now exist: the next is blocked by policy.
2010
+ const blocked = await mk("k5");
2011
+ expect(blocked.status).toBe("rejected");
2012
+ expect(blocked.policyDecisionReason).toMatch(/Circuit breaker/);
2013
+ db.close();
2014
+ });
2015
+
2016
+ it("B18: unknown incidents 404 and read_only/suggested_action requests never become grants", async () => {
2017
+ const db = createMigratedSqliteDb();
2018
+ const { incident, finding } = await (async () => {
2019
+ await seedIsolatedCohort(db);
2020
+ const incident = await newIncident(db);
2021
+ const finding = await newFinding(db, incident.id, {
2022
+ contributingChangeId: "chg_gov",
2023
+ });
2024
+ return { incident, finding };
2025
+ })();
2026
+ await expect(
2027
+ propose(db, "inc_missing", finding.id, "idem_404"),
2028
+ ).rejects.toMatchObject({ statusCode: 404 });
2029
+ for (const level of ["read_only", "suggested_action"]) {
2030
+ const p = await propose(db, incident.id, finding.id, `idem_${level}`, {
2031
+ requestedAutonomy: level,
2032
+ });
2033
+ expect(p.status).toBe("pending_human_approval");
2034
+ expect(p.effectiveAutonomy).toBe(level);
2035
+ expect(p.grantToken).toBeUndefined();
2036
+ }
2037
+ db.close();
2038
+ });
2039
+
2040
+ it("B11: watchdog checkpoints are anchored to completion time and recovery needs two consecutive healthy checkpoints", async () => {
2041
+ const db = createMigratedSqliteDb();
2042
+ const { proposal, incident, finding } = await grantedProposal(db);
2043
+ const t0 = Date.now();
2044
+ const at = (offsetMs: number) => new Date(t0 + offsetMs).toISOString();
2045
+ await claimRemediationGrant(db, "default", proposal.id, {
2046
+ environment: "prod",
2047
+ grantToken: proposal.grantToken as string,
2048
+ runnerId: "r1",
2049
+ nowIso: at(0),
2050
+ });
2051
+ await completeRemediationProposal(db, "default", proposal.id, {
2052
+ environment: "prod",
2053
+ runnerId: "r1",
2054
+ nowIso: at(60_000),
2055
+ });
2056
+ const completedAt = t0 + 60_000;
2057
+ const state = () =>
2058
+ db.raw
2059
+ .prepare(
2060
+ `SELECT recovery_status, watchdog_step, consecutive_ok_checks, next_watchdog_check_at FROM investigation_findings WHERE id = ?`,
2061
+ )
2062
+ .get(finding.id) as {
2063
+ recovery_status: string;
2064
+ watchdog_step: number;
2065
+ consecutive_ok_checks: number;
2066
+ next_watchdog_check_at: string | null;
2067
+ };
2068
+ expect(state().next_watchdog_check_at).toBe(
2069
+ new Date(completedAt + 2 * 60_000).toISOString(),
2070
+ );
2071
+
2072
+ for (let i = 0; i < 60; i++) {
2073
+ const t = new Date(completedAt + 30_000 + i * 100).toISOString();
2074
+ insertSpan(db, {
2075
+ traceId: `g_ok_${i}`,
2076
+ spanId: `g_ok_sp_${i}`,
2077
+ serviceName: "checkout-api",
2078
+ spanName: "POST /checkout",
2079
+ statusCode: 1,
2080
+ startTime: t,
2081
+ endTime: t,
2082
+ durationMs: 20,
2083
+ serviceVersion: "v2.4.0",
2084
+ });
2085
+ }
2086
+
2087
+ // Checkpoint 1 runs late (+10m): next checkpoint is still anchored on
2088
+ // completion (+5m), not on "now + offset".
2089
+ const first = await evaluateRemediationWatchdogs(db, {
2090
+ projectId: "default",
2091
+ nowIso: at(60_000 + 10 * 60_000),
2092
+ });
2093
+ expect(first.recovered).toBe(0);
2094
+ expect(state()).toMatchObject({
2095
+ recovery_status: "monitoring",
2096
+ watchdog_step: 2,
2097
+ consecutive_ok_checks: 1,
2098
+ next_watchdog_check_at: new Date(completedAt + 5 * 60_000).toISOString(),
2099
+ });
2100
+
2101
+ const second = await evaluateRemediationWatchdogs(db, {
2102
+ projectId: "default",
2103
+ nowIso: at(60_000 + 11 * 60_000),
2104
+ });
2105
+ expect(second.recovered).toBe(1);
2106
+ expect(state().recovery_status).toBe("recovered");
2107
+ const inc = db.raw
2108
+ .prepare(`SELECT status FROM incident_investigations WHERE id = ?`)
2109
+ .get(incident.id) as { status: string };
2110
+ expect(inc.status).toBe("resolved");
2111
+ db.close();
2112
+ });
2113
+
2114
+ it("B14/B17: routes reject unknown predicates and invalid timestamps", async () => {
2115
+ const db = createMigratedSqliteDb();
2116
+ const { app, env } = createTestApp(db);
2117
+ const post = (path: string, body: unknown) =>
2118
+ app.request(
2119
+ path,
2120
+ {
2121
+ method: "POST",
2122
+ headers: { "Content-Type": "application/json" },
2123
+ body: JSON.stringify(body),
2124
+ },
2125
+ env,
2126
+ );
2127
+
2128
+ const badPredicate = await post("/internal/findings", {
2129
+ title: "t",
2130
+ summary: "s",
2131
+ symptomEntityKind: "trace",
2132
+ symptomEntityId: "t1",
2133
+ faultLocusEntityKind: "service",
2134
+ faultLocusEntityId: SERVICE_NODE,
2135
+ causalChain: [
2136
+ {
2137
+ subjectKind: "trace",
2138
+ subjectId: "t1",
2139
+ predicate: "made_up_predicate",
2140
+ objectKind: "span",
2141
+ objectId: "s1",
2142
+ },
2143
+ ],
2144
+ });
2145
+ expect(badPredicate.status).toBe(400);
2146
+
2147
+ const badDate = await post("/v1/changes", {
2148
+ kind: "deploy",
2149
+ summary: "x",
2150
+ serviceName: "checkout-api",
2151
+ occurredAt: "yesterday-ish",
2152
+ });
2153
+ expect(badDate.status).toBe(400);
2154
+ const badKind = await post("/v1/changes", { kind: "nope", summary: "x" });
2155
+ expect(badKind.status).toBe(400);
2156
+
2157
+ const offset = await post("/v1/changes", {
2158
+ id: "chg_tz",
2159
+ kind: "deploy",
2160
+ summary: "x",
2161
+ serviceName: "checkout-api",
2162
+ environment: "prod",
2163
+ source: "ci_webhook",
2164
+ occurredAt: "2026-10-15T05:00:00+02:00",
2165
+ });
2166
+ expect(offset.status).toBe(201);
2167
+ const created = (await offset.json()) as {
2168
+ changeEvent: { occurredAt: string; source: string };
2169
+ };
2170
+ expect(created.changeEvent.occurredAt).toBe("2026-10-15T03:00:00.000Z");
2171
+ expect(created.changeEvent.source).toBe("ci_webhook");
2172
+
2173
+ const badSince = await app.request(
2174
+ "/internal/changes?since=garbage",
2175
+ {},
2176
+ env,
2177
+ );
2178
+ expect(badSince.status).toBe(400);
2179
+ const okSince = await app.request(
2180
+ "/internal/changes?since=2026-10-15T04:30:00%2B02:00",
2181
+ {},
2182
+ env,
2183
+ );
2184
+ expect(okSince.status).toBe(200);
2185
+ expect(
2186
+ ((await okSince.json()) as { changes: unknown[] }).changes,
2187
+ ).toHaveLength(1);
2188
+ db.close();
2189
+ });
2190
+
2191
+ it("B15: the approver is derived from the session, never from client-supplied identity", async () => {
2192
+ const db = createMigratedSqliteDb();
2193
+ const { app, env } = createTestApp(db);
2194
+ const incident = await newIncident(db);
2195
+ const finding = await newFinding(db, incident.id);
2196
+ const proposal = await propose(db, incident.id, finding.id, "idem_b15");
2197
+ const res = await app.request(
2198
+ `/internal/remediations/${proposal.id}/approve`,
2199
+ {
2200
+ method: "POST",
2201
+ headers: {
2202
+ "Content-Type": "application/json",
2203
+ Cookie: "obs_session=sess_secret_cookie_value",
2204
+ "X-Obs-User-Actor": "vp-of-engineering",
2205
+ },
2206
+ body: JSON.stringify({
2207
+ environment: "prod",
2208
+ approvedByUserId: "cto",
2209
+ approvedBySession: "cfo",
2210
+ }),
2211
+ },
2212
+ env,
2213
+ );
2214
+ expect(res.status).toBe(200);
2215
+ const body = (await res.json()) as {
2216
+ proposal: { approvedByUserId: string };
2217
+ };
2218
+ expect(body.proposal.approvedByUserId).toMatch(
2219
+ /^dashboard_session:[0-9a-f]{16}$/,
2220
+ );
2221
+ db.close();
2222
+ });
2223
+
2224
+ const json = (body: unknown) => ({
2225
+ method: "POST",
2226
+ headers: { "Content-Type": "application/json" },
2227
+ body: JSON.stringify(body),
2228
+ });
2229
+ const approveInit = (body: Record<string, unknown>) => ({
2230
+ method: "POST",
2231
+ headers: {
2232
+ "Content-Type": "application/json",
2233
+ Cookie: "obs_session=sess_lifecycle",
2234
+ },
2235
+ body: JSON.stringify(body),
2236
+ });
2237
+
2238
+ it("J10: span-id fault loci on one service/route share a fingerprint, so the breaker is not fragmented", async () => {
2239
+ const db = createMigratedSqliteDb();
2240
+ await seedIsolatedCohort(db);
2241
+ const incident = await newIncident(db);
2242
+ const spanFinding = (n: number) =>
2243
+ newFinding(db, incident.id, {
2244
+ title: `Checkout regression attempt ${n}`,
2245
+ summary: `Investigation ${n} found errors after the deploy`,
2246
+ contributingChangeId: "chg_gov",
2247
+ faultLocusEntityKind: "span",
2248
+ faultLocusEntityId: `g_new_sp_${n}`,
2249
+ });
2250
+
2251
+ const findings = [];
2252
+ for (let n = 0; n < 4; n++) findings.push(await spanFinding(n));
2253
+ expect(new Set(findings.map((f) => f.fingerprint)).size).toBe(1);
2254
+ expect(findings[0].fingerprintInput.originatingNodeId).toBe(SERVICE_NODE);
2255
+ expect(findings[0].fingerprintInput.normalizedOperation).toContain(
2256
+ "/checkout",
2257
+ );
2258
+
2259
+ for (let n = 0; n < 3; n++) {
2260
+ const granted = await propose(
2261
+ db,
2262
+ incident.id,
2263
+ findings[n].id,
2264
+ `j10_${n}`,
2265
+ );
2266
+ expect(granted.status).toBe("grant_issued");
2267
+ }
2268
+ const blocked = await propose(db, incident.id, findings[3].id, "j10_3");
2269
+ expect(blocked.status).toBe("rejected");
2270
+ expect(blocked.policyDecisionReason).toMatch(/Circuit breaker/);
2271
+
2272
+ // A span that is not in telemetry keeps the legacy behaviour.
2273
+ const unknown = await spanFinding(99);
2274
+ expect(unknown.fingerprint).not.toBe(findings[0].fingerprint);
2275
+ db.close();
2276
+ });
2277
+
2278
+ it("J10: traceId:spanId span loci resolve the service, and empty error signatures fall back to the summary", async () => {
2279
+ const db = createMigratedSqliteDb();
2280
+ const t0 = Date.parse("2026-10-15T06:00:00.000Z");
2281
+ const start = new Date(t0).toISOString();
2282
+ const end = new Date(t0 + 50).toISOString();
2283
+ const traceId = "a".repeat(32);
2284
+ insertSpan(db, {
2285
+ traceId,
2286
+ spanId: "b".repeat(16),
2287
+ serviceName: "pay-api",
2288
+ spanName: "POST /pay",
2289
+ statusCode: 2,
2290
+ statusMessage: "card declined",
2291
+ startTime: start,
2292
+ endTime: end,
2293
+ durationMs: 50,
2294
+ });
2295
+ insertSpan(db, {
2296
+ traceId,
2297
+ spanId: "c".repeat(16),
2298
+ serviceName: "pay-api",
2299
+ spanName: "POST /refund",
2300
+ statusCode: 2,
2301
+ statusMessage: "",
2302
+ errorSignature: "",
2303
+ startTime: start,
2304
+ endTime: end,
2305
+ durationMs: 50,
2306
+ });
2307
+ const incident = await newIncident(db);
2308
+ const mk = (id: string, summary = "Declines after deploy") =>
2309
+ newFinding(db, incident.id, {
2310
+ title: "Pay declines",
2311
+ summary,
2312
+ faultLocusEntityKind: "span",
2313
+ faultLocusEntityId: id,
2314
+ });
2315
+ const bare = await mk("b".repeat(16));
2316
+ const qualified = await mk(`${traceId}:${"b".repeat(16)}`);
2317
+ expect(qualified.fingerprintInput.originatingNodeId).toBe(
2318
+ "service:prod:default:pay-api",
2319
+ );
2320
+ expect(qualified.fingerprint).toBe(bare.fingerprint);
2321
+
2322
+ // No error signature or status message: the summary is the signature.
2323
+ const noSig = await mk("c".repeat(16), "Refunds time out");
2324
+ expect(noSig.fingerprintInput.errorSignature).toBe("Refunds time out");
2325
+ expect(noSig.fingerprintInput.normalizedOperation).toBe("POST /refund");
2326
+ db.close();
2327
+ });
2328
+
2329
+ it("J10: a recurring failure on a different trace surfaces the prior finding by fingerprint", async () => {
2330
+ const db = createMigratedSqliteDb();
2331
+ const { app, env } = createTestApp(db);
2332
+ const t0 = Date.parse("2026-10-15T05:00:00.000Z");
2333
+ for (const [trace, offset] of [
2334
+ ["tr_prior", 0],
2335
+ ["tr_recur", 60_000],
2336
+ ] as const) {
2337
+ const start = new Date(t0 + offset).toISOString();
2338
+ const end = new Date(t0 + offset + 80).toISOString();
2339
+ insertSpan(db, {
2340
+ traceId: trace,
2341
+ spanId: `${trace}_root`,
2342
+ serviceName: "pay-api",
2343
+ spanName: "POST /pay",
2344
+ statusCode: 2,
2345
+ startTime: start,
2346
+ endTime: end,
2347
+ durationMs: 80,
2348
+ });
2349
+ insertSpan(db, {
2350
+ traceId: trace,
2351
+ spanId: `${trace}_err`,
2352
+ parentSpanId: `${trace}_root`,
2353
+ serviceName: "pay-api",
2354
+ spanName: "POST /pay",
2355
+ statusCode: 2,
2356
+ statusMessage: "card declined",
2357
+ startTime: start,
2358
+ endTime: end,
2359
+ durationMs: 70,
2360
+ });
2361
+ }
2362
+ const incident = await newIncident(db);
2363
+ const prior = await newFinding(db, incident.id, {
2364
+ title: "Pay declines",
2365
+ summary: "Provider returns declines",
2366
+ symptomEntityId: "tr_prior",
2367
+ faultLocusEntityKind: "span",
2368
+ faultLocusEntityId: "tr_prior_err",
2369
+ });
2370
+ const bundleFor = async (trace: string) => {
2371
+ const res = await app.request(
2372
+ "/internal/evidence/bundle",
2373
+ json({
2374
+ intent: "triage_incident",
2375
+ anchor: { entityKind: "trace", entityId: trace },
2376
+ }),
2377
+ env,
2378
+ );
2379
+ expect(res.status).toBe(200);
2380
+ return (await res.json()) as {
2381
+ autosre?: { priorFindings?: Array<{ findingId: string }> };
2382
+ };
2383
+ };
2384
+ const recur = await bundleFor("tr_recur");
2385
+ expect(
2386
+ recur.autosre?.priorFindings?.some((f) => f.findingId === prior.id),
2387
+ ).toBe(true);
2388
+ db.close();
2389
+ });
2390
+
2391
+ it("J9: a finding idempotencyKey returns the stored finding on retry and survives route round-trips", async () => {
2392
+ const db = createMigratedSqliteDb();
2393
+ const { app, env } = createTestApp(db);
2394
+ const body = {
2395
+ environment: "prod",
2396
+ idempotencyKey: "retry-me",
2397
+ title: "Idempotent finding",
2398
+ summary: "Same call twice",
2399
+ symptomEntityKind: "trace",
2400
+ symptomEntityId: "t1",
2401
+ faultLocusEntityKind: "service",
2402
+ faultLocusEntityId: SERVICE_NODE,
2403
+ causalChain: [],
2404
+ citedEvidenceIds: [],
2405
+ };
2406
+ const first = await app.request("/internal/findings", json(body), env);
2407
+ const second = await app.request("/internal/findings", json(body), env);
2408
+ expect(first.status).toBe(201);
2409
+ const a = (await first.json()) as { id: string };
2410
+ const b = (await second.json()) as { id: string };
2411
+ expect(b.id).toBe(a.id);
2412
+ const n = db.raw
2413
+ .prepare(`SELECT COUNT(*) AS n FROM investigation_findings`)
2414
+ .get() as { n: number };
2415
+ expect(n.n).toBe(1);
2416
+ // A different key is a different finding.
2417
+ const third = await app.request(
2418
+ "/internal/findings",
2419
+ json({ ...body, idempotencyKey: "other" }),
2420
+ env,
2421
+ );
2422
+ expect(((await third.json()) as { id: string }).id).not.toBe(a.id);
1273
2423
  db.close();
1274
2424
  });
2425
+
2426
+ it("J9: GET /internal/evals/runs/:id reports existence so counterfactual prerequisites can be checked first", async () => {
2427
+ const db = createMigratedSqliteDb();
2428
+ const { app, env } = createTestApp(db);
2429
+ const missing = await app.request("/internal/evals/runs/nope", {}, env);
2430
+ expect(missing.status).toBe(404);
2431
+ db.raw
2432
+ .prepare(
2433
+ `INSERT INTO eval_runs (project_id, id, eval_case_id, status, pass_count, fail_count, created_at)
2434
+ VALUES ('default', 'run_exists', 'case_1', 'completed', 0, 1, ?)`,
2435
+ )
2436
+ .run(new Date().toISOString());
2437
+ const found = await app.request("/internal/evals/runs/run_exists", {}, env);
2438
+ expect(found.status).toBe(200);
2439
+ expect(((await found.json()) as { run: { id: string } }).run.id).toBe(
2440
+ "run_exists",
2441
+ );
2442
+ db.close();
2443
+ });
2444
+
2445
+ it("J17: callback stored at propose time -> approve without callback -> webhook receives the token -> claim succeeds", async () => {
2446
+ const db = createMigratedSqliteDb();
2447
+ const { app, env } = createTestApp(db);
2448
+ const incident = await newIncident(db);
2449
+ const finding = await newFinding(db, incident.id); // corroborated: needs a human
2450
+ const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
2451
+
2452
+ const bodies: Array<Record<string, unknown>> = [];
2453
+ const headers: Array<Record<string, string>> = [];
2454
+ vi.stubGlobal(
2455
+ "fetch",
2456
+ vi.fn(async (_url: string, init: RequestInit) => {
2457
+ bodies.push(JSON.parse(String(init.body)));
2458
+ headers.push(init.headers as Record<string, string>);
2459
+ return new Response("ok", { status: 200 });
2460
+ }),
2461
+ );
2462
+
2463
+ const proposeRes = await app.request(
2464
+ "/internal/remediations/propose",
2465
+ json({
2466
+ environment: "prod",
2467
+ incidentId: incident.id,
2468
+ findingId: finding.id,
2469
+ idempotencyKey: "j17",
2470
+ actionKind: "rollback_deploy",
2471
+ targetNodeId: SERVICE_NODE,
2472
+ expectedCurrentState: {},
2473
+ desiredTargetState: { version: "v2.4.0" },
2474
+ webhookUrl: "https://runner.example.com/grants?token=urlsecret",
2475
+ secret: "hmac-secret-j17",
2476
+ }),
2477
+ callbackEnv,
2478
+ );
2479
+ expect(proposeRes.status).toBe(201);
2480
+ const proposed = (await proposeRes.json()) as {
2481
+ proposal: { id: string; status: string; callbackHost: string };
2482
+ };
2483
+ expect(proposed.proposal.status).toBe("pending_human_approval");
2484
+ expect(proposed.proposal.callbackHost).toBe("runner.example.com");
2485
+ expect(bodies).toHaveLength(0);
2486
+
2487
+ // Neither the URL path/query nor the secret is ever returned by a GET.
2488
+ const getRes = await app.request(
2489
+ `/internal/remediations/${proposed.proposal.id}?environment=prod`,
2490
+ {},
2491
+ callbackEnv,
2492
+ );
2493
+ const getText = await getRes.text();
2494
+ expect(getText).toContain("runner.example.com");
2495
+ expect(getText).not.toContain("urlsecret");
2496
+ expect(getText).not.toContain("hmac-secret-j17");
2497
+ expect(getText).not.toContain("/grants");
2498
+ const list = await (
2499
+ await app.request(
2500
+ "/internal/remediations?environment=prod",
2501
+ {},
2502
+ callbackEnv,
2503
+ )
2504
+ ).text();
2505
+ expect(list).not.toContain("hmac-secret-j17");
2506
+
2507
+ const approve = await app.request(
2508
+ `/internal/remediations/${proposed.proposal.id}/approve`,
2509
+ approveInit({ environment: "prod" }),
2510
+ callbackEnv,
2511
+ );
2512
+ expect(approve.status).toBe(200);
2513
+ const approved = (await approve.json()) as {
2514
+ grantToken: string;
2515
+ webhookDelivery?: { delivered: boolean };
2516
+ proposal: { callbackHost: string | null };
2517
+ };
2518
+ expect(approved.webhookDelivery?.delivered).toBe(true);
2519
+ expect(approved.proposal.callbackHost).toBeNull();
2520
+ expect(bodies).toHaveLength(1);
2521
+ expect(bodies[0]).toMatchObject({
2522
+ event: "autosre.remediation.grant_issued",
2523
+ proposalId: proposed.proposal.id,
2524
+ grantToken: approved.grantToken,
2525
+ });
2526
+ expect(headers[0]["X-Obs-Signature-256"]).toMatch(/^sha256=[0-9a-f]{64}$/);
2527
+
2528
+ const claim = await app.request(
2529
+ `/internal/remediations/${proposed.proposal.id}/claim`,
2530
+ json({
2531
+ environment: "prod",
2532
+ grantToken: approved.grantToken,
2533
+ runnerId: "runner-j17",
2534
+ }),
2535
+ callbackEnv,
2536
+ );
2537
+ expect(claim.status).toBe(200);
2538
+
2539
+ // The stored callback is gone from the row once consumed.
2540
+ const row = db.raw
2541
+ .prepare(
2542
+ `SELECT callback_url, callback_secret FROM remediation_proposals WHERE id = ?`,
2543
+ )
2544
+ .get(proposed.proposal.id) as {
2545
+ callback_url: string | null;
2546
+ callback_secret: string | null;
2547
+ };
2548
+ expect(row.callback_url).toBeNull();
2549
+ expect(row.callback_secret).toBeNull();
2550
+ db.close();
2551
+ });
2552
+
2553
+ it("J17: a callback on the approve request wins, a bad propose callback is a 400, and reject clears the stored one", async () => {
2554
+ const db = createMigratedSqliteDb();
2555
+ const { app, env } = createTestApp(db);
2556
+ const incident = await newIncident(db);
2557
+ const finding = await newFinding(db, incident.id);
2558
+ const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
2559
+ const urls: string[] = [];
2560
+ vi.stubGlobal(
2561
+ "fetch",
2562
+ vi.fn(async (url: string) => {
2563
+ urls.push(url);
2564
+ return new Response("ok", { status: 200 });
2565
+ }),
2566
+ );
2567
+ const mk = (key: string, webhookUrl: string) =>
2568
+ app.request(
2569
+ "/internal/remediations/propose",
2570
+ json({
2571
+ environment: "prod",
2572
+ incidentId: incident.id,
2573
+ findingId: finding.id,
2574
+ idempotencyKey: key,
2575
+ actionKind: "rollback_deploy",
2576
+ targetNodeId: SERVICE_NODE,
2577
+ expectedCurrentState: {},
2578
+ desiredTargetState: {},
2579
+ webhookUrl,
2580
+ }),
2581
+ callbackEnv,
2582
+ );
2583
+ expect((await mk("j17_bad", "ftp://example.com/x")).status).toBe(400);
2584
+
2585
+ const one = (await (
2586
+ await mk("j17_a", "https://stored.example.com/a")
2587
+ ).json()) as {
2588
+ proposal: { id: string };
2589
+ };
2590
+ const approve = await app.request(
2591
+ `/internal/remediations/${one.proposal.id}/approve`,
2592
+ approveInit({
2593
+ environment: "prod",
2594
+ webhookUrl: "https://request.example.com/b",
2595
+ }),
2596
+ callbackEnv,
2597
+ );
2598
+ expect(approve.status).toBe(200);
2599
+ expect(urls).toEqual(["https://request.example.com/b"]);
2600
+
2601
+ const two = (await (
2602
+ await mk("j17_b", "https://stored.example.com/c")
2603
+ ).json()) as {
2604
+ proposal: { id: string };
2605
+ };
2606
+ await rejectRemediationProposal(db, "default", two.proposal.id, {
2607
+ environment: "prod",
2608
+ rejectedByUserId: "tester",
2609
+ });
2610
+ const row = db.raw
2611
+ .prepare(`SELECT callback_url FROM remediation_proposals WHERE id = ?`)
2612
+ .get(two.proposal.id) as { callback_url: string | null };
2613
+ expect(row.callback_url).toBeNull();
2614
+ db.close();
2615
+ });
2616
+
2617
+ describe("grant delivery retries", () => {
2618
+ type Delivery = {
2619
+ attempted: boolean;
2620
+ delivered: boolean;
2621
+ attempts: number;
2622
+ host: string | null;
2623
+ error?: string;
2624
+ };
2625
+ const setup = async (key: string, webhookUrl: string, secret?: string) => {
2626
+ const db = createMigratedSqliteDb();
2627
+ const { app, env } = createTestApp(db);
2628
+ const incident = await newIncident(db);
2629
+ const finding = await newFinding(db, incident.id);
2630
+ const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
2631
+ const proposed = (await (
2632
+ await app.request(
2633
+ "/internal/remediations/propose",
2634
+ json({
2635
+ environment: "prod",
2636
+ incidentId: incident.id,
2637
+ findingId: finding.id,
2638
+ idempotencyKey: key,
2639
+ actionKind: "rollback_deploy",
2640
+ targetNodeId: SERVICE_NODE,
2641
+ expectedCurrentState: {},
2642
+ desiredTargetState: { version: "v2.4.0" },
2643
+ webhookUrl,
2644
+ ...(secret ? { secret } : {}),
2645
+ }),
2646
+ callbackEnv,
2647
+ )
2648
+ ).json()) as { proposal: { id: string } };
2649
+ const approve = async () =>
2650
+ (await (
2651
+ await app.request(
2652
+ `/internal/remediations/${proposed.proposal.id}/approve`,
2653
+ approveInit({ environment: "prod" }),
2654
+ callbackEnv,
2655
+ )
2656
+ ).json()) as {
2657
+ grantToken: string;
2658
+ grantDelivery: Delivery;
2659
+ proposal: { callbackHost: string | null };
2660
+ };
2661
+ const get = async () =>
2662
+ (await (
2663
+ await app.request(
2664
+ `/internal/remediations/${proposed.proposal.id}?environment=prod`,
2665
+ {},
2666
+ callbackEnv,
2667
+ )
2668
+ ).json()) as {
2669
+ proposal: {
2670
+ callbackDelivery: {
2671
+ status: string;
2672
+ attempts: number;
2673
+ lastError: string | null;
2674
+ at: string;
2675
+ } | null;
2676
+ };
2677
+ };
2678
+ return { db, proposed, approve, get };
2679
+ };
2680
+
2681
+ it("delivers on the 2nd attempt after a 503", async () => {
2682
+ const statuses = [503, 200];
2683
+ const fetchMock = vi.fn(
2684
+ async () => new Response("x", { status: statuses.shift() ?? 200 }),
2685
+ );
2686
+ vi.stubGlobal("fetch", fetchMock);
2687
+ const { db, approve, get } = await setup(
2688
+ "retry-503",
2689
+ "https://runner.example.com/g?token=urlsecret",
2690
+ "hmac-retry",
2691
+ );
2692
+ const approved = await approve();
2693
+ expect(fetchMock).toHaveBeenCalledTimes(2);
2694
+ expect(approved.grantDelivery).toEqual({
2695
+ attempted: true,
2696
+ delivered: true,
2697
+ attempts: 2,
2698
+ host: "runner.example.com",
2699
+ });
2700
+ expect(JSON.stringify(approved.grantDelivery)).not.toContain("urlsecret");
2701
+ const got = await get();
2702
+ expect(got.proposal.callbackDelivery).toMatchObject({
2703
+ status: "delivered",
2704
+ attempts: 2,
2705
+ lastError: null,
2706
+ });
2707
+ db.close();
2708
+ }, 15_000);
2709
+
2710
+ it("fails after 3 attempts on a closed port but still returns the token and clears the callback", async () => {
2711
+ vi.unstubAllGlobals();
2712
+ const net = await import("node:net");
2713
+ const port = await new Promise<number>((resolve) => {
2714
+ const srv = net.createServer();
2715
+ srv.listen(0, "127.0.0.1", () => {
2716
+ const { port: p } = srv.address() as { port: number };
2717
+ srv.close(() => resolve(p));
2718
+ });
2719
+ });
2720
+ const { db, proposed, approve, get } = await setup(
2721
+ "retry-closed",
2722
+ `http://127.0.0.1:${port}/grant?token=urlsecret`,
2723
+ "hmac-closed",
2724
+ );
2725
+ const approved = await approve();
2726
+ expect(approved.grantToken).toMatch(/^rgt_/);
2727
+ expect(approved.grantDelivery).toMatchObject({
2728
+ attempted: true,
2729
+ delivered: false,
2730
+ attempts: 3,
2731
+ host: `127.0.0.1:${port}`,
2732
+ });
2733
+ expect(approved.grantDelivery.error).toBeTruthy();
2734
+ expect(JSON.stringify(approved.grantDelivery)).not.toContain("urlsecret");
2735
+ expect(approved.proposal.callbackHost).toBeNull();
2736
+ const row = db.raw
2737
+ .prepare(
2738
+ `SELECT callback_url, callback_secret FROM remediation_proposals WHERE id = ?`,
2739
+ )
2740
+ .get(proposed.proposal.id) as {
2741
+ callback_url: string | null;
2742
+ callback_secret: string | null;
2743
+ };
2744
+ expect(row.callback_url).toBeNull();
2745
+ expect(row.callback_secret).toBeNull();
2746
+ const got = await get();
2747
+ expect(got.proposal.callbackDelivery).toMatchObject({
2748
+ status: "failed",
2749
+ attempts: 3,
2750
+ });
2751
+ expect(got.proposal.callbackDelivery?.lastError).toBeTruthy();
2752
+ db.close();
2753
+ }, 20_000);
2754
+
2755
+ it("does not retry a 4xx", async () => {
2756
+ const fetchMock = vi.fn(async () => new Response("no", { status: 403 }));
2757
+ vi.stubGlobal("fetch", fetchMock);
2758
+ const { db, approve, get } = await setup(
2759
+ "retry-4xx",
2760
+ "https://runner.example.com/g",
2761
+ );
2762
+ const approved = await approve();
2763
+ expect(fetchMock).toHaveBeenCalledTimes(1);
2764
+ expect(approved.grantToken).toMatch(/^rgt_/);
2765
+ expect(approved.grantDelivery).toMatchObject({
2766
+ attempted: true,
2767
+ delivered: false,
2768
+ attempts: 1,
2769
+ error: "HTTP 403",
2770
+ });
2771
+ expect((await get()).proposal.callbackDelivery?.status).toBe("failed");
2772
+ db.close();
2773
+ });
2774
+
2775
+ it("reports attempted=false when no callback exists", async () => {
2776
+ const db = createMigratedSqliteDb();
2777
+ const { app, env } = createTestApp(db);
2778
+ const incident = await newIncident(db);
2779
+ const finding = await newFinding(db, incident.id);
2780
+ const proposed = (await (
2781
+ await app.request(
2782
+ "/internal/remediations/propose",
2783
+ json({
2784
+ environment: "prod",
2785
+ incidentId: incident.id,
2786
+ findingId: finding.id,
2787
+ idempotencyKey: "no-cb",
2788
+ actionKind: "rollback_deploy",
2789
+ targetNodeId: SERVICE_NODE,
2790
+ expectedCurrentState: {},
2791
+ desiredTargetState: {},
2792
+ }),
2793
+ env,
2794
+ )
2795
+ ).json()) as { proposal: { id: string } };
2796
+ const approved = (await (
2797
+ await app.request(
2798
+ `/internal/remediations/${proposed.proposal.id}/approve`,
2799
+ approveInit({ environment: "prod" }),
2800
+ env,
2801
+ )
2802
+ ).json()) as { grantDelivery: Delivery };
2803
+ expect(approved.grantDelivery).toEqual({
2804
+ attempted: false,
2805
+ delivered: false,
2806
+ attempts: 0,
2807
+ host: null,
2808
+ });
2809
+ db.close();
2810
+ });
2811
+ });
1275
2812
  });