@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,7 @@
1
1
  import type {
2
2
  CausalTriple,
3
3
  EvidenceEntityKind,
4
+ GrantCallbackDelivery,
4
5
  IncidentInvestigation,
5
6
  IncidentStatus,
6
7
  RemediationActionKind,
@@ -9,12 +10,18 @@ import type {
9
10
  RemediationProposalStatus,
10
11
  RemediationStateSpec,
11
12
  } from "@obsunified/types";
13
+ import { buildCanonicalNodeId } from "@obsunified/types";
12
14
  import { AgentAutonomyLevel } from "@obsunified/types/constants";
15
+ import { consoleLogger, type Logger } from "../framework/logger";
13
16
  import { getInvestigationFinding } from "./autosre-findings";
14
17
  import { randomHex, sha256Hex } from "./hash";
15
18
  import { parseJsonRecord } from "./json";
16
19
  import type { SqlDb } from "./sql-db";
17
- import { assertSafeWebhookUrl } from "./webhook-url";
20
+ import {
21
+ assertSafeWebhookUrl,
22
+ type HostLookup,
23
+ resolveWebhookHost,
24
+ } from "./webhook-url";
18
25
 
19
26
  interface IncidentRow {
20
27
  project_id: string;
@@ -70,6 +77,89 @@ export const ACTIVE_INCIDENT_STATUSES: ReadonlySet<IncidentStatus> = new Set([
70
77
  "verifying",
71
78
  ]);
72
79
 
80
+ const ACTIVE_STATUS_SQL =
81
+ "('open', 'dispatched', 'leased', 'remediating', 'verifying')";
82
+
83
+ /**
84
+ * Constant-time comparison for equal-purpose hex digests. Length is not secret
85
+ * (always a SHA-256 hex digest), so a length mismatch returns early.
86
+ */
87
+ function constantTimeEqual(a: string, b: string): boolean {
88
+ if (a.length !== b.length) return false;
89
+ let diff = 0;
90
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
91
+ return diff === 0;
92
+ }
93
+
94
+ async function loadProposalRow(
95
+ db: SqlDb,
96
+ projectId: string,
97
+ proposalId: string,
98
+ environment?: string,
99
+ ): Promise<ProposalRow | null> {
100
+ return environment
101
+ ? await db
102
+ .prepare(
103
+ `SELECT * FROM remediation_proposals
104
+ WHERE project_id = ? AND environment = ? AND id = ?`,
105
+ )
106
+ .bind(projectId, environment.toLowerCase(), proposalId)
107
+ .first<ProposalRow>()
108
+ : await db
109
+ .prepare(
110
+ `SELECT * FROM remediation_proposals
111
+ WHERE project_id = ? AND id = ?
112
+ ORDER BY created_at DESC
113
+ LIMIT 1`,
114
+ )
115
+ .bind(projectId, proposalId)
116
+ .first<ProposalRow>();
117
+ }
118
+
119
+ /**
120
+ * Resolves the environment and canonical node id an alert/analysis-triggered
121
+ * incident should target. The environment is taken from the most recent span of
122
+ * the service (rules and analyses carry no environment of their own); with no
123
+ * service or no spans it falls back to `default` and the supplied node id.
124
+ */
125
+ export async function resolveServiceIncidentTarget(
126
+ db: SqlDb,
127
+ projectId: string,
128
+ serviceName: string | null | undefined,
129
+ fallbackNodeId: string,
130
+ logger: Logger = consoleLogger,
131
+ ): Promise<{ environment: string; targetNodeId: string }> {
132
+ const service = serviceName?.trim();
133
+ if (!service) return { environment: "default", targetNodeId: fallbackNodeId };
134
+ let environment = "default";
135
+ try {
136
+ const row = await db
137
+ .prepare(
138
+ `SELECT environment FROM telemetry_spans
139
+ WHERE project_id = ? AND service_name = ?
140
+ ORDER BY received_at DESC
141
+ LIMIT 1`,
142
+ )
143
+ .bind(projectId, service)
144
+ .first<{ environment: string | null }>();
145
+ if (row?.environment) environment = row.environment.trim().toLowerCase();
146
+ } catch (err) {
147
+ logger.warn("[autosre] could not resolve incident environment", {
148
+ projectId,
149
+ service,
150
+ error: err instanceof Error ? err.message : String(err),
151
+ });
152
+ }
153
+ return {
154
+ environment,
155
+ targetNodeId: buildCanonicalNodeId({
156
+ kind: "service",
157
+ environment,
158
+ serviceName: service,
159
+ }).id,
160
+ };
161
+ }
162
+
73
163
  /**
74
164
  * Opens or deduplicates an active `incident_investigation` (RFC 0013 § 6.A & P2 #7).
75
165
  * Multiple firing alerts or webhook retries for the same `(project_id, environment, target_node_id, symptom_category)`
@@ -100,20 +190,23 @@ export async function openOrDeduplicateIncident(
100
190
  `${projectId}::${environment}::${input.targetNodeId}::${category}`,
101
191
  );
102
192
 
103
- const existing = await db
104
- .prepare(
105
- `SELECT * FROM incident_investigations
106
- WHERE project_id = ?
107
- AND environment = ?
108
- AND dedup_key = ?
109
- AND status IN ('open', 'dispatched', 'leased', 'remediating', 'verifying')
110
- ORDER BY opened_at DESC
111
- LIMIT 1`,
112
- )
113
- .bind(projectId, environment, dedupKey)
114
- .first<IncidentRow>();
193
+ const selectActive = () =>
194
+ db
195
+ .prepare(
196
+ `SELECT * FROM incident_investigations
197
+ WHERE project_id = ?
198
+ AND environment = ?
199
+ AND dedup_key = ?
200
+ AND status IN ${ACTIVE_STATUS_SQL}
201
+ ORDER BY opened_at DESC
202
+ LIMIT 1`,
203
+ )
204
+ .bind(projectId, environment, dedupKey)
205
+ .first<IncidentRow>();
115
206
 
116
- if (existing) {
207
+ const coalesceInto = async (
208
+ row: IncidentRow,
209
+ ): Promise<{ incident: IncidentInvestigation; deduplicated: boolean }> => {
117
210
  await db
118
211
  .prepare(
119
212
  `UPDATE incident_investigations
@@ -129,35 +222,39 @@ export async function openOrDeduplicateIncident(
129
222
  nowIso,
130
223
  projectId,
131
224
  environment,
132
- existing.id,
225
+ row.id,
133
226
  )
134
227
  .run();
135
228
 
136
229
  const updated = await getIncidentInvestigation(
137
230
  db,
138
231
  projectId,
139
- existing.id,
232
+ row.id,
140
233
  environment,
141
234
  );
142
235
  return {
143
- incident: updated ?? mapIncidentRow(existing),
236
+ incident: updated ?? mapIncidentRow(row),
144
237
  deduplicated: true,
145
238
  };
146
- }
239
+ };
240
+
241
+ const existing = await selectActive();
242
+ if (existing) return coalesceInto(existing);
147
243
 
148
244
  const id = input.id?.trim() || `inc_${randomHex(10)}`;
149
- const expiresAt = new Date(
150
- Date.now() + 30 * 24 * 3600_000,
151
- ).toISOString();
245
+ const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
152
246
 
153
- await db
247
+ // The partial unique index uq_incidents_active_dedup (migration 051) makes
248
+ // a concurrent duplicate insert a no-op instead of a second active incident.
249
+ const inserted = await db
154
250
  .prepare(
155
251
  `INSERT INTO incident_investigations (
156
252
  project_id, environment, id, dedup_key, status, target_node_id,
157
253
  symptom_anchor_kind, symptom_anchor_id, symptom_fingerprint,
158
254
  alert_count, latest_bundle_json, dispatch_attempts,
159
255
  opened_at, updated_at, expires_at
160
- ) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)`,
256
+ ) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)
257
+ ON CONFLICT DO NOTHING`,
161
258
  )
162
259
  .bind(
163
260
  projectId,
@@ -175,7 +272,22 @@ export async function openOrDeduplicateIncident(
175
272
  )
176
273
  .run();
177
274
 
178
- const created = await getIncidentInvestigation(db, projectId, id, environment);
275
+ if (inserted.meta.changes === 0) {
276
+ // Lost the race to a concurrent opener: coalesce into the winner.
277
+ const winner = await selectActive();
278
+ if (winner) return coalesceInto(winner);
279
+ throw new RemediationGateError(
280
+ `Incident id ${id} already exists in environment ${environment}`,
281
+ 409,
282
+ );
283
+ }
284
+
285
+ const created = await getIncidentInvestigation(
286
+ db,
287
+ projectId,
288
+ id,
289
+ environment,
290
+ );
179
291
  if (!created) {
180
292
  throw new Error(`Failed to load created incident ${id}`);
181
293
  }
@@ -229,7 +341,7 @@ export async function listIncidentInvestigations(
229
341
  conditions.push("status = ?");
230
342
  binds.push(options.status);
231
343
  }
232
- const limit = Math.max(1, Math.min(100, options.limit ?? 50));
344
+ const limit = Math.max(1, Math.min(200, options.limit ?? 50));
233
345
  binds.push(limit);
234
346
 
235
347
  const rows = await db
@@ -285,17 +397,12 @@ export async function acquireIncidentLease(
285
397
  };
286
398
  }
287
399
 
288
- if (
289
- incident.leaseOwnerId &&
290
- incident.leaseOwnerId !== input.runnerId &&
291
- incident.leaseExpiresAt &&
292
- incident.leaseExpiresAt > nowIso
293
- ) {
400
+ if (!ACTIVE_INCIDENT_STATUSES.has(incident.status)) {
294
401
  return {
295
402
  acquired: false,
296
- reason: `Incident ${incidentId} is already leased by runner ${incident.leaseOwnerId} until ${incident.leaseExpiresAt}`,
297
- conflictOwnerId: incident.leaseOwnerId,
298
- leaseExpiresAt: incident.leaseExpiresAt,
403
+ reason: `Incident ${incidentId} is ${incident.status} and can no longer be leased`,
404
+ conflictOwnerId: null,
405
+ leaseExpiresAt: null,
299
406
  };
300
407
  }
301
408
 
@@ -306,28 +413,32 @@ export async function acquireIncidentLease(
306
413
  const leaseExpiresAt = new Date(
307
414
  Date.parse(nowIso) + durationSec * 1000,
308
415
  ).toISOString();
309
- const nextStatus: IncidentStatus =
310
- incident.status === "open" || incident.status === "dispatched"
311
- ? "leased"
312
- : incident.status;
313
416
 
314
- await db
417
+ // The lease precondition lives in the UPDATE itself so two runners racing
418
+ // for the same incident cannot both succeed: only one sees changes === 1.
419
+ const res = await db
315
420
  .prepare(
316
421
  `UPDATE incident_investigations
317
422
  SET lease_owner_id = ?,
318
423
  lease_expires_at = ?,
319
- status = ?,
424
+ status = CASE WHEN status IN ('open', 'dispatched') THEN 'leased' ELSE status END,
320
425
  updated_at = ?
321
- WHERE project_id = ? AND environment = ? AND id = ?`,
426
+ WHERE project_id = ? AND environment = ? AND id = ?
427
+ AND status IN ${ACTIVE_STATUS_SQL}
428
+ AND (lease_owner_id IS NULL
429
+ OR lease_owner_id = ?
430
+ OR lease_expires_at IS NULL
431
+ OR lease_expires_at <= ?)`,
322
432
  )
323
433
  .bind(
324
434
  input.runnerId,
325
435
  leaseExpiresAt,
326
- nextStatus,
327
436
  nowIso,
328
437
  projectId,
329
438
  incident.environment,
330
439
  incident.id,
440
+ input.runnerId,
441
+ nowIso,
331
442
  )
332
443
  .run();
333
444
 
@@ -337,13 +448,20 @@ export async function acquireIncidentLease(
337
448
  incident.id,
338
449
  incident.environment,
339
450
  );
451
+ if (res.meta.changes !== 1) {
452
+ return {
453
+ acquired: false,
454
+ reason: `Incident ${incidentId} is already leased by runner ${updated?.leaseOwnerId ?? "unknown"} until ${updated?.leaseExpiresAt ?? "unknown"}`,
455
+ conflictOwnerId: updated?.leaseOwnerId ?? null,
456
+ leaseExpiresAt: updated?.leaseExpiresAt ?? null,
457
+ };
458
+ }
340
459
  return {
341
460
  acquired: true,
342
461
  incident: updated ?? {
343
462
  ...incident,
344
463
  leaseOwnerId: input.runnerId,
345
464
  leaseExpiresAt,
346
- status: nextStatus,
347
465
  updatedAt: nowIso,
348
466
  },
349
467
  };
@@ -368,9 +486,45 @@ async function computeHmacSha256Hex(
368
486
  return hex;
369
487
  }
370
488
 
489
+ /**
490
+ * Resolves the DNS-classification hook for hosts that can resolve names
491
+ * (Node: `dns.lookup`). Workers cannot reach private networks from `fetch`, so
492
+ * there the hook is left unset. The specifier is built at runtime so bundlers
493
+ * for Workers do not try to resolve `node:dns` statically.
494
+ */
495
+ async function defaultHostLookup(): Promise<HostLookup | undefined> {
496
+ if (typeof process === "undefined" || !process.versions?.node) {
497
+ return undefined;
498
+ }
499
+ try {
500
+ const specifier = "node:dns/promises";
501
+ const dns = (await import(/* @vite-ignore */ specifier)) as {
502
+ lookup: (
503
+ host: string,
504
+ opts: { all: true; verbatim: true },
505
+ ) => Promise<Array<{ address: string }>>;
506
+ };
507
+ return async (hostname) =>
508
+ (await dns.lookup(hostname, { all: true, verbatim: true })).map(
509
+ (a) => a.address,
510
+ );
511
+ } catch {
512
+ return undefined;
513
+ }
514
+ }
515
+
371
516
  /**
372
517
  * SSRF-safe, HMAC-SHA256 signed AutoSRE incident webhook outbox delivery
373
- * (RFC 0013 § 6.A).
518
+ * (RFC 0013 § 6.A). Mirrors the alert webhook delivery: literal host check,
519
+ * DNS classification (fail closed on private/unresolvable), no redirects and
520
+ * a 5s timeout.
521
+ *
522
+ * Grant handoff (RFC 0013 § 6.B): when `webhook.grant` is supplied the payload
523
+ * is an `autosre.remediation.grant_issued` event carrying `proposalId`,
524
+ * `status` and the plaintext `grantToken`. The plaintext token exists only at
525
+ * approve/propose time (the DB keeps its hash), so the caller that mints it
526
+ * must hand it to this function in the same request. Grant deliveries do not
527
+ * count against the incident's 5-attempt dispatch budget.
374
528
  */
375
529
  export async function dispatchIncidentWebhook(
376
530
  db: SqlDb,
@@ -379,25 +533,74 @@ export async function dispatchIncidentWebhook(
379
533
  webhook: {
380
534
  url: string;
381
535
  secret?: string;
536
+ environment?: string;
382
537
  allowPrivateNetworks?: boolean;
538
+ hostLookup?: HostLookup;
383
539
  fetchImpl?: typeof fetch;
540
+ logger?: Logger;
541
+ grant?: { proposalId: string; status: string; grantToken: string };
542
+ /** Test hook: replaces the real backoff sleep between grant attempts. */
543
+ sleep?: (ms: number) => Promise<void>;
384
544
  },
385
- ): Promise<{ delivered: boolean; status?: number; error?: string }> {
386
- const incident = await getIncidentInvestigation(db, projectId, incidentId);
545
+ ): Promise<{
546
+ delivered: boolean;
547
+ status?: number;
548
+ error?: string;
549
+ attempts: number;
550
+ }> {
551
+ const logger = webhook.logger ?? consoleLogger;
552
+ const incident = await getIncidentInvestigation(
553
+ db,
554
+ projectId,
555
+ incidentId,
556
+ webhook.environment,
557
+ );
387
558
  if (!incident) {
388
- return { delivered: false, error: "Incident not found" };
559
+ return { delivered: false, error: "Incident not found", attempts: 0 };
389
560
  }
390
- if (incident.dispatchAttempts >= 5) {
391
- return { delivered: false, error: "MAX_DISPATCH_ATTEMPTS (5) exceeded" };
561
+ if (!webhook.grant && incident.dispatchAttempts >= 5) {
562
+ return {
563
+ delivered: false,
564
+ error: "MAX_DISPATCH_ATTEMPTS (5) exceeded",
565
+ attempts: 0,
566
+ };
392
567
  }
393
568
 
394
- const safeUrl = assertSafeWebhookUrl(webhook.url, {
395
- allowPrivateNetworks: webhook.allowPrivateNetworks,
396
- });
569
+ let safeUrl: URL;
570
+ try {
571
+ safeUrl = assertSafeWebhookUrl(webhook.url, {
572
+ allowPrivateNetworks: webhook.allowPrivateNetworks,
573
+ });
574
+ if (!webhook.allowPrivateNetworks) {
575
+ const lookup = webhook.hostLookup ?? (await defaultHostLookup());
576
+ if (lookup) {
577
+ const resolution = await resolveWebhookHost(safeUrl.hostname, lookup);
578
+ if (resolution === "private") {
579
+ throw new Error(
580
+ `webhook host "${safeUrl.hostname}" resolves to a private address`,
581
+ );
582
+ }
583
+ if (resolution === "unresolvable") {
584
+ throw new Error(
585
+ `webhook host "${safeUrl.hostname}" could not be resolved; refusing to deliver blind`,
586
+ );
587
+ }
588
+ }
589
+ }
590
+ } catch (err) {
591
+ const error = err instanceof Error ? err.message : String(err);
592
+ logger.warn("[autosre] incident webhook target refused", {
593
+ incidentId,
594
+ error,
595
+ });
596
+ return { delivered: false, error, attempts: 0 };
597
+ }
397
598
 
398
599
  const nowIso = new Date().toISOString();
399
600
  const body = JSON.stringify({
400
- event: "autosre.incident.opened",
601
+ event: webhook.grant
602
+ ? "autosre.remediation.grant_issued"
603
+ : "autosre.incident.opened",
401
604
  incidentId: incident.id,
402
605
  projectId: incident.projectId,
403
606
  environment: incident.environment,
@@ -408,6 +611,13 @@ export async function dispatchIncidentWebhook(
408
611
  id: incident.symptomAnchorId,
409
612
  },
410
613
  bundle: incident.latestBundle,
614
+ ...(webhook.grant
615
+ ? {
616
+ proposalId: webhook.grant.proposalId,
617
+ status: webhook.grant.status,
618
+ grantToken: webhook.grant.grantToken,
619
+ }
620
+ : {}),
411
621
  dispatchedAt: nowIso,
412
622
  });
413
623
 
@@ -422,23 +632,59 @@ export async function dispatchIncidentWebhook(
422
632
  }
423
633
 
424
634
  const fetchFn = webhook.fetchImpl ?? fetch;
635
+ const sleep =
636
+ webhook.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
637
+ // Grant handoffs retry on network error, timeout and 5xx (never 4xx or a
638
+ // redirect); each attempt keeps the manual-redirect and 5 s timeout rules.
639
+ const maxAttempts = webhook.grant ? GRANT_DELIVERY_BACKOFF_MS.length + 1 : 1;
425
640
  let delivered = false;
426
641
  let status: number | undefined;
427
642
  let errorMsg: string | undefined;
643
+ let attempts = 0;
644
+
645
+ while (attempts < maxAttempts) {
646
+ attempts++;
647
+ let retryable = false;
648
+ status = undefined;
649
+ errorMsg = undefined;
650
+ try {
651
+ const res = await fetchFn(safeUrl.toString(), {
652
+ method: "POST",
653
+ headers,
654
+ body,
655
+ // Never follow redirects: a 3xx could re-aim the POST (and its
656
+ // signed body, which may carry a grant token) at another host.
657
+ redirect: "manual",
658
+ signal: AbortSignal.timeout(5000),
659
+ });
660
+ status = res.status;
661
+ if (res.status >= 300 && res.status < 400) {
662
+ errorMsg = `Redirect (HTTP ${res.status}) refused`;
663
+ } else {
664
+ delivered = res.ok;
665
+ if (!res.ok) {
666
+ errorMsg = `HTTP ${res.status}`;
667
+ retryable = res.status >= 500;
668
+ }
669
+ }
670
+ } catch (err) {
671
+ errorMsg = err instanceof Error ? err.message : String(err);
672
+ retryable = true;
673
+ }
674
+ if (delivered || !retryable || attempts >= maxAttempts) break;
675
+ await sleep(GRANT_DELIVERY_BACKOFF_MS[attempts - 1]);
676
+ }
428
677
 
429
- try {
430
- const res = await fetchFn(safeUrl.toString(), {
431
- method: "POST",
432
- headers,
433
- body,
678
+ if (!delivered) {
679
+ logger.warn("[autosre] incident webhook delivery failed", {
680
+ incidentId,
681
+ error: errorMsg,
682
+ attempts,
434
683
  });
435
- status = res.status;
436
- delivered = res.ok;
437
- if (!res.ok) {
438
- errorMsg = `HTTP ${res.status}`;
439
- }
440
- } catch (err) {
441
- errorMsg = err instanceof Error ? err.message : String(err);
684
+ }
685
+
686
+ if (webhook.grant) {
687
+ return { delivered, status, error: errorMsg, attempts };
442
688
  }
443
689
 
444
690
  await db
@@ -460,7 +706,37 @@ export async function dispatchIncidentWebhook(
460
706
  )
461
707
  .run();
462
708
 
463
- return { delivered, status, error: errorMsg };
709
+ return { delivered, status, error: errorMsg, attempts };
710
+ }
711
+
712
+ /** Backoff before attempt 2 and 3 of a grant webhook delivery. */
713
+ const GRANT_DELIVERY_BACKOFF_MS = [500, 1500];
714
+
715
+ /**
716
+ * Persists the last grant-webhook delivery outcome on the proposal. The JSON
717
+ * holds only status/attempts/error/time, never the URL or secret.
718
+ */
719
+ export async function recordCallbackDelivery(
720
+ db: SqlDb,
721
+ projectId: string,
722
+ environment: string,
723
+ proposalId: string,
724
+ outcome: { delivered: boolean; attempts: number; error?: string },
725
+ ): Promise<GrantCallbackDelivery> {
726
+ const record: GrantCallbackDelivery = {
727
+ status: outcome.delivered ? "delivered" : "failed",
728
+ attempts: outcome.attempts,
729
+ lastError: outcome.delivered ? null : (outcome.error ?? null),
730
+ at: new Date().toISOString(),
731
+ };
732
+ await db
733
+ .prepare(
734
+ `UPDATE remediation_proposals SET callback_delivery = ?
735
+ WHERE project_id = ? AND environment = ? AND id = ?`,
736
+ )
737
+ .bind(JSON.stringify(record), projectId, environment, proposalId)
738
+ .run();
739
+ return record;
464
740
  }
465
741
 
466
742
  interface ProposalRow {
@@ -488,11 +764,42 @@ interface ProposalRow {
488
764
  remediation_agent_run_id: string | null;
489
765
  resulting_change_id: string | null;
490
766
  failure_reason: string | null;
767
+ callback_url?: string | null;
768
+ callback_secret?: string | null;
769
+ callback_delivery?: string | null;
491
770
  created_at: string;
492
771
  updated_at: string;
493
772
  expires_at: string | null;
494
773
  }
495
774
 
775
+ /** Host (never path, query or credentials) of a stored grant callback URL. */
776
+ function callbackHostOf(url: string | null | undefined): string | null {
777
+ if (!url) return null;
778
+ try {
779
+ return new URL(url).host || null;
780
+ } catch {
781
+ return null;
782
+ }
783
+ }
784
+
785
+ function parseCallbackDelivery(
786
+ raw: string | null | undefined,
787
+ ): GrantCallbackDelivery | null {
788
+ if (!raw) return null;
789
+ try {
790
+ const v = JSON.parse(raw) as Partial<GrantCallbackDelivery>;
791
+ if (v.status !== "delivered" && v.status !== "failed") return null;
792
+ return {
793
+ status: v.status,
794
+ attempts: Number(v.attempts ?? 0),
795
+ lastError: typeof v.lastError === "string" ? v.lastError : null,
796
+ at: String(v.at ?? ""),
797
+ };
798
+ } catch {
799
+ return null;
800
+ }
801
+ }
802
+
496
803
  export const mapProposalRow = (
497
804
  row: ProposalRow,
498
805
  grantToken?: string,
@@ -524,6 +831,8 @@ export const mapProposalRow = (
524
831
  remediationAgentRunId: row.remediation_agent_run_id,
525
832
  resultingChangeId: row.resulting_change_id,
526
833
  failureReason: row.failure_reason,
834
+ callbackHost: callbackHostOf(row.callback_url),
835
+ callbackDelivery: parseCallbackDelivery(row.callback_delivery),
527
836
  createdAt: row.created_at,
528
837
  updatedAt: row.updated_at,
529
838
  expiresAt: row.expires_at,
@@ -542,10 +851,12 @@ function evaluateActionPolicyMatrix(
542
851
  effectiveAutonomy: AgentAutonomyLevel;
543
852
  reason: string;
544
853
  } {
854
+ // `recentProposalsLastHour` counts prior proposals only, so this proposal
855
+ // would be the 4th within the hour once three already exist (max 3/hr).
545
856
  if (recentProposalsLastHour >= 3) {
546
857
  return {
547
858
  effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
548
- reason: `Circuit breaker tripped: ${recentProposalsLastHour} remediation proposals already recorded in the last hour for this target/fingerprint (max 3/hr)`,
859
+ reason: `Circuit breaker tripped: ${recentProposalsLastHour} remediation proposals of this action kind already recorded in the last hour for this finding fingerprint (max 3/hr)`,
549
860
  };
550
861
  }
551
862
 
@@ -630,20 +941,25 @@ export async function proposeRemediationAction(
630
941
  db: SqlDb,
631
942
  projectId: string,
632
943
  input: RemediationProposalInput,
944
+ options: {
945
+ logger?: Logger;
946
+ /** Persisted on a pending proposal and used when it is approved later. */
947
+ callback?: { url: string; secret?: string };
948
+ } = {},
633
949
  ): Promise<RemediationProposal> {
950
+ const logger = options.logger ?? consoleLogger;
634
951
  const nowIso = new Date().toISOString();
635
952
  const environment = (input.environment || "default").trim().toLowerCase();
636
953
 
637
- // 1. Check idempotency key first
638
- const existing = await db
639
- .prepare(
640
- `SELECT * FROM remediation_proposals
641
- WHERE project_id = ? AND environment = ? AND idempotency_key = ?`,
642
- )
643
- .bind(projectId, environment, input.idempotencyKey)
644
- .first<ProposalRow>();
645
-
646
- if (existing) {
954
+ const loadByIdempotencyKey = () =>
955
+ db
956
+ .prepare(
957
+ `SELECT * FROM remediation_proposals
958
+ WHERE project_id = ? AND environment = ? AND idempotency_key = ?`,
959
+ )
960
+ .bind(projectId, environment, input.idempotencyKey)
961
+ .first<ProposalRow>();
962
+ const replayExisting = (existing: ProposalRow): RemediationProposal => {
647
963
  if (
648
964
  existing.action_kind !== input.actionKind ||
649
965
  existing.target_node_id !== input.targetNodeId
@@ -654,6 +970,23 @@ export async function proposeRemediationAction(
654
970
  );
655
971
  }
656
972
  return mapProposalRow(existing);
973
+ };
974
+
975
+ // 1. Check idempotency key first
976
+ const existing = await loadByIdempotencyKey();
977
+ if (existing) return replayExisting(existing);
978
+
979
+ const incident = await getIncidentInvestigation(
980
+ db,
981
+ projectId,
982
+ input.incidentId,
983
+ environment,
984
+ );
985
+ if (!incident) {
986
+ throw new RemediationGateError(
987
+ `Incident ${input.incidentId} not found in environment ${environment}`,
988
+ 404,
989
+ );
657
990
  }
658
991
 
659
992
  const finding = await getInvestigationFinding(
@@ -669,17 +1002,32 @@ export async function proposeRemediationAction(
669
1002
  );
670
1003
  }
671
1004
 
672
- // 2. Circuit breaker count in last 60 minutes
1005
+ // 2. Circuit breaker: proposals of the same action kind for the same finding
1006
+ // fingerprint in the last 60 minutes. Rejected and expired proposals never
1007
+ // executed, so they do not count against the budget.
673
1008
  const oneHourAgo = new Date(Date.now() - 3600_000).toISOString();
674
1009
  const recentCountRow = await db
675
1010
  .prepare(
676
- `SELECT COUNT(*) AS cnt FROM remediation_proposals
677
- WHERE project_id = ?
678
- AND environment = ?
679
- AND target_node_id = ?
680
- AND created_at >= ?`,
1011
+ `SELECT COUNT(*) AS cnt
1012
+ FROM remediation_proposals p
1013
+ JOIN investigation_findings f
1014
+ ON f.project_id = p.project_id
1015
+ AND f.environment = p.environment
1016
+ AND f.id = p.finding_id
1017
+ WHERE p.project_id = ?
1018
+ AND p.environment = ?
1019
+ AND f.fingerprint = ?
1020
+ AND p.action_kind = ?
1021
+ AND p.status NOT IN ('rejected', 'expired')
1022
+ AND p.created_at >= ?`,
1023
+ )
1024
+ .bind(
1025
+ projectId,
1026
+ environment,
1027
+ finding.fingerprint,
1028
+ input.actionKind,
1029
+ oneHourAgo,
681
1030
  )
682
- .bind(projectId, environment, input.targetNodeId, oneHourAgo)
683
1031
  .first<{ cnt: number }>();
684
1032
 
685
1033
  const recentCount = Number(recentCountRow?.cnt ?? 0);
@@ -701,6 +1049,16 @@ export async function proposeRemediationAction(
701
1049
  ) {
702
1050
  effectiveAutonomy = AgentAutonomyLevel.HumanApprovedWrite;
703
1051
  policyReason = `Caller explicitly requested human_approved_write (${policy.reason})`;
1052
+ } else if (
1053
+ (requestedAutonomy === AgentAutonomyLevel.ReadOnly ||
1054
+ requestedAutonomy === AgentAutonomyLevel.SuggestedAction) &&
1055
+ (effectiveAutonomy === AgentAutonomyLevel.AutonomousWrite ||
1056
+ effectiveAutonomy === AgentAutonomyLevel.HumanApprovedWrite)
1057
+ ) {
1058
+ // A caller that only asked to read or suggest never gets a write grant;
1059
+ // a human can still approve the parked proposal explicitly.
1060
+ effectiveAutonomy = requestedAutonomy;
1061
+ policyReason = `Caller requested ${requestedAutonomy}; parked for human approval (${policy.reason})`;
704
1062
  }
705
1063
 
706
1064
  let status: RemediationProposalStatus = "pending_human_approval";
@@ -717,12 +1075,25 @@ export async function proposeRemediationAction(
717
1075
  status = "rejected";
718
1076
  }
719
1077
 
1078
+ // Only a proposal that still awaits a human can use a stored callback; an
1079
+ // autonomous grant is delivered inline by the propose request itself.
1080
+ const storedCallback =
1081
+ status === "pending_human_approval" && options.callback?.url
1082
+ ? options.callback
1083
+ : null;
720
1084
  const id = `rem_${randomHex(10)}`;
721
- const expiresAt = new Date(
722
- Date.now() + 30 * 24 * 3600_000,
723
- ).toISOString();
1085
+ const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
724
1086
 
725
- await db
1087
+ if (effectiveAutonomy === AgentAutonomyLevel.BlockedByPolicy) {
1088
+ logger.warn("[autosre] remediation proposal blocked by policy", {
1089
+ projectId,
1090
+ incidentId: input.incidentId,
1091
+ actionKind: input.actionKind,
1092
+ reason: policyReason,
1093
+ });
1094
+ }
1095
+
1096
+ const insertRes = await db
726
1097
  .prepare(
727
1098
  `INSERT INTO remediation_proposals (
728
1099
  project_id, environment, id, incident_id, finding_id,
@@ -730,8 +1101,10 @@ export async function proposeRemediationAction(
730
1101
  expected_current_state_json, desired_target_state_json,
731
1102
  requested_autonomy, effective_autonomy, policy_decision_reason,
732
1103
  status, grant_token_hash, grant_expires_at,
1104
+ callback_url, callback_secret,
733
1105
  created_at, updated_at, expires_at
734
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1106
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1107
+ ON CONFLICT DO NOTHING`,
735
1108
  )
736
1109
  .bind(
737
1110
  projectId,
@@ -750,12 +1123,25 @@ export async function proposeRemediationAction(
750
1123
  status,
751
1124
  grantTokenHash,
752
1125
  grantExpiresAt,
1126
+ storedCallback?.url ?? null,
1127
+ storedCallback?.secret ?? null,
753
1128
  nowIso,
754
1129
  nowIso,
755
1130
  expiresAt,
756
1131
  )
757
1132
  .run();
758
1133
 
1134
+ if (insertRes.meta.changes === 0) {
1135
+ // A concurrent request with the same idempotency key won the race
1136
+ // (UNIQUE project_id, environment, idempotency_key): replay its row.
1137
+ const winner = await loadByIdempotencyKey();
1138
+ if (winner) return replayExisting(winner);
1139
+ throw new RemediationGateError(
1140
+ `Remediation proposal ${id} conflicted with an existing row`,
1141
+ 409,
1142
+ );
1143
+ }
1144
+
759
1145
  const created = await db
760
1146
  .prepare(
761
1147
  `SELECT * FROM remediation_proposals
@@ -828,7 +1214,7 @@ export async function listRemediationProposals(
828
1214
  binds.push(options.findingId);
829
1215
  }
830
1216
 
831
- const limit = Math.max(1, Math.min(100, options.limit ?? 50));
1217
+ const limit = Math.max(1, Math.min(200, options.limit ?? 50));
832
1218
  binds.push(limit);
833
1219
 
834
1220
  const rows = await db
@@ -844,9 +1230,32 @@ export async function listRemediationProposals(
844
1230
  return rows.results.map((r) => mapProposalRow(r));
845
1231
  }
846
1232
 
1233
+ /**
1234
+ * Reads the grant callback persisted at propose time. Server-side use only:
1235
+ * the secret is never mapped into API responses. Call before
1236
+ * `approveRemediationProposal`, which clears the stored callback.
1237
+ */
1238
+ export async function getRemediationCallback(
1239
+ db: SqlDb,
1240
+ projectId: string,
1241
+ proposalId: string,
1242
+ environment?: string,
1243
+ ): Promise<{ url: string; secret?: string } | null> {
1244
+ const row = await loadProposalRow(db, projectId, proposalId, environment);
1245
+ if (!row?.callback_url) return null;
1246
+ return {
1247
+ url: row.callback_url,
1248
+ ...(row.callback_secret ? { secret: row.callback_secret } : {}),
1249
+ };
1250
+ }
1251
+
847
1252
  /**
848
1253
  * Human dashboard session approval for `pending_human_approval` proposals
849
1254
  * (RFC 0013 § 6.B & P1 #4). Mints a single-use `grantToken`.
1255
+ *
1256
+ * The status precondition lives in the UPDATE's WHERE clause (D1 and Postgres
1257
+ * have no multi-statement transactions beyond `batch`), so two concurrent
1258
+ * approvals cannot both mint a token: only one sees `meta.changes === 1`.
850
1259
  */
851
1260
  export async function approveRemediationProposal(
852
1261
  db: SqlDb,
@@ -857,23 +1266,12 @@ export async function approveRemediationProposal(
857
1266
  approvedByUserId: string;
858
1267
  },
859
1268
  ): Promise<RemediationProposal> {
860
- const row = options.environment
861
- ? await db
862
- .prepare(
863
- `SELECT * FROM remediation_proposals
864
- WHERE project_id = ? AND environment = ? AND id = ?`,
865
- )
866
- .bind(projectId, options.environment.toLowerCase(), proposalId)
867
- .first<ProposalRow>()
868
- : await db
869
- .prepare(
870
- `SELECT * FROM remediation_proposals
871
- WHERE project_id = ? AND id = ?
872
- LIMIT 1`,
873
- )
874
- .bind(projectId, proposalId)
875
- .first<ProposalRow>();
876
-
1269
+ const row = await loadProposalRow(
1270
+ db,
1271
+ projectId,
1272
+ proposalId,
1273
+ options.environment,
1274
+ );
877
1275
  if (!row) {
878
1276
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
879
1277
  }
@@ -889,7 +1287,7 @@ export async function approveRemediationProposal(
889
1287
  const grantTokenHash = await sha256Hex(grantToken);
890
1288
  const grantExpiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
891
1289
 
892
- await db
1290
+ const res = await db
893
1291
  .prepare(
894
1292
  `UPDATE remediation_proposals
895
1293
  SET status = 'grant_issued',
@@ -897,8 +1295,11 @@ export async function approveRemediationProposal(
897
1295
  grant_expires_at = ?,
898
1296
  approved_by_user_id = ?,
899
1297
  approved_at = ?,
1298
+ callback_url = NULL,
1299
+ callback_secret = NULL,
900
1300
  updated_at = ?
901
- WHERE project_id = ? AND environment = ? AND id = ?`,
1301
+ WHERE project_id = ? AND environment = ? AND id = ?
1302
+ AND status = 'pending_human_approval'`,
902
1303
  )
903
1304
  .bind(
904
1305
  grantTokenHash,
@@ -911,6 +1312,12 @@ export async function approveRemediationProposal(
911
1312
  row.id,
912
1313
  )
913
1314
  .run();
1315
+ if (res.meta.changes !== 1) {
1316
+ throw new RemediationGateError(
1317
+ `Proposal ${proposalId} was modified concurrently and can no longer be approved`,
1318
+ 409,
1319
+ );
1320
+ }
914
1321
 
915
1322
  const updated = await db
916
1323
  .prepare(
@@ -933,23 +1340,12 @@ export async function rejectRemediationProposal(
933
1340
  reason?: string;
934
1341
  },
935
1342
  ): Promise<RemediationProposal> {
936
- const row = options.environment
937
- ? await db
938
- .prepare(
939
- `SELECT * FROM remediation_proposals
940
- WHERE project_id = ? AND environment = ? AND id = ?`,
941
- )
942
- .bind(projectId, options.environment.toLowerCase(), proposalId)
943
- .first<ProposalRow>()
944
- : await db
945
- .prepare(
946
- `SELECT * FROM remediation_proposals
947
- WHERE project_id = ? AND id = ?
948
- LIMIT 1`,
949
- )
950
- .bind(projectId, proposalId)
951
- .first<ProposalRow>();
952
-
1343
+ const row = await loadProposalRow(
1344
+ db,
1345
+ projectId,
1346
+ proposalId,
1347
+ options.environment,
1348
+ );
953
1349
  if (!row) {
954
1350
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
955
1351
  }
@@ -968,17 +1364,26 @@ export async function rejectRemediationProposal(
968
1364
  ? `Rejected by ${options.rejectedByUserId}: ${options.reason}`
969
1365
  : `Rejected by ${options.rejectedByUserId}`;
970
1366
 
971
- await db
1367
+ const res = await db
972
1368
  .prepare(
973
1369
  `UPDATE remediation_proposals
974
1370
  SET status = 'rejected',
975
1371
  grant_token_hash = NULL,
1372
+ callback_url = NULL,
1373
+ callback_secret = NULL,
976
1374
  failure_reason = ?,
977
1375
  updated_at = ?
978
- WHERE project_id = ? AND environment = ? AND id = ?`,
1376
+ WHERE project_id = ? AND environment = ? AND id = ?
1377
+ AND status IN ('pending_human_approval', 'grant_issued')`,
979
1378
  )
980
1379
  .bind(reason, nowIso, projectId, row.environment, row.id)
981
1380
  .run();
1381
+ if (res.meta.changes !== 1) {
1382
+ throw new RemediationGateError(
1383
+ `Proposal ${proposalId} was modified concurrently and can no longer be rejected`,
1384
+ 409,
1385
+ );
1386
+ }
982
1387
 
983
1388
  const updated = await db
984
1389
  .prepare(
@@ -994,6 +1399,12 @@ export async function rejectRemediationProposal(
994
1399
  /**
995
1400
  * Single-use grant redemption with optimistic `expectedCurrentState` verification
996
1401
  * against `ontology_nodes` (RFC 0013 § 6.B & P1 #4).
1402
+ *
1403
+ * The status, token-hash and expiry preconditions are part of the claiming
1404
+ * UPDATE, and the outcome is read from `meta.changes`: of N concurrent claims
1405
+ * for one token exactly one sees `changes === 1`; the rest get a 409. The
1406
+ * incident transition rides in the same `batch` and is guarded by an EXISTS on
1407
+ * the winning claim, so a losing claim cannot touch the incident.
997
1408
  */
998
1409
  export async function claimRemediationGrant(
999
1410
  db: SqlDb,
@@ -1004,25 +1415,17 @@ export async function claimRemediationGrant(
1004
1415
  grantToken: string;
1005
1416
  runnerId?: string;
1006
1417
  nowIso?: string;
1418
+ logger?: Logger;
1007
1419
  },
1008
1420
  ): Promise<RemediationProposal> {
1421
+ const logger = options.logger ?? consoleLogger;
1009
1422
  const nowIso = options.nowIso ?? new Date().toISOString();
1010
- const row = options.environment
1011
- ? await db
1012
- .prepare(
1013
- `SELECT * FROM remediation_proposals
1014
- WHERE project_id = ? AND environment = ? AND id = ?`,
1015
- )
1016
- .bind(projectId, options.environment.toLowerCase(), proposalId)
1017
- .first<ProposalRow>()
1018
- : await db
1019
- .prepare(
1020
- `SELECT * FROM remediation_proposals
1021
- WHERE project_id = ? AND id = ?
1022
- LIMIT 1`,
1023
- )
1024
- .bind(projectId, proposalId)
1025
- .first<ProposalRow>();
1423
+ const row = await loadProposalRow(
1424
+ db,
1425
+ projectId,
1426
+ proposalId,
1427
+ options.environment,
1428
+ );
1026
1429
 
1027
1430
  if (!row) {
1028
1431
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
@@ -1037,8 +1440,9 @@ export async function claimRemediationGrant(
1037
1440
  await db
1038
1441
  .prepare(
1039
1442
  `UPDATE remediation_proposals
1040
- SET status = 'expired', updated_at = ?
1041
- WHERE project_id = ? AND environment = ? AND id = ?`,
1443
+ SET status = 'expired', grant_token_hash = NULL, updated_at = ?
1444
+ WHERE project_id = ? AND environment = ? AND id = ?
1445
+ AND status = 'grant_issued'`,
1042
1446
  )
1043
1447
  .bind(nowIso, projectId, row.environment, row.id)
1044
1448
  .run();
@@ -1049,7 +1453,10 @@ export async function claimRemediationGrant(
1049
1453
  }
1050
1454
 
1051
1455
  const incomingHash = await sha256Hex(options.grantToken);
1052
- if (!row.grant_token_hash || incomingHash !== row.grant_token_hash) {
1456
+ if (
1457
+ !row.grant_token_hash ||
1458
+ !constantTimeEqual(incomingHash, row.grant_token_hash)
1459
+ ) {
1053
1460
  throw new RemediationGateError("Invalid grantToken", 403);
1054
1461
  }
1055
1462
 
@@ -1071,43 +1478,35 @@ export async function claimRemediationGrant(
1071
1478
  }>();
1072
1479
 
1073
1480
  if (targetNode) {
1074
- if (
1481
+ const lockFailure =
1075
1482
  expected.version &&
1076
1483
  targetNode.current_version &&
1077
1484
  targetNode.current_version !== expected.version
1078
- ) {
1079
- throw new RemediationGateError(
1080
- `Optimistic version lock failed on ${row.target_node_id}: expected version "${expected.version}", but current version is "${targetNode.current_version}"`,
1081
- 409,
1082
- );
1083
- }
1084
- if (
1085
- expected.commitSha &&
1086
- targetNode.current_commit_sha &&
1087
- targetNode.current_commit_sha !== expected.commitSha
1088
- ) {
1089
- throw new RemediationGateError(
1090
- `Optimistic commit lock failed on ${row.target_node_id}: expected commit "${expected.commitSha}", but current commit is "${targetNode.current_commit_sha}"`,
1091
- 409,
1092
- );
1093
- }
1094
- if (
1095
- expected.configDigest &&
1096
- targetNode.current_config_digest &&
1097
- targetNode.current_config_digest !== expected.configDigest
1098
- ) {
1099
- throw new RemediationGateError(
1100
- `Optimistic config lock failed on ${row.target_node_id}: expected digest "${expected.configDigest}", but current digest is "${targetNode.current_config_digest}"`,
1101
- 409,
1102
- );
1485
+ ? `Optimistic version lock failed on ${row.target_node_id}: expected version "${expected.version}", but current version is "${targetNode.current_version}"`
1486
+ : expected.commitSha &&
1487
+ targetNode.current_commit_sha &&
1488
+ targetNode.current_commit_sha !== expected.commitSha
1489
+ ? `Optimistic commit lock failed on ${row.target_node_id}: expected commit "${expected.commitSha}", but current commit is "${targetNode.current_commit_sha}"`
1490
+ : expected.configDigest &&
1491
+ targetNode.current_config_digest &&
1492
+ targetNode.current_config_digest !== expected.configDigest
1493
+ ? `Optimistic config lock failed on ${row.target_node_id}: expected digest "${expected.configDigest}", but current digest is "${targetNode.current_config_digest}"`
1494
+ : null;
1495
+ if (lockFailure) {
1496
+ logger.warn("[autosre] remediation claim rejected by state lock", {
1497
+ proposalId,
1498
+ error: lockFailure,
1499
+ });
1500
+ throw new RemediationGateError(lockFailure, 409);
1103
1501
  }
1104
1502
  }
1105
1503
 
1106
1504
  const claimExpiresAt = new Date(
1107
1505
  Date.parse(nowIso) + 5 * 60_000,
1108
1506
  ).toISOString();
1507
+ const runnerId = options.runnerId ?? "autosre-runner";
1109
1508
 
1110
- await db.batch([
1509
+ const results = await db.batch([
1111
1510
  db
1112
1511
  .prepare(
1113
1512
  `UPDATE remediation_proposals
@@ -1117,25 +1516,53 @@ export async function claimRemediationGrant(
1117
1516
  claimed_at = ?,
1118
1517
  claim_expires_at = ?,
1119
1518
  updated_at = ?
1120
- WHERE project_id = ? AND environment = ? AND id = ?`,
1519
+ WHERE project_id = ? AND environment = ? AND id = ?
1520
+ AND status = 'grant_issued'
1521
+ AND grant_token_hash = ?
1522
+ AND (grant_expires_at IS NULL OR grant_expires_at > ?)`,
1121
1523
  )
1122
1524
  .bind(
1123
- options.runnerId ?? "autosre-runner",
1525
+ runnerId,
1124
1526
  nowIso,
1125
1527
  claimExpiresAt,
1126
1528
  nowIso,
1127
1529
  projectId,
1128
1530
  row.environment,
1129
1531
  row.id,
1532
+ row.grant_token_hash,
1533
+ nowIso,
1130
1534
  ),
1131
1535
  db
1132
1536
  .prepare(
1133
1537
  `UPDATE incident_investigations
1134
1538
  SET status = 'remediating', updated_at = ?
1135
- WHERE project_id = ? AND environment = ? AND id = ?`,
1539
+ WHERE project_id = ? AND environment = ? AND id = ?
1540
+ AND EXISTS (
1541
+ SELECT 1 FROM remediation_proposals g
1542
+ WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
1543
+ AND g.status = 'claimed_executing'
1544
+ AND g.claimed_by_runner_id = ?
1545
+ AND g.claimed_at = ?
1546
+ )`,
1136
1547
  )
1137
- .bind(nowIso, projectId, row.environment, row.incident_id),
1548
+ .bind(
1549
+ nowIso,
1550
+ projectId,
1551
+ row.environment,
1552
+ row.incident_id,
1553
+ projectId,
1554
+ row.environment,
1555
+ row.id,
1556
+ runnerId,
1557
+ nowIso,
1558
+ ),
1138
1559
  ]);
1560
+ if (results[0]?.meta.changes !== 1) {
1561
+ throw new RemediationGateError(
1562
+ `Grant for proposal ${proposalId} was already claimed or is no longer valid (grants are single-use)`,
1563
+ 409,
1564
+ );
1565
+ }
1139
1566
 
1140
1567
  const updated = await db
1141
1568
  .prepare(
@@ -1151,6 +1578,10 @@ export async function claimRemediationGrant(
1151
1578
  /**
1152
1579
  * Records remediation completion, updates `ontology_nodes` to desired state,
1153
1580
  * and arms the +2m/+5m/+15m post-remediation watchdog on the finding.
1581
+ *
1582
+ * Only the runner that holds the claim may complete, and only while the claim
1583
+ * window is open (`now <= claim_expires_at`); otherwise 409. All follow-up
1584
+ * writes are guarded on the completion UPDATE having applied.
1154
1585
  */
1155
1586
  export async function completeRemediationProposal(
1156
1587
  db: SqlDb,
@@ -1158,6 +1589,7 @@ export async function completeRemediationProposal(
1158
1589
  proposalId: string,
1159
1590
  options: {
1160
1591
  environment?: string;
1592
+ runnerId?: string;
1161
1593
  resultingChangeId?: string | null;
1162
1594
  remediationAgentRunId?: string | null;
1163
1595
  baselineRequestRatePerMin?: number | null;
@@ -1165,22 +1597,12 @@ export async function completeRemediationProposal(
1165
1597
  },
1166
1598
  ): Promise<RemediationProposal> {
1167
1599
  const nowIso = options.nowIso ?? new Date().toISOString();
1168
- const row = options.environment
1169
- ? await db
1170
- .prepare(
1171
- `SELECT * FROM remediation_proposals
1172
- WHERE project_id = ? AND environment = ? AND id = ?`,
1173
- )
1174
- .bind(projectId, options.environment.toLowerCase(), proposalId)
1175
- .first<ProposalRow>()
1176
- : await db
1177
- .prepare(
1178
- `SELECT * FROM remediation_proposals
1179
- WHERE project_id = ? AND id = ?
1180
- LIMIT 1`,
1181
- )
1182
- .bind(projectId, proposalId)
1183
- .first<ProposalRow>();
1600
+ const row = await loadProposalRow(
1601
+ db,
1602
+ projectId,
1603
+ proposalId,
1604
+ options.environment,
1605
+ );
1184
1606
 
1185
1607
  if (!row) {
1186
1608
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
@@ -1191,6 +1613,18 @@ export async function completeRemediationProposal(
1191
1613
  409,
1192
1614
  );
1193
1615
  }
1616
+ if (!options.runnerId || options.runnerId !== row.claimed_by_runner_id) {
1617
+ throw new RemediationGateError(
1618
+ `Proposal ${proposalId} is claimed by a different runner; only the claiming runner may complete it`,
1619
+ 409,
1620
+ );
1621
+ }
1622
+ if (row.claim_expires_at && row.claim_expires_at < nowIso) {
1623
+ throw new RemediationGateError(
1624
+ `Claim on proposal ${proposalId} expired at ${row.claim_expires_at}`,
1625
+ 409,
1626
+ );
1627
+ }
1194
1628
 
1195
1629
  const desired = parseJsonRecord(
1196
1630
  row.desired_target_state_json,
@@ -1233,7 +1667,15 @@ export async function completeRemediationProposal(
1233
1667
  });
1234
1668
  }
1235
1669
 
1236
- await db.batch([
1670
+ // Follow-up writes only apply when the completion UPDATE below won.
1671
+ const completedGuard = `EXISTS (
1672
+ SELECT 1 FROM remediation_proposals g
1673
+ WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
1674
+ AND g.status = 'completed' AND g.updated_at = ?
1675
+ )`;
1676
+ const guardBinds = [projectId, row.environment, row.id, nowIso];
1677
+
1678
+ const results = await db.batch([
1237
1679
  db
1238
1680
  .prepare(
1239
1681
  `UPDATE remediation_proposals
@@ -1241,7 +1683,10 @@ export async function completeRemediationProposal(
1241
1683
  resulting_change_id = COALESCE(?, resulting_change_id),
1242
1684
  remediation_agent_run_id = COALESCE(?, remediation_agent_run_id),
1243
1685
  updated_at = ?
1244
- WHERE project_id = ? AND environment = ? AND id = ?`,
1686
+ WHERE project_id = ? AND environment = ? AND id = ?
1687
+ AND status = 'claimed_executing'
1688
+ AND claimed_by_runner_id = ?
1689
+ AND (claim_expires_at IS NULL OR claim_expires_at >= ?)`,
1245
1690
  )
1246
1691
  .bind(
1247
1692
  options.resultingChangeId ?? null,
@@ -1250,6 +1695,8 @@ export async function completeRemediationProposal(
1250
1695
  projectId,
1251
1696
  row.environment,
1252
1697
  row.id,
1698
+ options.runnerId,
1699
+ nowIso,
1253
1700
  ),
1254
1701
  db
1255
1702
  .prepare(
@@ -1258,7 +1705,8 @@ export async function completeRemediationProposal(
1258
1705
  current_commit_sha = COALESCE(?, current_commit_sha),
1259
1706
  current_config_digest = COALESCE(?, current_config_digest),
1260
1707
  last_seen_at = ?
1261
- WHERE project_id = ? AND environment = ? AND id = ?`,
1708
+ WHERE project_id = ? AND environment = ? AND id = ?
1709
+ AND ${completedGuard}`,
1262
1710
  )
1263
1711
  .bind(
1264
1712
  desired.version ?? null,
@@ -1268,17 +1716,20 @@ export async function completeRemediationProposal(
1268
1716
  projectId,
1269
1717
  row.environment,
1270
1718
  row.target_node_id,
1719
+ ...guardBinds,
1271
1720
  ),
1272
1721
  db
1273
1722
  .prepare(
1274
1723
  `UPDATE investigation_findings
1275
1724
  SET recovery_status = 'monitoring',
1276
1725
  watchdog_step = 1,
1726
+ consecutive_ok_checks = 0,
1277
1727
  baseline_request_rate_per_min = COALESCE(?, baseline_request_rate_per_min),
1278
1728
  next_watchdog_check_at = ?,
1279
1729
  causal_chain_json = ?,
1280
1730
  updated_at = ?
1281
- WHERE project_id = ? AND environment = ? AND id = ?`,
1731
+ WHERE project_id = ? AND environment = ? AND id = ?
1732
+ AND ${completedGuard}`,
1282
1733
  )
1283
1734
  .bind(
1284
1735
  options.baselineRequestRatePerMin ?? null,
@@ -1288,15 +1739,23 @@ export async function completeRemediationProposal(
1288
1739
  projectId,
1289
1740
  row.environment,
1290
1741
  row.finding_id,
1742
+ ...guardBinds,
1291
1743
  ),
1292
1744
  db
1293
1745
  .prepare(
1294
1746
  `UPDATE incident_investigations
1295
1747
  SET status = 'verifying', updated_at = ?
1296
- WHERE project_id = ? AND environment = ? AND id = ?`,
1748
+ WHERE project_id = ? AND environment = ? AND id = ?
1749
+ AND ${completedGuard}`,
1297
1750
  )
1298
- .bind(nowIso, projectId, row.environment, row.incident_id),
1751
+ .bind(nowIso, projectId, row.environment, row.incident_id, ...guardBinds),
1299
1752
  ]);
1753
+ if (results[0]?.meta.changes !== 1) {
1754
+ throw new RemediationGateError(
1755
+ `Proposal ${proposalId} changed state concurrently and can no longer be completed`,
1756
+ 409,
1757
+ );
1758
+ }
1300
1759
 
1301
1760
  const updated = await db
1302
1761
  .prepare(
@@ -1315,50 +1774,82 @@ export async function failRemediationProposal(
1315
1774
  proposalId: string,
1316
1775
  options: {
1317
1776
  environment?: string;
1777
+ runnerId?: string;
1318
1778
  failureReason: string;
1319
1779
  nowIso?: string;
1320
1780
  },
1321
1781
  ): Promise<RemediationProposal> {
1322
1782
  const nowIso = options.nowIso ?? new Date().toISOString();
1323
- const row = options.environment
1324
- ? await db
1325
- .prepare(
1326
- `SELECT * FROM remediation_proposals
1327
- WHERE project_id = ? AND environment = ? AND id = ?`,
1328
- )
1329
- .bind(projectId, options.environment.toLowerCase(), proposalId)
1330
- .first<ProposalRow>()
1331
- : await db
1332
- .prepare(
1333
- `SELECT * FROM remediation_proposals
1334
- WHERE project_id = ? AND id = ?
1335
- LIMIT 1`,
1336
- )
1337
- .bind(projectId, proposalId)
1338
- .first<ProposalRow>();
1783
+ const row = await loadProposalRow(
1784
+ db,
1785
+ projectId,
1786
+ proposalId,
1787
+ options.environment,
1788
+ );
1339
1789
 
1340
1790
  if (!row) {
1341
1791
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
1342
1792
  }
1793
+ if (row.status !== "claimed_executing") {
1794
+ throw new RemediationGateError(
1795
+ `Proposal ${proposalId} cannot be failed from status=${row.status} (must be claimed_executing)`,
1796
+ 409,
1797
+ );
1798
+ }
1799
+ if (!options.runnerId || options.runnerId !== row.claimed_by_runner_id) {
1800
+ throw new RemediationGateError(
1801
+ `Proposal ${proposalId} is claimed by a different runner; only the claiming runner may fail it`,
1802
+ 409,
1803
+ );
1804
+ }
1343
1805
 
1344
- await db.batch([
1806
+ const results = await db.batch([
1345
1807
  db
1346
1808
  .prepare(
1347
1809
  `UPDATE remediation_proposals
1348
1810
  SET status = 'failed_execution',
1349
1811
  failure_reason = ?,
1350
1812
  updated_at = ?
1351
- WHERE project_id = ? AND environment = ? AND id = ?`,
1813
+ WHERE project_id = ? AND environment = ? AND id = ?
1814
+ AND status = 'claimed_executing'
1815
+ AND claimed_by_runner_id = ?`,
1352
1816
  )
1353
- .bind(options.failureReason, nowIso, projectId, row.environment, row.id),
1817
+ .bind(
1818
+ options.failureReason,
1819
+ nowIso,
1820
+ projectId,
1821
+ row.environment,
1822
+ row.id,
1823
+ options.runnerId,
1824
+ ),
1354
1825
  db
1355
1826
  .prepare(
1356
1827
  `UPDATE incident_investigations
1357
1828
  SET status = 'escalated', updated_at = ?
1358
- WHERE project_id = ? AND environment = ? AND id = ?`,
1829
+ WHERE project_id = ? AND environment = ? AND id = ?
1830
+ AND EXISTS (
1831
+ SELECT 1 FROM remediation_proposals g
1832
+ WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
1833
+ AND g.status = 'failed_execution' AND g.updated_at = ?
1834
+ )`,
1359
1835
  )
1360
- .bind(nowIso, projectId, row.environment, row.incident_id),
1836
+ .bind(
1837
+ nowIso,
1838
+ projectId,
1839
+ row.environment,
1840
+ row.incident_id,
1841
+ projectId,
1842
+ row.environment,
1843
+ row.id,
1844
+ nowIso,
1845
+ ),
1361
1846
  ]);
1847
+ if (results[0]?.meta.changes !== 1) {
1848
+ throw new RemediationGateError(
1849
+ `Proposal ${proposalId} changed state concurrently and can no longer be failed`,
1850
+ 409,
1851
+ );
1852
+ }
1362
1853
 
1363
1854
  const updated = await db
1364
1855
  .prepare(
@@ -1378,8 +1869,19 @@ export interface WatchdogEvaluationSummary {
1378
1869
  stillDegraded: number;
1379
1870
  timedOutClaims: number;
1380
1871
  expiredGrants: number;
1872
+ /** Findings whose evaluation threw; logged and retried on the next tick. */
1873
+ failed: number;
1381
1874
  }
1382
1875
 
1876
+ /** Watchdog checkpoints, in minutes after the remediation completed (RFC 0013 § 5.B). */
1877
+ const WATCHDOG_STEP_OFFSET_MINUTES: Record<number, number> = {
1878
+ 1: 2,
1879
+ 2: 5,
1880
+ 3: 15,
1881
+ };
1882
+ /** Consecutive sufficient-traffic healthy checkpoints required for `recovered`. */
1883
+ const REQUIRED_CONSECUTIVE_OK_CHECKS = 2;
1884
+
1383
1885
  /**
1384
1886
  * Post-Remediation Watchdog & Lease/Claim Timeout Evaluator (RFC 0013 § 5.B, § 6.B & P1 #3).
1385
1887
  *
@@ -1387,16 +1889,28 @@ export interface WatchdogEvaluationSummary {
1387
1889
  * - `recovery_status`: whether the service recovered under comparable traffic (`N >= minComparableRequests`)
1388
1890
  * - `causal_verification_status`: `"operationally_corroborated"` (never promoted to `"counterfactual_verified"`
1389
1891
  * by telemetry recovery alone!).
1892
+ *
1893
+ * Checkpoints are scheduled at +2m/+5m/+15m measured from the remediation's
1894
+ * completion time (not from whenever a tick happened to run), and a finding
1895
+ * only becomes `recovered` after two consecutive healthy checkpoints that each
1896
+ * had sufficient traffic. Pass `projectId` to scope the sweep to one tenant
1897
+ * (HTTP path); the cron path omits it and sweeps every project.
1390
1898
  */
1391
1899
  export async function evaluateRemediationWatchdogs(
1392
1900
  db: SqlDb,
1393
1901
  options: {
1394
1902
  nowIso?: string;
1395
1903
  minComparableRequests?: number;
1904
+ projectId?: string;
1905
+ logger?: Logger;
1396
1906
  } = {},
1397
1907
  ): Promise<WatchdogEvaluationSummary> {
1908
+ const logger = options.logger ?? consoleLogger;
1398
1909
  const nowIso = options.nowIso ?? new Date().toISOString();
1399
1910
  const minComparableRequests = options.minComparableRequests ?? 50;
1911
+ const projectId = options.projectId;
1912
+ const projectClause = projectId ? " AND project_id = ?" : "";
1913
+ const projectBinds = projectId ? [projectId] : [];
1400
1914
 
1401
1915
  // 1. Expire unclaimed grants past grant_expires_at
1402
1916
  const expiredGrantsRes = await db
@@ -1405,9 +1919,9 @@ export async function evaluateRemediationWatchdogs(
1405
1919
  SET status = 'expired', grant_token_hash = NULL, updated_at = ?
1406
1920
  WHERE status = 'grant_issued'
1407
1921
  AND grant_expires_at IS NOT NULL
1408
- AND grant_expires_at <= ?`,
1922
+ AND grant_expires_at <= ?${projectClause}`,
1409
1923
  )
1410
- .bind(nowIso, nowIso)
1924
+ .bind(nowIso, nowIso, ...projectBinds)
1411
1925
  .run();
1412
1926
 
1413
1927
  // 2. Time out claimed_executing proposals past claim_expires_at and escalate their incidents
@@ -1417,9 +1931,9 @@ export async function evaluateRemediationWatchdogs(
1417
1931
  FROM remediation_proposals
1418
1932
  WHERE status = 'claimed_executing'
1419
1933
  AND claim_expires_at IS NOT NULL
1420
- AND claim_expires_at <= ?`,
1934
+ AND claim_expires_at <= ?${projectClause}`,
1421
1935
  )
1422
- .bind(nowIso)
1936
+ .bind(nowIso, ...projectBinds)
1423
1937
  .all<{
1424
1938
  project_id: string;
1425
1939
  environment: string;
@@ -1427,25 +1941,42 @@ export async function evaluateRemediationWatchdogs(
1427
1941
  incident_id: string;
1428
1942
  }>();
1429
1943
 
1944
+ let timedOutClaims = 0;
1430
1945
  for (const t of timedOutRows.results) {
1431
- await db.batch([
1946
+ const res = await db.batch([
1432
1947
  db
1433
1948
  .prepare(
1434
1949
  `UPDATE remediation_proposals
1435
1950
  SET status = 'execution_timed_out',
1436
1951
  failure_reason = 'Runner did not complete or fail within 5m claim window',
1437
1952
  updated_at = ?
1438
- WHERE project_id = ? AND environment = ? AND id = ?`,
1953
+ WHERE project_id = ? AND environment = ? AND id = ?
1954
+ AND status = 'claimed_executing'`,
1439
1955
  )
1440
1956
  .bind(nowIso, t.project_id, t.environment, t.id),
1441
1957
  db
1442
1958
  .prepare(
1443
1959
  `UPDATE incident_investigations
1444
1960
  SET status = 'escalated', updated_at = ?
1445
- WHERE project_id = ? AND environment = ? AND id = ?`,
1961
+ WHERE project_id = ? AND environment = ? AND id = ?
1962
+ AND EXISTS (
1963
+ SELECT 1 FROM remediation_proposals g
1964
+ WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
1965
+ AND g.status = 'execution_timed_out' AND g.updated_at = ?
1966
+ )`,
1446
1967
  )
1447
- .bind(nowIso, t.project_id, t.environment, t.incident_id),
1968
+ .bind(
1969
+ nowIso,
1970
+ t.project_id,
1971
+ t.environment,
1972
+ t.incident_id,
1973
+ t.project_id,
1974
+ t.environment,
1975
+ t.id,
1976
+ nowIso,
1977
+ ),
1448
1978
  ]);
1979
+ if (res[0]?.meta.changes === 1) timedOutClaims += 1;
1449
1980
  }
1450
1981
 
1451
1982
  // 3. Evaluate findings in `monitoring` where `next_watchdog_check_at <= nowIso`
@@ -1454,15 +1985,15 @@ export async function evaluateRemediationWatchdogs(
1454
1985
  `SELECT
1455
1986
  project_id, environment, id, incident_id,
1456
1987
  fault_locus_entity_id, root_cause_entity_id,
1457
- watchdog_step, baseline_request_rate_per_min,
1988
+ watchdog_step, consecutive_ok_checks, baseline_request_rate_per_min,
1458
1989
  causal_verification_status, updated_at
1459
1990
  FROM investigation_findings
1460
1991
  WHERE recovery_status = 'monitoring'
1461
1992
  AND next_watchdog_check_at IS NOT NULL
1462
- AND next_watchdog_check_at <= ?
1993
+ AND next_watchdog_check_at <= ?${projectClause}
1463
1994
  LIMIT 50`,
1464
1995
  )
1465
- .bind(nowIso)
1996
+ .bind(nowIso, ...projectBinds)
1466
1997
  .all<{
1467
1998
  project_id: string;
1468
1999
  environment: string;
@@ -1471,6 +2002,7 @@ export async function evaluateRemediationWatchdogs(
1471
2002
  fault_locus_entity_id: string;
1472
2003
  root_cause_entity_id: string | null;
1473
2004
  watchdog_step: number;
2005
+ consecutive_ok_checks: number | null;
1474
2006
  baseline_request_rate_per_min: number | null;
1475
2007
  causal_verification_status: string;
1476
2008
  updated_at: string;
@@ -1479,141 +2011,193 @@ export async function evaluateRemediationWatchdogs(
1479
2011
  let recovered = 0;
1480
2012
  let inconclusiveLowTraffic = 0;
1481
2013
  let stillDegraded = 0;
2014
+ let failed = 0;
1482
2015
 
1483
2016
  for (const f of dueFindings.results) {
1484
- // Find the completed remediation timestamp for this finding
1485
- const completedProposal = await db
1486
- .prepare(
1487
- `SELECT
1488
- p.updated_at,
1489
- p.target_node_id,
1490
- p.resulting_change_id,
1491
- c.occurred_at AS change_occurred_at
1492
- FROM remediation_proposals p
1493
- LEFT JOIN change_events c
1494
- ON c.project_id = p.project_id
1495
- AND c.environment = p.environment
1496
- AND c.id = p.resulting_change_id
1497
- WHERE p.project_id = ? AND p.environment = ? AND p.finding_id = ? AND p.status = 'completed'
1498
- ORDER BY p.updated_at DESC
1499
- LIMIT 1`,
1500
- )
1501
- .bind(f.project_id, f.environment, f.id)
1502
- .first<{
1503
- updated_at: string;
1504
- target_node_id: string;
1505
- resulting_change_id: string | null;
1506
- change_occurred_at: string | null;
1507
- }>();
1508
-
1509
- const sinceTs =
1510
- completedProposal?.change_occurred_at ??
1511
- completedProposal?.updated_at ??
1512
- f.updated_at;
1513
- const targetNodeId =
1514
- completedProposal?.target_node_id ?? f.fault_locus_entity_id;
1515
-
1516
- // Extract service name from canonical service:{env}:{ns}:{serviceName} if applicable
1517
- const parts = targetNodeId.split(":");
1518
- const serviceName = parts[0] === "service" && parts.length >= 4 ? parts.slice(3).join(":") : null;
1519
-
1520
- const stats = serviceName
1521
- ? await db
1522
- .prepare(
1523
- `SELECT
1524
- COUNT(*) AS total_requests,
1525
- SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
1526
- FROM telemetry_spans
1527
- WHERE project_id = ?
1528
- AND environment = ?
1529
- AND service_name = ?
1530
- AND (parent_span_id IS NULL OR span_kind = 1)
1531
- AND (start_time >= ? OR received_at >= ?)`,
1532
- )
1533
- .bind(f.project_id, f.environment, serviceName, sinceTs, sinceTs)
1534
- .first<{ total_requests: number; error_requests: number }>()
1535
- : await db
1536
- .prepare(
1537
- `SELECT
1538
- COUNT(*) AS total_requests,
1539
- SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
1540
- FROM telemetry_spans
1541
- WHERE project_id = ?
1542
- AND environment = ?
1543
- AND (parent_span_id IS NULL OR span_kind = 1)
1544
- AND (start_time >= ? OR received_at >= ?)`,
1545
- )
1546
- .bind(f.project_id, f.environment, sinceTs, sinceTs)
1547
- .first<{ total_requests: number; error_requests: number }>();
1548
-
1549
- const totalReq = Number(stats?.total_requests ?? 0);
1550
- const errorReq = Number(stats?.error_requests ?? 0);
1551
- const errorRate = totalReq > 0 ? errorReq / totalReq : 0;
1552
-
1553
- const requiredTraffic = Math.max(
1554
- minComparableRequests,
1555
- f.baseline_request_rate_per_min
1556
- ? Math.floor(0.5 * f.baseline_request_rate_per_min * 5)
1557
- : minComparableRequests,
1558
- );
1559
-
1560
- if (totalReq < requiredTraffic) {
1561
- if (f.watchdog_step < 3) {
1562
- const nextStep = f.watchdog_step + 1;
1563
- const offsetMins = nextStep === 2 ? 5 : 15;
1564
- const nextCheck = new Date(
1565
- Date.parse(nowIso) + offsetMins * 60_000,
2017
+ try {
2018
+ // Find the completed remediation timestamp for this finding
2019
+ const completedProposal = await db
2020
+ .prepare(
2021
+ `SELECT
2022
+ p.updated_at,
2023
+ p.target_node_id,
2024
+ p.resulting_change_id,
2025
+ c.occurred_at AS change_occurred_at
2026
+ FROM remediation_proposals p
2027
+ LEFT JOIN change_events c
2028
+ ON c.project_id = p.project_id
2029
+ AND c.environment = p.environment
2030
+ AND c.id = p.resulting_change_id
2031
+ WHERE p.project_id = ? AND p.environment = ? AND p.finding_id = ? AND p.status = 'completed'
2032
+ ORDER BY p.updated_at DESC
2033
+ LIMIT 1`,
2034
+ )
2035
+ .bind(f.project_id, f.environment, f.id)
2036
+ .first<{
2037
+ updated_at: string;
2038
+ target_node_id: string;
2039
+ resulting_change_id: string | null;
2040
+ change_occurred_at: string | null;
2041
+ }>();
2042
+
2043
+ // Traffic is measured since the remediation change took effect; the
2044
+ // checkpoint schedule is anchored on when the remediation completed.
2045
+ const sinceTs =
2046
+ completedProposal?.change_occurred_at ??
2047
+ completedProposal?.updated_at ??
2048
+ f.updated_at;
2049
+ const completedAtMs = Date.parse(
2050
+ completedProposal?.updated_at ?? f.updated_at,
2051
+ );
2052
+ const nextCheckFor = (step: number): string =>
2053
+ new Date(
2054
+ completedAtMs + (WATCHDOG_STEP_OFFSET_MINUTES[step] ?? 15) * 60_000,
1566
2055
  ).toISOString();
2056
+ const targetNodeId =
2057
+ completedProposal?.target_node_id ?? f.fault_locus_entity_id;
2058
+
2059
+ // Extract service name from canonical service:{env}:{ns}:{serviceName} if applicable
2060
+ const parts = targetNodeId.split(":");
2061
+ const serviceName =
2062
+ parts[0] === "service" && parts.length >= 4
2063
+ ? parts.slice(3).join(":")
2064
+ : null;
2065
+
2066
+ const stats = serviceName
2067
+ ? await db
2068
+ .prepare(
2069
+ `SELECT
2070
+ COUNT(*) AS total_requests,
2071
+ SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
2072
+ FROM telemetry_spans
2073
+ WHERE project_id = ?
2074
+ AND environment = ?
2075
+ AND service_name = ?
2076
+ AND (parent_span_id IS NULL OR span_kind = 1)
2077
+ AND (start_time >= ? OR received_at >= ?)`,
2078
+ )
2079
+ .bind(f.project_id, f.environment, serviceName, sinceTs, sinceTs)
2080
+ .first<{ total_requests: number; error_requests: number }>()
2081
+ : await db
2082
+ .prepare(
2083
+ `SELECT
2084
+ COUNT(*) AS total_requests,
2085
+ SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
2086
+ FROM telemetry_spans
2087
+ WHERE project_id = ?
2088
+ AND environment = ?
2089
+ AND (parent_span_id IS NULL OR span_kind = 1)
2090
+ AND (start_time >= ? OR received_at >= ?)`,
2091
+ )
2092
+ .bind(f.project_id, f.environment, sinceTs, sinceTs)
2093
+ .first<{ total_requests: number; error_requests: number }>();
2094
+
2095
+ const totalReq = Number(stats?.total_requests ?? 0);
2096
+ const errorReq = Number(stats?.error_requests ?? 0);
2097
+ const errorRate = totalReq > 0 ? errorReq / totalReq : 0;
2098
+
2099
+ const requiredTraffic = Math.max(
2100
+ minComparableRequests,
2101
+ f.baseline_request_rate_per_min
2102
+ ? Math.floor(0.5 * f.baseline_request_rate_per_min * 5)
2103
+ : minComparableRequests,
2104
+ );
2105
+
2106
+ if (totalReq < requiredTraffic) {
2107
+ // Insufficient traffic breaks the consecutive-ok streak.
2108
+ if (f.watchdog_step < 3) {
2109
+ const nextStep = f.watchdog_step + 1;
2110
+ await db
2111
+ .prepare(
2112
+ `UPDATE investigation_findings
2113
+ SET watchdog_step = ?, consecutive_ok_checks = 0,
2114
+ next_watchdog_check_at = ?, updated_at = ?
2115
+ WHERE project_id = ? AND environment = ? AND id = ?`,
2116
+ )
2117
+ .bind(
2118
+ nextStep,
2119
+ nextCheckFor(nextStep),
2120
+ nowIso,
2121
+ f.project_id,
2122
+ f.environment,
2123
+ f.id,
2124
+ )
2125
+ .run();
2126
+ } else {
2127
+ // Step 3 (+15m) with insufficient comparable traffic -> inconclusive_low_traffic
2128
+ inconclusiveLowTraffic += 1;
2129
+ await db
2130
+ .prepare(
2131
+ `UPDATE investigation_findings
2132
+ SET recovery_status = 'inconclusive_low_traffic',
2133
+ consecutive_ok_checks = 0,
2134
+ next_watchdog_check_at = NULL,
2135
+ updated_at = ?
2136
+ WHERE project_id = ? AND environment = ? AND id = ?`,
2137
+ )
2138
+ .bind(nowIso, f.project_id, f.environment, f.id)
2139
+ .run();
2140
+ }
2141
+ continue;
2142
+ }
2143
+
2144
+ if (errorRate >= 0.05) {
2145
+ stillDegraded += 1;
1567
2146
  await db
1568
2147
  .prepare(
1569
2148
  `UPDATE investigation_findings
1570
- SET watchdog_step = ?, next_watchdog_check_at = ?, updated_at = ?
1571
- WHERE project_id = ? AND environment = ? AND id = ?`,
1572
- )
1573
- .bind(nextStep, nextCheck, nowIso, f.project_id, f.environment, f.id)
1574
- .run();
1575
- } else {
1576
- // Step 3 (+15m) with insufficient comparable traffic -> inconclusive_low_traffic
1577
- inconclusiveLowTraffic += 1;
1578
- await db
1579
- .prepare(
1580
- `UPDATE investigation_findings
1581
- SET recovery_status = 'inconclusive_low_traffic',
2149
+ SET recovery_status = 'still_degraded',
2150
+ causal_verification_status = 'refuted',
2151
+ consecutive_ok_checks = 0,
1582
2152
  next_watchdog_check_at = NULL,
1583
2153
  updated_at = ?
1584
2154
  WHERE project_id = ? AND environment = ? AND id = ?`,
1585
2155
  )
1586
2156
  .bind(nowIso, f.project_id, f.environment, f.id)
1587
2157
  .run();
2158
+ if (f.incident_id) {
2159
+ await db
2160
+ .prepare(
2161
+ `UPDATE incident_investigations
2162
+ SET status = 'escalated', updated_at = ?
2163
+ WHERE project_id = ? AND environment = ? AND id = ?`,
2164
+ )
2165
+ .bind(nowIso, f.project_id, f.environment, f.incident_id)
2166
+ .run();
2167
+ }
2168
+ continue;
1588
2169
  }
1589
- continue;
1590
- }
1591
2170
 
1592
- if (errorRate >= 0.05) {
1593
- stillDegraded += 1;
1594
- await db
1595
- .prepare(
1596
- `UPDATE investigation_findings
1597
- SET recovery_status = 'still_degraded',
1598
- causal_verification_status = 'refuted',
1599
- next_watchdog_check_at = NULL,
1600
- updated_at = ?
1601
- WHERE project_id = ? AND environment = ? AND id = ?`,
1602
- )
1603
- .bind(nowIso, f.project_id, f.environment, f.id)
1604
- .run();
1605
- if (f.incident_id) {
2171
+ // Traffic is sufficient AND error rate is healthy (<5%) at this checkpoint.
2172
+ const okChecks = Number(f.consecutive_ok_checks ?? 0) + 1;
2173
+ if (okChecks < REQUIRED_CONSECUTIVE_OK_CHECKS) {
2174
+ // Need one more healthy checkpoint. Advance along the schedule; past
2175
+ // the last step, re-check shortly after now.
2176
+ const nextStep = Math.min(3, f.watchdog_step + 1);
2177
+ const nextCheck =
2178
+ f.watchdog_step < 3
2179
+ ? nextCheckFor(nextStep)
2180
+ : new Date(Date.parse(nowIso) + 5 * 60_000).toISOString();
1606
2181
  await db
1607
2182
  .prepare(
1608
- `UPDATE incident_investigations
1609
- SET status = 'escalated', updated_at = ?
2183
+ `UPDATE investigation_findings
2184
+ SET watchdog_step = ?, consecutive_ok_checks = ?,
2185
+ next_watchdog_check_at = ?, updated_at = ?
1610
2186
  WHERE project_id = ? AND environment = ? AND id = ?`,
1611
2187
  )
1612
- .bind(nowIso, f.project_id, f.environment, f.incident_id)
2188
+ .bind(
2189
+ nextStep,
2190
+ okChecks,
2191
+ nextCheck,
2192
+ nowIso,
2193
+ f.project_id,
2194
+ f.environment,
2195
+ f.id,
2196
+ )
1613
2197
  .run();
2198
+ continue;
1614
2199
  }
1615
- } else {
1616
- // Traffic is sufficient AND error rate recovered (<5%)
2200
+
1617
2201
  recovered += 1;
1618
2202
  const nextCausalVerification =
1619
2203
  f.causal_verification_status === "counterfactual_verified"
@@ -1626,12 +2210,14 @@ export async function evaluateRemediationWatchdogs(
1626
2210
  SET recovery_status = 'recovered',
1627
2211
  causal_verification_status = ?,
1628
2212
  watchdog_step = 3,
2213
+ consecutive_ok_checks = ?,
1629
2214
  next_watchdog_check_at = NULL,
1630
2215
  updated_at = ?
1631
2216
  WHERE project_id = ? AND environment = ? AND id = ?`,
1632
2217
  )
1633
2218
  .bind(
1634
2219
  nextCausalVerification,
2220
+ okChecks,
1635
2221
  nowIso,
1636
2222
  f.project_id,
1637
2223
  f.environment,
@@ -1649,6 +2235,13 @@ export async function evaluateRemediationWatchdogs(
1649
2235
  .bind(nowIso, nowIso, f.project_id, f.environment, f.incident_id)
1650
2236
  .run();
1651
2237
  }
2238
+ } catch (err) {
2239
+ failed += 1;
2240
+ logger.error("[autosre] watchdog evaluation failed for finding", {
2241
+ findingId: f.id,
2242
+ projectId: f.project_id,
2243
+ error: err instanceof Error ? err.message : String(err),
2244
+ });
1652
2245
  }
1653
2246
  }
1654
2247
 
@@ -1657,7 +2250,8 @@ export async function evaluateRemediationWatchdogs(
1657
2250
  recovered,
1658
2251
  inconclusiveLowTraffic,
1659
2252
  stillDegraded,
1660
- timedOutClaims: timedOutRows.results.length,
2253
+ timedOutClaims,
1661
2254
  expiredGrants: expiredGrantsRes.meta.changes,
2255
+ failed,
1662
2256
  };
1663
2257
  }