@obsunified/collector 2.2.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,12 +9,18 @@ import type {
9
9
  RemediationProposalStatus,
10
10
  RemediationStateSpec,
11
11
  } from "@obsunified/types";
12
+ import { buildCanonicalNodeId } from "@obsunified/types";
12
13
  import { AgentAutonomyLevel } from "@obsunified/types/constants";
14
+ import { consoleLogger, type Logger } from "../framework/logger";
13
15
  import { getInvestigationFinding } from "./autosre-findings";
14
16
  import { randomHex, sha256Hex } from "./hash";
15
17
  import { parseJsonRecord } from "./json";
16
18
  import type { SqlDb } from "./sql-db";
17
- import { assertSafeWebhookUrl } from "./webhook-url";
19
+ import {
20
+ assertSafeWebhookUrl,
21
+ type HostLookup,
22
+ resolveWebhookHost,
23
+ } from "./webhook-url";
18
24
 
19
25
  interface IncidentRow {
20
26
  project_id: string;
@@ -70,6 +76,89 @@ export const ACTIVE_INCIDENT_STATUSES: ReadonlySet<IncidentStatus> = new Set([
70
76
  "verifying",
71
77
  ]);
72
78
 
79
+ const ACTIVE_STATUS_SQL =
80
+ "('open', 'dispatched', 'leased', 'remediating', 'verifying')";
81
+
82
+ /**
83
+ * Constant-time comparison for equal-purpose hex digests. Length is not secret
84
+ * (always a SHA-256 hex digest), so a length mismatch returns early.
85
+ */
86
+ function constantTimeEqual(a: string, b: string): boolean {
87
+ if (a.length !== b.length) return false;
88
+ let diff = 0;
89
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
90
+ return diff === 0;
91
+ }
92
+
93
+ async function loadProposalRow(
94
+ db: SqlDb,
95
+ projectId: string,
96
+ proposalId: string,
97
+ environment?: string,
98
+ ): Promise<ProposalRow | null> {
99
+ return environment
100
+ ? await db
101
+ .prepare(
102
+ `SELECT * FROM remediation_proposals
103
+ WHERE project_id = ? AND environment = ? AND id = ?`,
104
+ )
105
+ .bind(projectId, environment.toLowerCase(), proposalId)
106
+ .first<ProposalRow>()
107
+ : await db
108
+ .prepare(
109
+ `SELECT * FROM remediation_proposals
110
+ WHERE project_id = ? AND id = ?
111
+ ORDER BY created_at DESC
112
+ LIMIT 1`,
113
+ )
114
+ .bind(projectId, proposalId)
115
+ .first<ProposalRow>();
116
+ }
117
+
118
+ /**
119
+ * Resolves the environment and canonical node id an alert/analysis-triggered
120
+ * incident should target. The environment is taken from the most recent span of
121
+ * the service (rules and analyses carry no environment of their own); with no
122
+ * service or no spans it falls back to `default` and the supplied node id.
123
+ */
124
+ export async function resolveServiceIncidentTarget(
125
+ db: SqlDb,
126
+ projectId: string,
127
+ serviceName: string | null | undefined,
128
+ fallbackNodeId: string,
129
+ logger: Logger = consoleLogger,
130
+ ): Promise<{ environment: string; targetNodeId: string }> {
131
+ const service = serviceName?.trim();
132
+ if (!service) return { environment: "default", targetNodeId: fallbackNodeId };
133
+ let environment = "default";
134
+ try {
135
+ const row = await db
136
+ .prepare(
137
+ `SELECT environment FROM telemetry_spans
138
+ WHERE project_id = ? AND service_name = ?
139
+ ORDER BY received_at DESC
140
+ LIMIT 1`,
141
+ )
142
+ .bind(projectId, service)
143
+ .first<{ environment: string | null }>();
144
+ if (row?.environment) environment = row.environment.trim().toLowerCase();
145
+ } catch (err) {
146
+ logger.warn("[autosre] could not resolve incident environment", {
147
+ projectId,
148
+ service,
149
+ error: err instanceof Error ? err.message : String(err),
150
+ });
151
+ }
152
+ return {
153
+ environment,
154
+ targetNodeId: buildCanonicalNodeId({
155
+ kind: "service",
156
+ environment,
157
+ serviceName: service,
158
+ }).id,
159
+ };
160
+ }
161
+
73
162
  /**
74
163
  * Opens or deduplicates an active `incident_investigation` (RFC 0013 § 6.A & P2 #7).
75
164
  * Multiple firing alerts or webhook retries for the same `(project_id, environment, target_node_id, symptom_category)`
@@ -100,20 +189,23 @@ export async function openOrDeduplicateIncident(
100
189
  `${projectId}::${environment}::${input.targetNodeId}::${category}`,
101
190
  );
102
191
 
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>();
192
+ const selectActive = () =>
193
+ db
194
+ .prepare(
195
+ `SELECT * FROM incident_investigations
196
+ WHERE project_id = ?
197
+ AND environment = ?
198
+ AND dedup_key = ?
199
+ AND status IN ${ACTIVE_STATUS_SQL}
200
+ ORDER BY opened_at DESC
201
+ LIMIT 1`,
202
+ )
203
+ .bind(projectId, environment, dedupKey)
204
+ .first<IncidentRow>();
115
205
 
116
- if (existing) {
206
+ const coalesceInto = async (
207
+ row: IncidentRow,
208
+ ): Promise<{ incident: IncidentInvestigation; deduplicated: boolean }> => {
117
209
  await db
118
210
  .prepare(
119
211
  `UPDATE incident_investigations
@@ -129,35 +221,39 @@ export async function openOrDeduplicateIncident(
129
221
  nowIso,
130
222
  projectId,
131
223
  environment,
132
- existing.id,
224
+ row.id,
133
225
  )
134
226
  .run();
135
227
 
136
228
  const updated = await getIncidentInvestigation(
137
229
  db,
138
230
  projectId,
139
- existing.id,
231
+ row.id,
140
232
  environment,
141
233
  );
142
234
  return {
143
- incident: updated ?? mapIncidentRow(existing),
235
+ incident: updated ?? mapIncidentRow(row),
144
236
  deduplicated: true,
145
237
  };
146
- }
238
+ };
239
+
240
+ const existing = await selectActive();
241
+ if (existing) return coalesceInto(existing);
147
242
 
148
243
  const id = input.id?.trim() || `inc_${randomHex(10)}`;
149
- const expiresAt = new Date(
150
- Date.now() + 30 * 24 * 3600_000,
151
- ).toISOString();
244
+ const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
152
245
 
153
- await db
246
+ // The partial unique index uq_incidents_active_dedup (migration 051) makes
247
+ // a concurrent duplicate insert a no-op instead of a second active incident.
248
+ const inserted = await db
154
249
  .prepare(
155
250
  `INSERT INTO incident_investigations (
156
251
  project_id, environment, id, dedup_key, status, target_node_id,
157
252
  symptom_anchor_kind, symptom_anchor_id, symptom_fingerprint,
158
253
  alert_count, latest_bundle_json, dispatch_attempts,
159
254
  opened_at, updated_at, expires_at
160
- ) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)`,
255
+ ) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)
256
+ ON CONFLICT DO NOTHING`,
161
257
  )
162
258
  .bind(
163
259
  projectId,
@@ -175,7 +271,22 @@ export async function openOrDeduplicateIncident(
175
271
  )
176
272
  .run();
177
273
 
178
- const created = await getIncidentInvestigation(db, projectId, id, environment);
274
+ if (inserted.meta.changes === 0) {
275
+ // Lost the race to a concurrent opener: coalesce into the winner.
276
+ const winner = await selectActive();
277
+ if (winner) return coalesceInto(winner);
278
+ throw new RemediationGateError(
279
+ `Incident id ${id} already exists in environment ${environment}`,
280
+ 409,
281
+ );
282
+ }
283
+
284
+ const created = await getIncidentInvestigation(
285
+ db,
286
+ projectId,
287
+ id,
288
+ environment,
289
+ );
179
290
  if (!created) {
180
291
  throw new Error(`Failed to load created incident ${id}`);
181
292
  }
@@ -229,7 +340,7 @@ export async function listIncidentInvestigations(
229
340
  conditions.push("status = ?");
230
341
  binds.push(options.status);
231
342
  }
232
- const limit = Math.max(1, Math.min(100, options.limit ?? 50));
343
+ const limit = Math.max(1, Math.min(200, options.limit ?? 50));
233
344
  binds.push(limit);
234
345
 
235
346
  const rows = await db
@@ -285,17 +396,12 @@ export async function acquireIncidentLease(
285
396
  };
286
397
  }
287
398
 
288
- if (
289
- incident.leaseOwnerId &&
290
- incident.leaseOwnerId !== input.runnerId &&
291
- incident.leaseExpiresAt &&
292
- incident.leaseExpiresAt > nowIso
293
- ) {
399
+ if (!ACTIVE_INCIDENT_STATUSES.has(incident.status)) {
294
400
  return {
295
401
  acquired: false,
296
- reason: `Incident ${incidentId} is already leased by runner ${incident.leaseOwnerId} until ${incident.leaseExpiresAt}`,
297
- conflictOwnerId: incident.leaseOwnerId,
298
- leaseExpiresAt: incident.leaseExpiresAt,
402
+ reason: `Incident ${incidentId} is ${incident.status} and can no longer be leased`,
403
+ conflictOwnerId: null,
404
+ leaseExpiresAt: null,
299
405
  };
300
406
  }
301
407
 
@@ -306,28 +412,32 @@ export async function acquireIncidentLease(
306
412
  const leaseExpiresAt = new Date(
307
413
  Date.parse(nowIso) + durationSec * 1000,
308
414
  ).toISOString();
309
- const nextStatus: IncidentStatus =
310
- incident.status === "open" || incident.status === "dispatched"
311
- ? "leased"
312
- : incident.status;
313
415
 
314
- await db
416
+ // The lease precondition lives in the UPDATE itself so two runners racing
417
+ // for the same incident cannot both succeed: only one sees changes === 1.
418
+ const res = await db
315
419
  .prepare(
316
420
  `UPDATE incident_investigations
317
421
  SET lease_owner_id = ?,
318
422
  lease_expires_at = ?,
319
- status = ?,
423
+ status = CASE WHEN status IN ('open', 'dispatched') THEN 'leased' ELSE status END,
320
424
  updated_at = ?
321
- WHERE project_id = ? AND environment = ? AND id = ?`,
425
+ WHERE project_id = ? AND environment = ? AND id = ?
426
+ AND status IN ${ACTIVE_STATUS_SQL}
427
+ AND (lease_owner_id IS NULL
428
+ OR lease_owner_id = ?
429
+ OR lease_expires_at IS NULL
430
+ OR lease_expires_at <= ?)`,
322
431
  )
323
432
  .bind(
324
433
  input.runnerId,
325
434
  leaseExpiresAt,
326
- nextStatus,
327
435
  nowIso,
328
436
  projectId,
329
437
  incident.environment,
330
438
  incident.id,
439
+ input.runnerId,
440
+ nowIso,
331
441
  )
332
442
  .run();
333
443
 
@@ -337,13 +447,20 @@ export async function acquireIncidentLease(
337
447
  incident.id,
338
448
  incident.environment,
339
449
  );
450
+ if (res.meta.changes !== 1) {
451
+ return {
452
+ acquired: false,
453
+ reason: `Incident ${incidentId} is already leased by runner ${updated?.leaseOwnerId ?? "unknown"} until ${updated?.leaseExpiresAt ?? "unknown"}`,
454
+ conflictOwnerId: updated?.leaseOwnerId ?? null,
455
+ leaseExpiresAt: updated?.leaseExpiresAt ?? null,
456
+ };
457
+ }
340
458
  return {
341
459
  acquired: true,
342
460
  incident: updated ?? {
343
461
  ...incident,
344
462
  leaseOwnerId: input.runnerId,
345
463
  leaseExpiresAt,
346
- status: nextStatus,
347
464
  updatedAt: nowIso,
348
465
  },
349
466
  };
@@ -368,9 +485,45 @@ async function computeHmacSha256Hex(
368
485
  return hex;
369
486
  }
370
487
 
488
+ /**
489
+ * Resolves the DNS-classification hook for hosts that can resolve names
490
+ * (Node: `dns.lookup`). Workers cannot reach private networks from `fetch`, so
491
+ * there the hook is left unset. The specifier is built at runtime so bundlers
492
+ * for Workers do not try to resolve `node:dns` statically.
493
+ */
494
+ async function defaultHostLookup(): Promise<HostLookup | undefined> {
495
+ if (typeof process === "undefined" || !process.versions?.node) {
496
+ return undefined;
497
+ }
498
+ try {
499
+ const specifier = "node:dns/promises";
500
+ const dns = (await import(/* @vite-ignore */ specifier)) as {
501
+ lookup: (
502
+ host: string,
503
+ opts: { all: true; verbatim: true },
504
+ ) => Promise<Array<{ address: string }>>;
505
+ };
506
+ return async (hostname) =>
507
+ (await dns.lookup(hostname, { all: true, verbatim: true })).map(
508
+ (a) => a.address,
509
+ );
510
+ } catch {
511
+ return undefined;
512
+ }
513
+ }
514
+
371
515
  /**
372
516
  * SSRF-safe, HMAC-SHA256 signed AutoSRE incident webhook outbox delivery
373
- * (RFC 0013 § 6.A).
517
+ * (RFC 0013 § 6.A). Mirrors the alert webhook delivery: literal host check,
518
+ * DNS classification (fail closed on private/unresolvable), no redirects and
519
+ * a 5s timeout.
520
+ *
521
+ * Grant handoff (RFC 0013 § 6.B): when `webhook.grant` is supplied the payload
522
+ * is an `autosre.remediation.grant_issued` event carrying `proposalId`,
523
+ * `status` and the plaintext `grantToken`. The plaintext token exists only at
524
+ * approve/propose time (the DB keeps its hash), so the caller that mints it
525
+ * must hand it to this function in the same request. Grant deliveries do not
526
+ * count against the incident's 5-attempt dispatch budget.
374
527
  */
375
528
  export async function dispatchIncidentWebhook(
376
529
  db: SqlDb,
@@ -379,25 +532,63 @@ export async function dispatchIncidentWebhook(
379
532
  webhook: {
380
533
  url: string;
381
534
  secret?: string;
535
+ environment?: string;
382
536
  allowPrivateNetworks?: boolean;
537
+ hostLookup?: HostLookup;
383
538
  fetchImpl?: typeof fetch;
539
+ logger?: Logger;
540
+ grant?: { proposalId: string; status: string; grantToken: string };
384
541
  },
385
542
  ): Promise<{ delivered: boolean; status?: number; error?: string }> {
386
- const incident = await getIncidentInvestigation(db, projectId, incidentId);
543
+ const logger = webhook.logger ?? consoleLogger;
544
+ const incident = await getIncidentInvestigation(
545
+ db,
546
+ projectId,
547
+ incidentId,
548
+ webhook.environment,
549
+ );
387
550
  if (!incident) {
388
551
  return { delivered: false, error: "Incident not found" };
389
552
  }
390
- if (incident.dispatchAttempts >= 5) {
553
+ if (!webhook.grant && incident.dispatchAttempts >= 5) {
391
554
  return { delivered: false, error: "MAX_DISPATCH_ATTEMPTS (5) exceeded" };
392
555
  }
393
556
 
394
- const safeUrl = assertSafeWebhookUrl(webhook.url, {
395
- allowPrivateNetworks: webhook.allowPrivateNetworks,
396
- });
557
+ let safeUrl: URL;
558
+ try {
559
+ safeUrl = assertSafeWebhookUrl(webhook.url, {
560
+ allowPrivateNetworks: webhook.allowPrivateNetworks,
561
+ });
562
+ if (!webhook.allowPrivateNetworks) {
563
+ const lookup = webhook.hostLookup ?? (await defaultHostLookup());
564
+ if (lookup) {
565
+ const resolution = await resolveWebhookHost(safeUrl.hostname, lookup);
566
+ if (resolution === "private") {
567
+ throw new Error(
568
+ `webhook host "${safeUrl.hostname}" resolves to a private address`,
569
+ );
570
+ }
571
+ if (resolution === "unresolvable") {
572
+ throw new Error(
573
+ `webhook host "${safeUrl.hostname}" could not be resolved; refusing to deliver blind`,
574
+ );
575
+ }
576
+ }
577
+ }
578
+ } catch (err) {
579
+ const error = err instanceof Error ? err.message : String(err);
580
+ logger.warn("[autosre] incident webhook target refused", {
581
+ incidentId,
582
+ error,
583
+ });
584
+ return { delivered: false, error };
585
+ }
397
586
 
398
587
  const nowIso = new Date().toISOString();
399
588
  const body = JSON.stringify({
400
- event: "autosre.incident.opened",
589
+ event: webhook.grant
590
+ ? "autosre.remediation.grant_issued"
591
+ : "autosre.incident.opened",
401
592
  incidentId: incident.id,
402
593
  projectId: incident.projectId,
403
594
  environment: incident.environment,
@@ -408,6 +599,13 @@ export async function dispatchIncidentWebhook(
408
599
  id: incident.symptomAnchorId,
409
600
  },
410
601
  bundle: incident.latestBundle,
602
+ ...(webhook.grant
603
+ ? {
604
+ proposalId: webhook.grant.proposalId,
605
+ status: webhook.grant.status,
606
+ grantToken: webhook.grant.grantToken,
607
+ }
608
+ : {}),
411
609
  dispatchedAt: nowIso,
412
610
  });
413
611
 
@@ -431,16 +629,33 @@ export async function dispatchIncidentWebhook(
431
629
  method: "POST",
432
630
  headers,
433
631
  body,
632
+ // Never follow redirects: a 3xx could re-aim the POST (and its
633
+ // signed body, which may carry a grant token) at another host.
634
+ redirect: "manual",
635
+ signal: AbortSignal.timeout(5000),
434
636
  });
435
637
  status = res.status;
436
- delivered = res.ok;
437
- if (!res.ok) {
438
- errorMsg = `HTTP ${res.status}`;
638
+ if (res.status >= 300 && res.status < 400) {
639
+ errorMsg = `Redirect (HTTP ${res.status}) refused`;
640
+ } else {
641
+ delivered = res.ok;
642
+ if (!res.ok) errorMsg = `HTTP ${res.status}`;
439
643
  }
440
644
  } catch (err) {
441
645
  errorMsg = err instanceof Error ? err.message : String(err);
442
646
  }
443
647
 
648
+ if (!delivered) {
649
+ logger.warn("[autosre] incident webhook delivery failed", {
650
+ incidentId,
651
+ error: errorMsg,
652
+ });
653
+ }
654
+
655
+ if (webhook.grant) {
656
+ return { delivered, status, error: errorMsg };
657
+ }
658
+
444
659
  await db
445
660
  .prepare(
446
661
  `UPDATE incident_investigations
@@ -542,10 +757,12 @@ function evaluateActionPolicyMatrix(
542
757
  effectiveAutonomy: AgentAutonomyLevel;
543
758
  reason: string;
544
759
  } {
760
+ // `recentProposalsLastHour` counts prior proposals only, so this proposal
761
+ // would be the 4th within the hour once three already exist (max 3/hr).
545
762
  if (recentProposalsLastHour >= 3) {
546
763
  return {
547
764
  effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
548
- reason: `Circuit breaker tripped: ${recentProposalsLastHour} remediation proposals already recorded in the last hour for this target/fingerprint (max 3/hr)`,
765
+ 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
766
  };
550
767
  }
551
768
 
@@ -630,20 +847,21 @@ export async function proposeRemediationAction(
630
847
  db: SqlDb,
631
848
  projectId: string,
632
849
  input: RemediationProposalInput,
850
+ options: { logger?: Logger } = {},
633
851
  ): Promise<RemediationProposal> {
852
+ const logger = options.logger ?? consoleLogger;
634
853
  const nowIso = new Date().toISOString();
635
854
  const environment = (input.environment || "default").trim().toLowerCase();
636
855
 
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) {
856
+ const loadByIdempotencyKey = () =>
857
+ db
858
+ .prepare(
859
+ `SELECT * FROM remediation_proposals
860
+ WHERE project_id = ? AND environment = ? AND idempotency_key = ?`,
861
+ )
862
+ .bind(projectId, environment, input.idempotencyKey)
863
+ .first<ProposalRow>();
864
+ const replayExisting = (existing: ProposalRow): RemediationProposal => {
647
865
  if (
648
866
  existing.action_kind !== input.actionKind ||
649
867
  existing.target_node_id !== input.targetNodeId
@@ -654,6 +872,23 @@ export async function proposeRemediationAction(
654
872
  );
655
873
  }
656
874
  return mapProposalRow(existing);
875
+ };
876
+
877
+ // 1. Check idempotency key first
878
+ const existing = await loadByIdempotencyKey();
879
+ if (existing) return replayExisting(existing);
880
+
881
+ const incident = await getIncidentInvestigation(
882
+ db,
883
+ projectId,
884
+ input.incidentId,
885
+ environment,
886
+ );
887
+ if (!incident) {
888
+ throw new RemediationGateError(
889
+ `Incident ${input.incidentId} not found in environment ${environment}`,
890
+ 404,
891
+ );
657
892
  }
658
893
 
659
894
  const finding = await getInvestigationFinding(
@@ -669,17 +904,32 @@ export async function proposeRemediationAction(
669
904
  );
670
905
  }
671
906
 
672
- // 2. Circuit breaker count in last 60 minutes
907
+ // 2. Circuit breaker: proposals of the same action kind for the same finding
908
+ // fingerprint in the last 60 minutes. Rejected and expired proposals never
909
+ // executed, so they do not count against the budget.
673
910
  const oneHourAgo = new Date(Date.now() - 3600_000).toISOString();
674
911
  const recentCountRow = await db
675
912
  .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 >= ?`,
913
+ `SELECT COUNT(*) AS cnt
914
+ FROM remediation_proposals p
915
+ JOIN investigation_findings f
916
+ ON f.project_id = p.project_id
917
+ AND f.environment = p.environment
918
+ AND f.id = p.finding_id
919
+ WHERE p.project_id = ?
920
+ AND p.environment = ?
921
+ AND f.fingerprint = ?
922
+ AND p.action_kind = ?
923
+ AND p.status NOT IN ('rejected', 'expired')
924
+ AND p.created_at >= ?`,
925
+ )
926
+ .bind(
927
+ projectId,
928
+ environment,
929
+ finding.fingerprint,
930
+ input.actionKind,
931
+ oneHourAgo,
681
932
  )
682
- .bind(projectId, environment, input.targetNodeId, oneHourAgo)
683
933
  .first<{ cnt: number }>();
684
934
 
685
935
  const recentCount = Number(recentCountRow?.cnt ?? 0);
@@ -701,6 +951,16 @@ export async function proposeRemediationAction(
701
951
  ) {
702
952
  effectiveAutonomy = AgentAutonomyLevel.HumanApprovedWrite;
703
953
  policyReason = `Caller explicitly requested human_approved_write (${policy.reason})`;
954
+ } else if (
955
+ (requestedAutonomy === AgentAutonomyLevel.ReadOnly ||
956
+ requestedAutonomy === AgentAutonomyLevel.SuggestedAction) &&
957
+ (effectiveAutonomy === AgentAutonomyLevel.AutonomousWrite ||
958
+ effectiveAutonomy === AgentAutonomyLevel.HumanApprovedWrite)
959
+ ) {
960
+ // A caller that only asked to read or suggest never gets a write grant;
961
+ // a human can still approve the parked proposal explicitly.
962
+ effectiveAutonomy = requestedAutonomy;
963
+ policyReason = `Caller requested ${requestedAutonomy}; parked for human approval (${policy.reason})`;
704
964
  }
705
965
 
706
966
  let status: RemediationProposalStatus = "pending_human_approval";
@@ -718,11 +978,18 @@ export async function proposeRemediationAction(
718
978
  }
719
979
 
720
980
  const id = `rem_${randomHex(10)}`;
721
- const expiresAt = new Date(
722
- Date.now() + 30 * 24 * 3600_000,
723
- ).toISOString();
981
+ const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
724
982
 
725
- await db
983
+ if (effectiveAutonomy === AgentAutonomyLevel.BlockedByPolicy) {
984
+ logger.warn("[autosre] remediation proposal blocked by policy", {
985
+ projectId,
986
+ incidentId: input.incidentId,
987
+ actionKind: input.actionKind,
988
+ reason: policyReason,
989
+ });
990
+ }
991
+
992
+ const insertRes = await db
726
993
  .prepare(
727
994
  `INSERT INTO remediation_proposals (
728
995
  project_id, environment, id, incident_id, finding_id,
@@ -731,7 +998,8 @@ export async function proposeRemediationAction(
731
998
  requested_autonomy, effective_autonomy, policy_decision_reason,
732
999
  status, grant_token_hash, grant_expires_at,
733
1000
  created_at, updated_at, expires_at
734
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1001
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1002
+ ON CONFLICT DO NOTHING`,
735
1003
  )
736
1004
  .bind(
737
1005
  projectId,
@@ -756,6 +1024,17 @@ export async function proposeRemediationAction(
756
1024
  )
757
1025
  .run();
758
1026
 
1027
+ if (insertRes.meta.changes === 0) {
1028
+ // A concurrent request with the same idempotency key won the race
1029
+ // (UNIQUE project_id, environment, idempotency_key): replay its row.
1030
+ const winner = await loadByIdempotencyKey();
1031
+ if (winner) return replayExisting(winner);
1032
+ throw new RemediationGateError(
1033
+ `Remediation proposal ${id} conflicted with an existing row`,
1034
+ 409,
1035
+ );
1036
+ }
1037
+
759
1038
  const created = await db
760
1039
  .prepare(
761
1040
  `SELECT * FROM remediation_proposals
@@ -828,7 +1107,7 @@ export async function listRemediationProposals(
828
1107
  binds.push(options.findingId);
829
1108
  }
830
1109
 
831
- const limit = Math.max(1, Math.min(100, options.limit ?? 50));
1110
+ const limit = Math.max(1, Math.min(200, options.limit ?? 50));
832
1111
  binds.push(limit);
833
1112
 
834
1113
  const rows = await db
@@ -847,6 +1126,10 @@ export async function listRemediationProposals(
847
1126
  /**
848
1127
  * Human dashboard session approval for `pending_human_approval` proposals
849
1128
  * (RFC 0013 § 6.B & P1 #4). Mints a single-use `grantToken`.
1129
+ *
1130
+ * The status precondition lives in the UPDATE's WHERE clause (D1 and Postgres
1131
+ * have no multi-statement transactions beyond `batch`), so two concurrent
1132
+ * approvals cannot both mint a token: only one sees `meta.changes === 1`.
850
1133
  */
851
1134
  export async function approveRemediationProposal(
852
1135
  db: SqlDb,
@@ -857,23 +1140,12 @@ export async function approveRemediationProposal(
857
1140
  approvedByUserId: string;
858
1141
  },
859
1142
  ): 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
-
1143
+ const row = await loadProposalRow(
1144
+ db,
1145
+ projectId,
1146
+ proposalId,
1147
+ options.environment,
1148
+ );
877
1149
  if (!row) {
878
1150
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
879
1151
  }
@@ -889,7 +1161,7 @@ export async function approveRemediationProposal(
889
1161
  const grantTokenHash = await sha256Hex(grantToken);
890
1162
  const grantExpiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
891
1163
 
892
- await db
1164
+ const res = await db
893
1165
  .prepare(
894
1166
  `UPDATE remediation_proposals
895
1167
  SET status = 'grant_issued',
@@ -898,7 +1170,8 @@ export async function approveRemediationProposal(
898
1170
  approved_by_user_id = ?,
899
1171
  approved_at = ?,
900
1172
  updated_at = ?
901
- WHERE project_id = ? AND environment = ? AND id = ?`,
1173
+ WHERE project_id = ? AND environment = ? AND id = ?
1174
+ AND status = 'pending_human_approval'`,
902
1175
  )
903
1176
  .bind(
904
1177
  grantTokenHash,
@@ -911,6 +1184,12 @@ export async function approveRemediationProposal(
911
1184
  row.id,
912
1185
  )
913
1186
  .run();
1187
+ if (res.meta.changes !== 1) {
1188
+ throw new RemediationGateError(
1189
+ `Proposal ${proposalId} was modified concurrently and can no longer be approved`,
1190
+ 409,
1191
+ );
1192
+ }
914
1193
 
915
1194
  const updated = await db
916
1195
  .prepare(
@@ -933,23 +1212,12 @@ export async function rejectRemediationProposal(
933
1212
  reason?: string;
934
1213
  },
935
1214
  ): 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
-
1215
+ const row = await loadProposalRow(
1216
+ db,
1217
+ projectId,
1218
+ proposalId,
1219
+ options.environment,
1220
+ );
953
1221
  if (!row) {
954
1222
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
955
1223
  }
@@ -968,17 +1236,24 @@ export async function rejectRemediationProposal(
968
1236
  ? `Rejected by ${options.rejectedByUserId}: ${options.reason}`
969
1237
  : `Rejected by ${options.rejectedByUserId}`;
970
1238
 
971
- await db
1239
+ const res = await db
972
1240
  .prepare(
973
1241
  `UPDATE remediation_proposals
974
1242
  SET status = 'rejected',
975
1243
  grant_token_hash = NULL,
976
1244
  failure_reason = ?,
977
1245
  updated_at = ?
978
- WHERE project_id = ? AND environment = ? AND id = ?`,
1246
+ WHERE project_id = ? AND environment = ? AND id = ?
1247
+ AND status IN ('pending_human_approval', 'grant_issued')`,
979
1248
  )
980
1249
  .bind(reason, nowIso, projectId, row.environment, row.id)
981
1250
  .run();
1251
+ if (res.meta.changes !== 1) {
1252
+ throw new RemediationGateError(
1253
+ `Proposal ${proposalId} was modified concurrently and can no longer be rejected`,
1254
+ 409,
1255
+ );
1256
+ }
982
1257
 
983
1258
  const updated = await db
984
1259
  .prepare(
@@ -994,6 +1269,12 @@ export async function rejectRemediationProposal(
994
1269
  /**
995
1270
  * Single-use grant redemption with optimistic `expectedCurrentState` verification
996
1271
  * against `ontology_nodes` (RFC 0013 § 6.B & P1 #4).
1272
+ *
1273
+ * The status, token-hash and expiry preconditions are part of the claiming
1274
+ * UPDATE, and the outcome is read from `meta.changes`: of N concurrent claims
1275
+ * for one token exactly one sees `changes === 1`; the rest get a 409. The
1276
+ * incident transition rides in the same `batch` and is guarded by an EXISTS on
1277
+ * the winning claim, so a losing claim cannot touch the incident.
997
1278
  */
998
1279
  export async function claimRemediationGrant(
999
1280
  db: SqlDb,
@@ -1004,25 +1285,17 @@ export async function claimRemediationGrant(
1004
1285
  grantToken: string;
1005
1286
  runnerId?: string;
1006
1287
  nowIso?: string;
1288
+ logger?: Logger;
1007
1289
  },
1008
1290
  ): Promise<RemediationProposal> {
1291
+ const logger = options.logger ?? consoleLogger;
1009
1292
  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>();
1293
+ const row = await loadProposalRow(
1294
+ db,
1295
+ projectId,
1296
+ proposalId,
1297
+ options.environment,
1298
+ );
1026
1299
 
1027
1300
  if (!row) {
1028
1301
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
@@ -1037,8 +1310,9 @@ export async function claimRemediationGrant(
1037
1310
  await db
1038
1311
  .prepare(
1039
1312
  `UPDATE remediation_proposals
1040
- SET status = 'expired', updated_at = ?
1041
- WHERE project_id = ? AND environment = ? AND id = ?`,
1313
+ SET status = 'expired', grant_token_hash = NULL, updated_at = ?
1314
+ WHERE project_id = ? AND environment = ? AND id = ?
1315
+ AND status = 'grant_issued'`,
1042
1316
  )
1043
1317
  .bind(nowIso, projectId, row.environment, row.id)
1044
1318
  .run();
@@ -1049,7 +1323,10 @@ export async function claimRemediationGrant(
1049
1323
  }
1050
1324
 
1051
1325
  const incomingHash = await sha256Hex(options.grantToken);
1052
- if (!row.grant_token_hash || incomingHash !== row.grant_token_hash) {
1326
+ if (
1327
+ !row.grant_token_hash ||
1328
+ !constantTimeEqual(incomingHash, row.grant_token_hash)
1329
+ ) {
1053
1330
  throw new RemediationGateError("Invalid grantToken", 403);
1054
1331
  }
1055
1332
 
@@ -1071,43 +1348,35 @@ export async function claimRemediationGrant(
1071
1348
  }>();
1072
1349
 
1073
1350
  if (targetNode) {
1074
- if (
1351
+ const lockFailure =
1075
1352
  expected.version &&
1076
1353
  targetNode.current_version &&
1077
1354
  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
- );
1355
+ ? `Optimistic version lock failed on ${row.target_node_id}: expected version "${expected.version}", but current version is "${targetNode.current_version}"`
1356
+ : expected.commitSha &&
1357
+ targetNode.current_commit_sha &&
1358
+ targetNode.current_commit_sha !== expected.commitSha
1359
+ ? `Optimistic commit lock failed on ${row.target_node_id}: expected commit "${expected.commitSha}", but current commit is "${targetNode.current_commit_sha}"`
1360
+ : expected.configDigest &&
1361
+ targetNode.current_config_digest &&
1362
+ targetNode.current_config_digest !== expected.configDigest
1363
+ ? `Optimistic config lock failed on ${row.target_node_id}: expected digest "${expected.configDigest}", but current digest is "${targetNode.current_config_digest}"`
1364
+ : null;
1365
+ if (lockFailure) {
1366
+ logger.warn("[autosre] remediation claim rejected by state lock", {
1367
+ proposalId,
1368
+ error: lockFailure,
1369
+ });
1370
+ throw new RemediationGateError(lockFailure, 409);
1103
1371
  }
1104
1372
  }
1105
1373
 
1106
1374
  const claimExpiresAt = new Date(
1107
1375
  Date.parse(nowIso) + 5 * 60_000,
1108
1376
  ).toISOString();
1377
+ const runnerId = options.runnerId ?? "autosre-runner";
1109
1378
 
1110
- await db.batch([
1379
+ const results = await db.batch([
1111
1380
  db
1112
1381
  .prepare(
1113
1382
  `UPDATE remediation_proposals
@@ -1117,25 +1386,53 @@ export async function claimRemediationGrant(
1117
1386
  claimed_at = ?,
1118
1387
  claim_expires_at = ?,
1119
1388
  updated_at = ?
1120
- WHERE project_id = ? AND environment = ? AND id = ?`,
1389
+ WHERE project_id = ? AND environment = ? AND id = ?
1390
+ AND status = 'grant_issued'
1391
+ AND grant_token_hash = ?
1392
+ AND (grant_expires_at IS NULL OR grant_expires_at > ?)`,
1121
1393
  )
1122
1394
  .bind(
1123
- options.runnerId ?? "autosre-runner",
1395
+ runnerId,
1124
1396
  nowIso,
1125
1397
  claimExpiresAt,
1126
1398
  nowIso,
1127
1399
  projectId,
1128
1400
  row.environment,
1129
1401
  row.id,
1402
+ row.grant_token_hash,
1403
+ nowIso,
1130
1404
  ),
1131
1405
  db
1132
1406
  .prepare(
1133
1407
  `UPDATE incident_investigations
1134
1408
  SET status = 'remediating', updated_at = ?
1135
- WHERE project_id = ? AND environment = ? AND id = ?`,
1409
+ WHERE project_id = ? AND environment = ? AND id = ?
1410
+ AND EXISTS (
1411
+ SELECT 1 FROM remediation_proposals g
1412
+ WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
1413
+ AND g.status = 'claimed_executing'
1414
+ AND g.claimed_by_runner_id = ?
1415
+ AND g.claimed_at = ?
1416
+ )`,
1136
1417
  )
1137
- .bind(nowIso, projectId, row.environment, row.incident_id),
1418
+ .bind(
1419
+ nowIso,
1420
+ projectId,
1421
+ row.environment,
1422
+ row.incident_id,
1423
+ projectId,
1424
+ row.environment,
1425
+ row.id,
1426
+ runnerId,
1427
+ nowIso,
1428
+ ),
1138
1429
  ]);
1430
+ if (results[0]?.meta.changes !== 1) {
1431
+ throw new RemediationGateError(
1432
+ `Grant for proposal ${proposalId} was already claimed or is no longer valid (grants are single-use)`,
1433
+ 409,
1434
+ );
1435
+ }
1139
1436
 
1140
1437
  const updated = await db
1141
1438
  .prepare(
@@ -1151,6 +1448,10 @@ export async function claimRemediationGrant(
1151
1448
  /**
1152
1449
  * Records remediation completion, updates `ontology_nodes` to desired state,
1153
1450
  * and arms the +2m/+5m/+15m post-remediation watchdog on the finding.
1451
+ *
1452
+ * Only the runner that holds the claim may complete, and only while the claim
1453
+ * window is open (`now <= claim_expires_at`); otherwise 409. All follow-up
1454
+ * writes are guarded on the completion UPDATE having applied.
1154
1455
  */
1155
1456
  export async function completeRemediationProposal(
1156
1457
  db: SqlDb,
@@ -1158,6 +1459,7 @@ export async function completeRemediationProposal(
1158
1459
  proposalId: string,
1159
1460
  options: {
1160
1461
  environment?: string;
1462
+ runnerId?: string;
1161
1463
  resultingChangeId?: string | null;
1162
1464
  remediationAgentRunId?: string | null;
1163
1465
  baselineRequestRatePerMin?: number | null;
@@ -1165,22 +1467,12 @@ export async function completeRemediationProposal(
1165
1467
  },
1166
1468
  ): Promise<RemediationProposal> {
1167
1469
  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>();
1470
+ const row = await loadProposalRow(
1471
+ db,
1472
+ projectId,
1473
+ proposalId,
1474
+ options.environment,
1475
+ );
1184
1476
 
1185
1477
  if (!row) {
1186
1478
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
@@ -1191,6 +1483,18 @@ export async function completeRemediationProposal(
1191
1483
  409,
1192
1484
  );
1193
1485
  }
1486
+ if (!options.runnerId || options.runnerId !== row.claimed_by_runner_id) {
1487
+ throw new RemediationGateError(
1488
+ `Proposal ${proposalId} is claimed by a different runner; only the claiming runner may complete it`,
1489
+ 409,
1490
+ );
1491
+ }
1492
+ if (row.claim_expires_at && row.claim_expires_at < nowIso) {
1493
+ throw new RemediationGateError(
1494
+ `Claim on proposal ${proposalId} expired at ${row.claim_expires_at}`,
1495
+ 409,
1496
+ );
1497
+ }
1194
1498
 
1195
1499
  const desired = parseJsonRecord(
1196
1500
  row.desired_target_state_json,
@@ -1233,7 +1537,15 @@ export async function completeRemediationProposal(
1233
1537
  });
1234
1538
  }
1235
1539
 
1236
- await db.batch([
1540
+ // Follow-up writes only apply when the completion UPDATE below won.
1541
+ const completedGuard = `EXISTS (
1542
+ SELECT 1 FROM remediation_proposals g
1543
+ WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
1544
+ AND g.status = 'completed' AND g.updated_at = ?
1545
+ )`;
1546
+ const guardBinds = [projectId, row.environment, row.id, nowIso];
1547
+
1548
+ const results = await db.batch([
1237
1549
  db
1238
1550
  .prepare(
1239
1551
  `UPDATE remediation_proposals
@@ -1241,7 +1553,10 @@ export async function completeRemediationProposal(
1241
1553
  resulting_change_id = COALESCE(?, resulting_change_id),
1242
1554
  remediation_agent_run_id = COALESCE(?, remediation_agent_run_id),
1243
1555
  updated_at = ?
1244
- WHERE project_id = ? AND environment = ? AND id = ?`,
1556
+ WHERE project_id = ? AND environment = ? AND id = ?
1557
+ AND status = 'claimed_executing'
1558
+ AND claimed_by_runner_id = ?
1559
+ AND (claim_expires_at IS NULL OR claim_expires_at >= ?)`,
1245
1560
  )
1246
1561
  .bind(
1247
1562
  options.resultingChangeId ?? null,
@@ -1250,6 +1565,8 @@ export async function completeRemediationProposal(
1250
1565
  projectId,
1251
1566
  row.environment,
1252
1567
  row.id,
1568
+ options.runnerId,
1569
+ nowIso,
1253
1570
  ),
1254
1571
  db
1255
1572
  .prepare(
@@ -1258,7 +1575,8 @@ export async function completeRemediationProposal(
1258
1575
  current_commit_sha = COALESCE(?, current_commit_sha),
1259
1576
  current_config_digest = COALESCE(?, current_config_digest),
1260
1577
  last_seen_at = ?
1261
- WHERE project_id = ? AND environment = ? AND id = ?`,
1578
+ WHERE project_id = ? AND environment = ? AND id = ?
1579
+ AND ${completedGuard}`,
1262
1580
  )
1263
1581
  .bind(
1264
1582
  desired.version ?? null,
@@ -1268,17 +1586,20 @@ export async function completeRemediationProposal(
1268
1586
  projectId,
1269
1587
  row.environment,
1270
1588
  row.target_node_id,
1589
+ ...guardBinds,
1271
1590
  ),
1272
1591
  db
1273
1592
  .prepare(
1274
1593
  `UPDATE investigation_findings
1275
1594
  SET recovery_status = 'monitoring',
1276
1595
  watchdog_step = 1,
1596
+ consecutive_ok_checks = 0,
1277
1597
  baseline_request_rate_per_min = COALESCE(?, baseline_request_rate_per_min),
1278
1598
  next_watchdog_check_at = ?,
1279
1599
  causal_chain_json = ?,
1280
1600
  updated_at = ?
1281
- WHERE project_id = ? AND environment = ? AND id = ?`,
1601
+ WHERE project_id = ? AND environment = ? AND id = ?
1602
+ AND ${completedGuard}`,
1282
1603
  )
1283
1604
  .bind(
1284
1605
  options.baselineRequestRatePerMin ?? null,
@@ -1288,15 +1609,23 @@ export async function completeRemediationProposal(
1288
1609
  projectId,
1289
1610
  row.environment,
1290
1611
  row.finding_id,
1612
+ ...guardBinds,
1291
1613
  ),
1292
1614
  db
1293
1615
  .prepare(
1294
1616
  `UPDATE incident_investigations
1295
1617
  SET status = 'verifying', updated_at = ?
1296
- WHERE project_id = ? AND environment = ? AND id = ?`,
1618
+ WHERE project_id = ? AND environment = ? AND id = ?
1619
+ AND ${completedGuard}`,
1297
1620
  )
1298
- .bind(nowIso, projectId, row.environment, row.incident_id),
1621
+ .bind(nowIso, projectId, row.environment, row.incident_id, ...guardBinds),
1299
1622
  ]);
1623
+ if (results[0]?.meta.changes !== 1) {
1624
+ throw new RemediationGateError(
1625
+ `Proposal ${proposalId} changed state concurrently and can no longer be completed`,
1626
+ 409,
1627
+ );
1628
+ }
1300
1629
 
1301
1630
  const updated = await db
1302
1631
  .prepare(
@@ -1315,50 +1644,82 @@ export async function failRemediationProposal(
1315
1644
  proposalId: string,
1316
1645
  options: {
1317
1646
  environment?: string;
1647
+ runnerId?: string;
1318
1648
  failureReason: string;
1319
1649
  nowIso?: string;
1320
1650
  },
1321
1651
  ): Promise<RemediationProposal> {
1322
1652
  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>();
1653
+ const row = await loadProposalRow(
1654
+ db,
1655
+ projectId,
1656
+ proposalId,
1657
+ options.environment,
1658
+ );
1339
1659
 
1340
1660
  if (!row) {
1341
1661
  throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
1342
1662
  }
1663
+ if (row.status !== "claimed_executing") {
1664
+ throw new RemediationGateError(
1665
+ `Proposal ${proposalId} cannot be failed from status=${row.status} (must be claimed_executing)`,
1666
+ 409,
1667
+ );
1668
+ }
1669
+ if (!options.runnerId || options.runnerId !== row.claimed_by_runner_id) {
1670
+ throw new RemediationGateError(
1671
+ `Proposal ${proposalId} is claimed by a different runner; only the claiming runner may fail it`,
1672
+ 409,
1673
+ );
1674
+ }
1343
1675
 
1344
- await db.batch([
1676
+ const results = await db.batch([
1345
1677
  db
1346
1678
  .prepare(
1347
1679
  `UPDATE remediation_proposals
1348
1680
  SET status = 'failed_execution',
1349
1681
  failure_reason = ?,
1350
1682
  updated_at = ?
1351
- WHERE project_id = ? AND environment = ? AND id = ?`,
1683
+ WHERE project_id = ? AND environment = ? AND id = ?
1684
+ AND status = 'claimed_executing'
1685
+ AND claimed_by_runner_id = ?`,
1352
1686
  )
1353
- .bind(options.failureReason, nowIso, projectId, row.environment, row.id),
1687
+ .bind(
1688
+ options.failureReason,
1689
+ nowIso,
1690
+ projectId,
1691
+ row.environment,
1692
+ row.id,
1693
+ options.runnerId,
1694
+ ),
1354
1695
  db
1355
1696
  .prepare(
1356
1697
  `UPDATE incident_investigations
1357
1698
  SET status = 'escalated', updated_at = ?
1358
- WHERE project_id = ? AND environment = ? AND id = ?`,
1699
+ WHERE project_id = ? AND environment = ? AND id = ?
1700
+ AND EXISTS (
1701
+ SELECT 1 FROM remediation_proposals g
1702
+ WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
1703
+ AND g.status = 'failed_execution' AND g.updated_at = ?
1704
+ )`,
1359
1705
  )
1360
- .bind(nowIso, projectId, row.environment, row.incident_id),
1706
+ .bind(
1707
+ nowIso,
1708
+ projectId,
1709
+ row.environment,
1710
+ row.incident_id,
1711
+ projectId,
1712
+ row.environment,
1713
+ row.id,
1714
+ nowIso,
1715
+ ),
1361
1716
  ]);
1717
+ if (results[0]?.meta.changes !== 1) {
1718
+ throw new RemediationGateError(
1719
+ `Proposal ${proposalId} changed state concurrently and can no longer be failed`,
1720
+ 409,
1721
+ );
1722
+ }
1362
1723
 
1363
1724
  const updated = await db
1364
1725
  .prepare(
@@ -1378,8 +1739,19 @@ export interface WatchdogEvaluationSummary {
1378
1739
  stillDegraded: number;
1379
1740
  timedOutClaims: number;
1380
1741
  expiredGrants: number;
1742
+ /** Findings whose evaluation threw; logged and retried on the next tick. */
1743
+ failed: number;
1381
1744
  }
1382
1745
 
1746
+ /** Watchdog checkpoints, in minutes after the remediation completed (RFC 0013 § 5.B). */
1747
+ const WATCHDOG_STEP_OFFSET_MINUTES: Record<number, number> = {
1748
+ 1: 2,
1749
+ 2: 5,
1750
+ 3: 15,
1751
+ };
1752
+ /** Consecutive sufficient-traffic healthy checkpoints required for `recovered`. */
1753
+ const REQUIRED_CONSECUTIVE_OK_CHECKS = 2;
1754
+
1383
1755
  /**
1384
1756
  * Post-Remediation Watchdog & Lease/Claim Timeout Evaluator (RFC 0013 § 5.B, § 6.B & P1 #3).
1385
1757
  *
@@ -1387,16 +1759,28 @@ export interface WatchdogEvaluationSummary {
1387
1759
  * - `recovery_status`: whether the service recovered under comparable traffic (`N >= minComparableRequests`)
1388
1760
  * - `causal_verification_status`: `"operationally_corroborated"` (never promoted to `"counterfactual_verified"`
1389
1761
  * by telemetry recovery alone!).
1762
+ *
1763
+ * Checkpoints are scheduled at +2m/+5m/+15m measured from the remediation's
1764
+ * completion time (not from whenever a tick happened to run), and a finding
1765
+ * only becomes `recovered` after two consecutive healthy checkpoints that each
1766
+ * had sufficient traffic. Pass `projectId` to scope the sweep to one tenant
1767
+ * (HTTP path); the cron path omits it and sweeps every project.
1390
1768
  */
1391
1769
  export async function evaluateRemediationWatchdogs(
1392
1770
  db: SqlDb,
1393
1771
  options: {
1394
1772
  nowIso?: string;
1395
1773
  minComparableRequests?: number;
1774
+ projectId?: string;
1775
+ logger?: Logger;
1396
1776
  } = {},
1397
1777
  ): Promise<WatchdogEvaluationSummary> {
1778
+ const logger = options.logger ?? consoleLogger;
1398
1779
  const nowIso = options.nowIso ?? new Date().toISOString();
1399
1780
  const minComparableRequests = options.minComparableRequests ?? 50;
1781
+ const projectId = options.projectId;
1782
+ const projectClause = projectId ? " AND project_id = ?" : "";
1783
+ const projectBinds = projectId ? [projectId] : [];
1400
1784
 
1401
1785
  // 1. Expire unclaimed grants past grant_expires_at
1402
1786
  const expiredGrantsRes = await db
@@ -1405,9 +1789,9 @@ export async function evaluateRemediationWatchdogs(
1405
1789
  SET status = 'expired', grant_token_hash = NULL, updated_at = ?
1406
1790
  WHERE status = 'grant_issued'
1407
1791
  AND grant_expires_at IS NOT NULL
1408
- AND grant_expires_at <= ?`,
1792
+ AND grant_expires_at <= ?${projectClause}`,
1409
1793
  )
1410
- .bind(nowIso, nowIso)
1794
+ .bind(nowIso, nowIso, ...projectBinds)
1411
1795
  .run();
1412
1796
 
1413
1797
  // 2. Time out claimed_executing proposals past claim_expires_at and escalate their incidents
@@ -1417,9 +1801,9 @@ export async function evaluateRemediationWatchdogs(
1417
1801
  FROM remediation_proposals
1418
1802
  WHERE status = 'claimed_executing'
1419
1803
  AND claim_expires_at IS NOT NULL
1420
- AND claim_expires_at <= ?`,
1804
+ AND claim_expires_at <= ?${projectClause}`,
1421
1805
  )
1422
- .bind(nowIso)
1806
+ .bind(nowIso, ...projectBinds)
1423
1807
  .all<{
1424
1808
  project_id: string;
1425
1809
  environment: string;
@@ -1427,25 +1811,42 @@ export async function evaluateRemediationWatchdogs(
1427
1811
  incident_id: string;
1428
1812
  }>();
1429
1813
 
1814
+ let timedOutClaims = 0;
1430
1815
  for (const t of timedOutRows.results) {
1431
- await db.batch([
1816
+ const res = await db.batch([
1432
1817
  db
1433
1818
  .prepare(
1434
1819
  `UPDATE remediation_proposals
1435
1820
  SET status = 'execution_timed_out',
1436
1821
  failure_reason = 'Runner did not complete or fail within 5m claim window',
1437
1822
  updated_at = ?
1438
- WHERE project_id = ? AND environment = ? AND id = ?`,
1823
+ WHERE project_id = ? AND environment = ? AND id = ?
1824
+ AND status = 'claimed_executing'`,
1439
1825
  )
1440
1826
  .bind(nowIso, t.project_id, t.environment, t.id),
1441
1827
  db
1442
1828
  .prepare(
1443
1829
  `UPDATE incident_investigations
1444
1830
  SET status = 'escalated', updated_at = ?
1445
- WHERE project_id = ? AND environment = ? AND id = ?`,
1831
+ WHERE project_id = ? AND environment = ? AND id = ?
1832
+ AND EXISTS (
1833
+ SELECT 1 FROM remediation_proposals g
1834
+ WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
1835
+ AND g.status = 'execution_timed_out' AND g.updated_at = ?
1836
+ )`,
1446
1837
  )
1447
- .bind(nowIso, t.project_id, t.environment, t.incident_id),
1838
+ .bind(
1839
+ nowIso,
1840
+ t.project_id,
1841
+ t.environment,
1842
+ t.incident_id,
1843
+ t.project_id,
1844
+ t.environment,
1845
+ t.id,
1846
+ nowIso,
1847
+ ),
1448
1848
  ]);
1849
+ if (res[0]?.meta.changes === 1) timedOutClaims += 1;
1449
1850
  }
1450
1851
 
1451
1852
  // 3. Evaluate findings in `monitoring` where `next_watchdog_check_at <= nowIso`
@@ -1454,15 +1855,15 @@ export async function evaluateRemediationWatchdogs(
1454
1855
  `SELECT
1455
1856
  project_id, environment, id, incident_id,
1456
1857
  fault_locus_entity_id, root_cause_entity_id,
1457
- watchdog_step, baseline_request_rate_per_min,
1858
+ watchdog_step, consecutive_ok_checks, baseline_request_rate_per_min,
1458
1859
  causal_verification_status, updated_at
1459
1860
  FROM investigation_findings
1460
1861
  WHERE recovery_status = 'monitoring'
1461
1862
  AND next_watchdog_check_at IS NOT NULL
1462
- AND next_watchdog_check_at <= ?
1863
+ AND next_watchdog_check_at <= ?${projectClause}
1463
1864
  LIMIT 50`,
1464
1865
  )
1465
- .bind(nowIso)
1866
+ .bind(nowIso, ...projectBinds)
1466
1867
  .all<{
1467
1868
  project_id: string;
1468
1869
  environment: string;
@@ -1471,6 +1872,7 @@ export async function evaluateRemediationWatchdogs(
1471
1872
  fault_locus_entity_id: string;
1472
1873
  root_cause_entity_id: string | null;
1473
1874
  watchdog_step: number;
1875
+ consecutive_ok_checks: number | null;
1474
1876
  baseline_request_rate_per_min: number | null;
1475
1877
  causal_verification_status: string;
1476
1878
  updated_at: string;
@@ -1479,141 +1881,193 @@ export async function evaluateRemediationWatchdogs(
1479
1881
  let recovered = 0;
1480
1882
  let inconclusiveLowTraffic = 0;
1481
1883
  let stillDegraded = 0;
1884
+ let failed = 0;
1482
1885
 
1483
1886
  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,
1887
+ try {
1888
+ // Find the completed remediation timestamp for this finding
1889
+ const completedProposal = await db
1890
+ .prepare(
1891
+ `SELECT
1892
+ p.updated_at,
1893
+ p.target_node_id,
1894
+ p.resulting_change_id,
1895
+ c.occurred_at AS change_occurred_at
1896
+ FROM remediation_proposals p
1897
+ LEFT JOIN change_events c
1898
+ ON c.project_id = p.project_id
1899
+ AND c.environment = p.environment
1900
+ AND c.id = p.resulting_change_id
1901
+ WHERE p.project_id = ? AND p.environment = ? AND p.finding_id = ? AND p.status = 'completed'
1902
+ ORDER BY p.updated_at DESC
1903
+ LIMIT 1`,
1904
+ )
1905
+ .bind(f.project_id, f.environment, f.id)
1906
+ .first<{
1907
+ updated_at: string;
1908
+ target_node_id: string;
1909
+ resulting_change_id: string | null;
1910
+ change_occurred_at: string | null;
1911
+ }>();
1912
+
1913
+ // Traffic is measured since the remediation change took effect; the
1914
+ // checkpoint schedule is anchored on when the remediation completed.
1915
+ const sinceTs =
1916
+ completedProposal?.change_occurred_at ??
1917
+ completedProposal?.updated_at ??
1918
+ f.updated_at;
1919
+ const completedAtMs = Date.parse(
1920
+ completedProposal?.updated_at ?? f.updated_at,
1921
+ );
1922
+ const nextCheckFor = (step: number): string =>
1923
+ new Date(
1924
+ completedAtMs + (WATCHDOG_STEP_OFFSET_MINUTES[step] ?? 15) * 60_000,
1566
1925
  ).toISOString();
1926
+ const targetNodeId =
1927
+ completedProposal?.target_node_id ?? f.fault_locus_entity_id;
1928
+
1929
+ // Extract service name from canonical service:{env}:{ns}:{serviceName} if applicable
1930
+ const parts = targetNodeId.split(":");
1931
+ const serviceName =
1932
+ parts[0] === "service" && parts.length >= 4
1933
+ ? parts.slice(3).join(":")
1934
+ : null;
1935
+
1936
+ const stats = serviceName
1937
+ ? await db
1938
+ .prepare(
1939
+ `SELECT
1940
+ COUNT(*) AS total_requests,
1941
+ SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
1942
+ FROM telemetry_spans
1943
+ WHERE project_id = ?
1944
+ AND environment = ?
1945
+ AND service_name = ?
1946
+ AND (parent_span_id IS NULL OR span_kind = 1)
1947
+ AND (start_time >= ? OR received_at >= ?)`,
1948
+ )
1949
+ .bind(f.project_id, f.environment, serviceName, sinceTs, sinceTs)
1950
+ .first<{ total_requests: number; error_requests: number }>()
1951
+ : await db
1952
+ .prepare(
1953
+ `SELECT
1954
+ COUNT(*) AS total_requests,
1955
+ SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
1956
+ FROM telemetry_spans
1957
+ WHERE project_id = ?
1958
+ AND environment = ?
1959
+ AND (parent_span_id IS NULL OR span_kind = 1)
1960
+ AND (start_time >= ? OR received_at >= ?)`,
1961
+ )
1962
+ .bind(f.project_id, f.environment, sinceTs, sinceTs)
1963
+ .first<{ total_requests: number; error_requests: number }>();
1964
+
1965
+ const totalReq = Number(stats?.total_requests ?? 0);
1966
+ const errorReq = Number(stats?.error_requests ?? 0);
1967
+ const errorRate = totalReq > 0 ? errorReq / totalReq : 0;
1968
+
1969
+ const requiredTraffic = Math.max(
1970
+ minComparableRequests,
1971
+ f.baseline_request_rate_per_min
1972
+ ? Math.floor(0.5 * f.baseline_request_rate_per_min * 5)
1973
+ : minComparableRequests,
1974
+ );
1975
+
1976
+ if (totalReq < requiredTraffic) {
1977
+ // Insufficient traffic breaks the consecutive-ok streak.
1978
+ if (f.watchdog_step < 3) {
1979
+ const nextStep = f.watchdog_step + 1;
1980
+ await db
1981
+ .prepare(
1982
+ `UPDATE investigation_findings
1983
+ SET watchdog_step = ?, consecutive_ok_checks = 0,
1984
+ next_watchdog_check_at = ?, updated_at = ?
1985
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1986
+ )
1987
+ .bind(
1988
+ nextStep,
1989
+ nextCheckFor(nextStep),
1990
+ nowIso,
1991
+ f.project_id,
1992
+ f.environment,
1993
+ f.id,
1994
+ )
1995
+ .run();
1996
+ } else {
1997
+ // Step 3 (+15m) with insufficient comparable traffic -> inconclusive_low_traffic
1998
+ inconclusiveLowTraffic += 1;
1999
+ await db
2000
+ .prepare(
2001
+ `UPDATE investigation_findings
2002
+ SET recovery_status = 'inconclusive_low_traffic',
2003
+ consecutive_ok_checks = 0,
2004
+ next_watchdog_check_at = NULL,
2005
+ updated_at = ?
2006
+ WHERE project_id = ? AND environment = ? AND id = ?`,
2007
+ )
2008
+ .bind(nowIso, f.project_id, f.environment, f.id)
2009
+ .run();
2010
+ }
2011
+ continue;
2012
+ }
2013
+
2014
+ if (errorRate >= 0.05) {
2015
+ stillDegraded += 1;
1567
2016
  await db
1568
2017
  .prepare(
1569
2018
  `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',
2019
+ SET recovery_status = 'still_degraded',
2020
+ causal_verification_status = 'refuted',
2021
+ consecutive_ok_checks = 0,
1582
2022
  next_watchdog_check_at = NULL,
1583
2023
  updated_at = ?
1584
2024
  WHERE project_id = ? AND environment = ? AND id = ?`,
1585
2025
  )
1586
2026
  .bind(nowIso, f.project_id, f.environment, f.id)
1587
2027
  .run();
2028
+ if (f.incident_id) {
2029
+ await db
2030
+ .prepare(
2031
+ `UPDATE incident_investigations
2032
+ SET status = 'escalated', updated_at = ?
2033
+ WHERE project_id = ? AND environment = ? AND id = ?`,
2034
+ )
2035
+ .bind(nowIso, f.project_id, f.environment, f.incident_id)
2036
+ .run();
2037
+ }
2038
+ continue;
1588
2039
  }
1589
- continue;
1590
- }
1591
2040
 
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) {
2041
+ // Traffic is sufficient AND error rate is healthy (<5%) at this checkpoint.
2042
+ const okChecks = Number(f.consecutive_ok_checks ?? 0) + 1;
2043
+ if (okChecks < REQUIRED_CONSECUTIVE_OK_CHECKS) {
2044
+ // Need one more healthy checkpoint. Advance along the schedule; past
2045
+ // the last step, re-check shortly after now.
2046
+ const nextStep = Math.min(3, f.watchdog_step + 1);
2047
+ const nextCheck =
2048
+ f.watchdog_step < 3
2049
+ ? nextCheckFor(nextStep)
2050
+ : new Date(Date.parse(nowIso) + 5 * 60_000).toISOString();
1606
2051
  await db
1607
2052
  .prepare(
1608
- `UPDATE incident_investigations
1609
- SET status = 'escalated', updated_at = ?
2053
+ `UPDATE investigation_findings
2054
+ SET watchdog_step = ?, consecutive_ok_checks = ?,
2055
+ next_watchdog_check_at = ?, updated_at = ?
1610
2056
  WHERE project_id = ? AND environment = ? AND id = ?`,
1611
2057
  )
1612
- .bind(nowIso, f.project_id, f.environment, f.incident_id)
2058
+ .bind(
2059
+ nextStep,
2060
+ okChecks,
2061
+ nextCheck,
2062
+ nowIso,
2063
+ f.project_id,
2064
+ f.environment,
2065
+ f.id,
2066
+ )
1613
2067
  .run();
2068
+ continue;
1614
2069
  }
1615
- } else {
1616
- // Traffic is sufficient AND error rate recovered (<5%)
2070
+
1617
2071
  recovered += 1;
1618
2072
  const nextCausalVerification =
1619
2073
  f.causal_verification_status === "counterfactual_verified"
@@ -1626,12 +2080,14 @@ export async function evaluateRemediationWatchdogs(
1626
2080
  SET recovery_status = 'recovered',
1627
2081
  causal_verification_status = ?,
1628
2082
  watchdog_step = 3,
2083
+ consecutive_ok_checks = ?,
1629
2084
  next_watchdog_check_at = NULL,
1630
2085
  updated_at = ?
1631
2086
  WHERE project_id = ? AND environment = ? AND id = ?`,
1632
2087
  )
1633
2088
  .bind(
1634
2089
  nextCausalVerification,
2090
+ okChecks,
1635
2091
  nowIso,
1636
2092
  f.project_id,
1637
2093
  f.environment,
@@ -1649,6 +2105,13 @@ export async function evaluateRemediationWatchdogs(
1649
2105
  .bind(nowIso, nowIso, f.project_id, f.environment, f.incident_id)
1650
2106
  .run();
1651
2107
  }
2108
+ } catch (err) {
2109
+ failed += 1;
2110
+ logger.error("[autosre] watchdog evaluation failed for finding", {
2111
+ findingId: f.id,
2112
+ projectId: f.project_id,
2113
+ error: err instanceof Error ? err.message : String(err),
2114
+ });
1652
2115
  }
1653
2116
  }
1654
2117
 
@@ -1657,7 +2120,8 @@ export async function evaluateRemediationWatchdogs(
1657
2120
  recovered,
1658
2121
  inconclusiveLowTraffic,
1659
2122
  stillDegraded,
1660
- timedOutClaims: timedOutRows.results.length,
2123
+ timedOutClaims,
1661
2124
  expiredGrants: expiredGrantsRes.meta.changes,
2125
+ failed,
1662
2126
  };
1663
2127
  }