@obsunified/collector 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  2. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  3. package/dist/index.d.ts +359 -2
  4. package/dist/index.js +5000 -334
  5. package/dist/index.js.map +1 -1
  6. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  7. package/package.json +2 -2
  8. package/src/__tests__/autosre-closed-loop.test.ts +2222 -0
  9. package/src/index.ts +43 -0
  10. package/src/lib/analyses-runner.ts +46 -0
  11. package/src/lib/autosre-change-correlation.ts +807 -0
  12. package/src/lib/autosre-findings.ts +763 -0
  13. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  14. package/src/lib/autosre-ontology.ts +938 -0
  15. package/src/lib/autosre-remediation-gate.ts +2127 -0
  16. package/src/lib/eval-cases-store.ts +63 -1
  17. package/src/lib/evidence-references.ts +6 -0
  18. package/src/lib/housekeeping.test.ts +150 -0
  19. package/src/lib/housekeeping.ts +109 -1
  20. package/src/lib/store/ingest.ts +46 -6
  21. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  22. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  23. package/src/migrations/alert-state-notified.test.ts +3 -3
  24. package/src/migrations/parity.test.ts +4 -0
  25. package/src/migrations/project-scoped-keys.test.ts +1 -1
  26. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  27. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  28. package/src/plugins/alerts-evaluator.ts +29 -1
  29. package/src/plugins/autosre-routes.ts +1134 -0
  30. package/src/plugins/connected-routes/manifest.ts +10 -1
  31. package/src/plugins/connected-routes.ts +241 -0
  32. package/src/plugins/default-span-enrichment.ts +13 -1
  33. package/src/plugins/eval-cases-routes.ts +57 -2
  34. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  35. package/src/plugins/evidence-retrieval-routes.ts +643 -5
  36. package/dist/chunk-XKQTVIRE.js.map +0 -1
  37. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -0,0 +1,2127 @@
1
+ import type {
2
+ CausalTriple,
3
+ EvidenceEntityKind,
4
+ IncidentInvestigation,
5
+ IncidentStatus,
6
+ RemediationActionKind,
7
+ RemediationProposal,
8
+ RemediationProposalInput,
9
+ RemediationProposalStatus,
10
+ RemediationStateSpec,
11
+ } from "@obsunified/types";
12
+ import { buildCanonicalNodeId } from "@obsunified/types";
13
+ import { AgentAutonomyLevel } from "@obsunified/types/constants";
14
+ import { consoleLogger, type Logger } from "../framework/logger";
15
+ import { getInvestigationFinding } from "./autosre-findings";
16
+ import { randomHex, sha256Hex } from "./hash";
17
+ import { parseJsonRecord } from "./json";
18
+ import type { SqlDb } from "./sql-db";
19
+ import {
20
+ assertSafeWebhookUrl,
21
+ type HostLookup,
22
+ resolveWebhookHost,
23
+ } from "./webhook-url";
24
+
25
+ interface IncidentRow {
26
+ project_id: string;
27
+ environment: string;
28
+ id: string;
29
+ dedup_key: string;
30
+ status: string;
31
+ target_node_id: string;
32
+ symptom_anchor_kind: string;
33
+ symptom_anchor_id: string;
34
+ symptom_fingerprint: string | null;
35
+ alert_count: number;
36
+ latest_bundle_json: string | null;
37
+ lease_owner_id: string | null;
38
+ lease_expires_at: string | null;
39
+ dispatch_attempts: number;
40
+ last_dispatched_at: string | null;
41
+ opened_at: string;
42
+ updated_at: string;
43
+ resolved_at: string | null;
44
+ expires_at: string | null;
45
+ }
46
+
47
+ export const mapIncidentRow = (row: IncidentRow): IncidentInvestigation => ({
48
+ id: row.id,
49
+ projectId: row.project_id,
50
+ environment: row.environment,
51
+ dedupKey: row.dedup_key,
52
+ status: row.status as IncidentStatus,
53
+ targetNodeId: row.target_node_id,
54
+ symptomAnchorKind: row.symptom_anchor_kind as EvidenceEntityKind,
55
+ symptomAnchorId: row.symptom_anchor_id,
56
+ symptomFingerprint: row.symptom_fingerprint,
57
+ alertCount: Number(row.alert_count ?? 1),
58
+ latestBundle: row.latest_bundle_json
59
+ ? (parseJsonRecord(row.latest_bundle_json) as Record<string, unknown>)
60
+ : null,
61
+ leaseOwnerId: row.lease_owner_id,
62
+ leaseExpiresAt: row.lease_expires_at,
63
+ dispatchAttempts: Number(row.dispatch_attempts ?? 0),
64
+ lastDispatchedAt: row.last_dispatched_at,
65
+ openedAt: row.opened_at,
66
+ updatedAt: row.updated_at,
67
+ resolvedAt: row.resolved_at,
68
+ expiresAt: row.expires_at,
69
+ });
70
+
71
+ export const ACTIVE_INCIDENT_STATUSES: ReadonlySet<IncidentStatus> = new Set([
72
+ "open",
73
+ "dispatched",
74
+ "leased",
75
+ "remediating",
76
+ "verifying",
77
+ ]);
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
+
162
+ /**
163
+ * Opens or deduplicates an active `incident_investigation` (RFC 0013 § 6.A & P2 #7).
164
+ * Multiple firing alerts or webhook retries for the same `(project_id, environment, target_node_id, symptom_category)`
165
+ * coalesce into a single active incident.
166
+ */
167
+ export async function openOrDeduplicateIncident(
168
+ db: SqlDb,
169
+ projectId: string,
170
+ input: {
171
+ id?: string;
172
+ environment?: string;
173
+ targetNodeId: string;
174
+ symptomCategory?: string;
175
+ symptomAnchorKind: EvidenceEntityKind;
176
+ symptomAnchorId: string;
177
+ symptomFingerprint?: string | null;
178
+ latestBundle?: Record<string, unknown> | null;
179
+ },
180
+ ): Promise<{ incident: IncidentInvestigation; deduplicated: boolean }> {
181
+ const nowIso = new Date().toISOString();
182
+ const environment = (input.environment || "default").trim().toLowerCase();
183
+ const category = (
184
+ input.symptomCategory ??
185
+ input.symptomFingerprint ??
186
+ `${input.symptomAnchorKind}:${input.symptomAnchorId}`
187
+ ).trim();
188
+ const dedupKey = await sha256Hex(
189
+ `${projectId}::${environment}::${input.targetNodeId}::${category}`,
190
+ );
191
+
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>();
205
+
206
+ const coalesceInto = async (
207
+ row: IncidentRow,
208
+ ): Promise<{ incident: IncidentInvestigation; deduplicated: boolean }> => {
209
+ await db
210
+ .prepare(
211
+ `UPDATE incident_investigations
212
+ SET alert_count = alert_count + 1,
213
+ latest_bundle_json = COALESCE(?, latest_bundle_json),
214
+ symptom_fingerprint = COALESCE(?, symptom_fingerprint),
215
+ updated_at = ?
216
+ WHERE project_id = ? AND environment = ? AND id = ?`,
217
+ )
218
+ .bind(
219
+ input.latestBundle ? JSON.stringify(input.latestBundle) : null,
220
+ input.symptomFingerprint ?? null,
221
+ nowIso,
222
+ projectId,
223
+ environment,
224
+ row.id,
225
+ )
226
+ .run();
227
+
228
+ const updated = await getIncidentInvestigation(
229
+ db,
230
+ projectId,
231
+ row.id,
232
+ environment,
233
+ );
234
+ return {
235
+ incident: updated ?? mapIncidentRow(row),
236
+ deduplicated: true,
237
+ };
238
+ };
239
+
240
+ const existing = await selectActive();
241
+ if (existing) return coalesceInto(existing);
242
+
243
+ const id = input.id?.trim() || `inc_${randomHex(10)}`;
244
+ const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
245
+
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
249
+ .prepare(
250
+ `INSERT INTO incident_investigations (
251
+ project_id, environment, id, dedup_key, status, target_node_id,
252
+ symptom_anchor_kind, symptom_anchor_id, symptom_fingerprint,
253
+ alert_count, latest_bundle_json, dispatch_attempts,
254
+ opened_at, updated_at, expires_at
255
+ ) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)
256
+ ON CONFLICT DO NOTHING`,
257
+ )
258
+ .bind(
259
+ projectId,
260
+ environment,
261
+ id,
262
+ dedupKey,
263
+ input.targetNodeId,
264
+ input.symptomAnchorKind,
265
+ input.symptomAnchorId,
266
+ input.symptomFingerprint ?? null,
267
+ input.latestBundle ? JSON.stringify(input.latestBundle) : null,
268
+ nowIso,
269
+ nowIso,
270
+ expiresAt,
271
+ )
272
+ .run();
273
+
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
+ );
290
+ if (!created) {
291
+ throw new Error(`Failed to load created incident ${id}`);
292
+ }
293
+ return { incident: created, deduplicated: false };
294
+ }
295
+
296
+ export async function getIncidentInvestigation(
297
+ db: SqlDb,
298
+ projectId: string,
299
+ incidentId: string,
300
+ environment?: string,
301
+ ): Promise<IncidentInvestigation | null> {
302
+ const row = environment
303
+ ? await db
304
+ .prepare(
305
+ `SELECT * FROM incident_investigations
306
+ WHERE project_id = ? AND environment = ? AND id = ?`,
307
+ )
308
+ .bind(projectId, environment.toLowerCase(), incidentId)
309
+ .first<IncidentRow>()
310
+ : await db
311
+ .prepare(
312
+ `SELECT * FROM incident_investigations
313
+ WHERE project_id = ? AND id = ?
314
+ ORDER BY opened_at DESC
315
+ LIMIT 1`,
316
+ )
317
+ .bind(projectId, incidentId)
318
+ .first<IncidentRow>();
319
+
320
+ return row ? mapIncidentRow(row) : null;
321
+ }
322
+
323
+ export async function listIncidentInvestigations(
324
+ db: SqlDb,
325
+ options: {
326
+ projectId: string;
327
+ environment?: string;
328
+ status?: string;
329
+ limit?: number;
330
+ },
331
+ ): Promise<IncidentInvestigation[]> {
332
+ const conditions: string[] = ["project_id = ?"];
333
+ const binds: unknown[] = [options.projectId];
334
+
335
+ if (options.environment) {
336
+ conditions.push("environment = ?");
337
+ binds.push(options.environment.toLowerCase());
338
+ }
339
+ if (options.status) {
340
+ conditions.push("status = ?");
341
+ binds.push(options.status);
342
+ }
343
+ const limit = Math.max(1, Math.min(200, options.limit ?? 50));
344
+ binds.push(limit);
345
+
346
+ const rows = await db
347
+ .prepare(
348
+ `SELECT * FROM incident_investigations
349
+ WHERE ${conditions.join(" AND ")}
350
+ ORDER BY opened_at DESC
351
+ LIMIT ?`,
352
+ )
353
+ .bind(...binds)
354
+ .all<IncidentRow>();
355
+
356
+ return rows.results.map(mapIncidentRow);
357
+ }
358
+
359
+ /**
360
+ * Exclusive runner lease acquisition / renewal (RFC 0013 § 6.B & P2 #7).
361
+ * Prevents competing AutoSRE runners from investigating or remediating the same
362
+ * incident simultaneously.
363
+ */
364
+ export async function acquireIncidentLease(
365
+ db: SqlDb,
366
+ projectId: string,
367
+ incidentId: string,
368
+ input: {
369
+ environment?: string;
370
+ runnerId: string;
371
+ leaseDurationSeconds?: number;
372
+ nowIso?: string;
373
+ },
374
+ ): Promise<
375
+ | { acquired: true; incident: IncidentInvestigation }
376
+ | {
377
+ acquired: false;
378
+ reason: string;
379
+ conflictOwnerId: string | null;
380
+ leaseExpiresAt: string | null;
381
+ }
382
+ > {
383
+ const nowIso = input.nowIso ?? new Date().toISOString();
384
+ const incident = await getIncidentInvestigation(
385
+ db,
386
+ projectId,
387
+ incidentId,
388
+ input.environment,
389
+ );
390
+ if (!incident) {
391
+ return {
392
+ acquired: false,
393
+ reason: `Incident ${incidentId} not found`,
394
+ conflictOwnerId: null,
395
+ leaseExpiresAt: null,
396
+ };
397
+ }
398
+
399
+ if (!ACTIVE_INCIDENT_STATUSES.has(incident.status)) {
400
+ return {
401
+ acquired: false,
402
+ reason: `Incident ${incidentId} is ${incident.status} and can no longer be leased`,
403
+ conflictOwnerId: null,
404
+ leaseExpiresAt: null,
405
+ };
406
+ }
407
+
408
+ const durationSec = Math.max(
409
+ 15,
410
+ Math.min(600, input.leaseDurationSeconds ?? 120),
411
+ );
412
+ const leaseExpiresAt = new Date(
413
+ Date.parse(nowIso) + durationSec * 1000,
414
+ ).toISOString();
415
+
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
419
+ .prepare(
420
+ `UPDATE incident_investigations
421
+ SET lease_owner_id = ?,
422
+ lease_expires_at = ?,
423
+ status = CASE WHEN status IN ('open', 'dispatched') THEN 'leased' ELSE status END,
424
+ updated_at = ?
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 <= ?)`,
431
+ )
432
+ .bind(
433
+ input.runnerId,
434
+ leaseExpiresAt,
435
+ nowIso,
436
+ projectId,
437
+ incident.environment,
438
+ incident.id,
439
+ input.runnerId,
440
+ nowIso,
441
+ )
442
+ .run();
443
+
444
+ const updated = await getIncidentInvestigation(
445
+ db,
446
+ projectId,
447
+ incident.id,
448
+ incident.environment,
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
+ }
458
+ return {
459
+ acquired: true,
460
+ incident: updated ?? {
461
+ ...incident,
462
+ leaseOwnerId: input.runnerId,
463
+ leaseExpiresAt,
464
+ updatedAt: nowIso,
465
+ },
466
+ };
467
+ }
468
+
469
+ async function computeHmacSha256Hex(
470
+ secret: string,
471
+ body: string,
472
+ ): Promise<string> {
473
+ const enc = new TextEncoder();
474
+ const key = await crypto.subtle.importKey(
475
+ "raw",
476
+ enc.encode(secret),
477
+ { name: "HMAC", hash: "SHA-256" },
478
+ false,
479
+ ["sign"],
480
+ );
481
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
482
+ const bytes = new Uint8Array(sig);
483
+ let hex = "";
484
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
485
+ return hex;
486
+ }
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
+
515
+ /**
516
+ * SSRF-safe, HMAC-SHA256 signed AutoSRE incident webhook outbox delivery
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.
527
+ */
528
+ export async function dispatchIncidentWebhook(
529
+ db: SqlDb,
530
+ projectId: string,
531
+ incidentId: string,
532
+ webhook: {
533
+ url: string;
534
+ secret?: string;
535
+ environment?: string;
536
+ allowPrivateNetworks?: boolean;
537
+ hostLookup?: HostLookup;
538
+ fetchImpl?: typeof fetch;
539
+ logger?: Logger;
540
+ grant?: { proposalId: string; status: string; grantToken: string };
541
+ },
542
+ ): Promise<{ delivered: boolean; status?: number; error?: string }> {
543
+ const logger = webhook.logger ?? consoleLogger;
544
+ const incident = await getIncidentInvestigation(
545
+ db,
546
+ projectId,
547
+ incidentId,
548
+ webhook.environment,
549
+ );
550
+ if (!incident) {
551
+ return { delivered: false, error: "Incident not found" };
552
+ }
553
+ if (!webhook.grant && incident.dispatchAttempts >= 5) {
554
+ return { delivered: false, error: "MAX_DISPATCH_ATTEMPTS (5) exceeded" };
555
+ }
556
+
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
+ }
586
+
587
+ const nowIso = new Date().toISOString();
588
+ const body = JSON.stringify({
589
+ event: webhook.grant
590
+ ? "autosre.remediation.grant_issued"
591
+ : "autosre.incident.opened",
592
+ incidentId: incident.id,
593
+ projectId: incident.projectId,
594
+ environment: incident.environment,
595
+ targetNodeId: incident.targetNodeId,
596
+ dedupKey: incident.dedupKey,
597
+ symptomAnchor: {
598
+ kind: incident.symptomAnchorKind,
599
+ id: incident.symptomAnchorId,
600
+ },
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
+ : {}),
609
+ dispatchedAt: nowIso,
610
+ });
611
+
612
+ const headers: Record<string, string> = {
613
+ "Content-Type": "application/json",
614
+ "X-Obs-Incident-Id": incident.id,
615
+ "X-Obs-Deduplication-Key": incident.dedupKey,
616
+ };
617
+ if (webhook.secret) {
618
+ const hmac = await computeHmacSha256Hex(webhook.secret, body);
619
+ headers["X-Obs-Signature-256"] = `sha256=${hmac}`;
620
+ }
621
+
622
+ const fetchFn = webhook.fetchImpl ?? fetch;
623
+ let delivered = false;
624
+ let status: number | undefined;
625
+ let errorMsg: string | undefined;
626
+
627
+ try {
628
+ const res = await fetchFn(safeUrl.toString(), {
629
+ method: "POST",
630
+ headers,
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),
636
+ });
637
+ status = 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}`;
643
+ }
644
+ } catch (err) {
645
+ errorMsg = err instanceof Error ? err.message : String(err);
646
+ }
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
+
659
+ await db
660
+ .prepare(
661
+ `UPDATE incident_investigations
662
+ SET dispatch_attempts = dispatch_attempts + 1,
663
+ last_dispatched_at = ?,
664
+ status = CASE WHEN ? = 1 AND status = 'open' THEN 'dispatched' ELSE status END,
665
+ updated_at = ?
666
+ WHERE project_id = ? AND environment = ? AND id = ?`,
667
+ )
668
+ .bind(
669
+ nowIso,
670
+ delivered ? 1 : 0,
671
+ nowIso,
672
+ projectId,
673
+ incident.environment,
674
+ incident.id,
675
+ )
676
+ .run();
677
+
678
+ return { delivered, status, error: errorMsg };
679
+ }
680
+
681
+ interface ProposalRow {
682
+ project_id: string;
683
+ environment: string;
684
+ id: string;
685
+ incident_id: string;
686
+ finding_id: string;
687
+ idempotency_key: string;
688
+ action_kind: string;
689
+ target_node_id: string;
690
+ expected_current_state_json: string;
691
+ desired_target_state_json: string;
692
+ requested_autonomy: string;
693
+ effective_autonomy: string;
694
+ policy_decision_reason: string;
695
+ status: string;
696
+ grant_token_hash: string | null;
697
+ grant_expires_at: string | null;
698
+ approved_by_user_id: string | null;
699
+ approved_at: string | null;
700
+ claimed_by_runner_id: string | null;
701
+ claimed_at: string | null;
702
+ claim_expires_at: string | null;
703
+ remediation_agent_run_id: string | null;
704
+ resulting_change_id: string | null;
705
+ failure_reason: string | null;
706
+ created_at: string;
707
+ updated_at: string;
708
+ expires_at: string | null;
709
+ }
710
+
711
+ export const mapProposalRow = (
712
+ row: ProposalRow,
713
+ grantToken?: string,
714
+ ): RemediationProposal => ({
715
+ id: row.id,
716
+ projectId: row.project_id,
717
+ environment: row.environment,
718
+ incidentId: row.incident_id,
719
+ findingId: row.finding_id,
720
+ idempotencyKey: row.idempotency_key,
721
+ actionKind: row.action_kind as RemediationActionKind,
722
+ targetNodeId: row.target_node_id,
723
+ expectedCurrentState: parseJsonRecord(
724
+ row.expected_current_state_json,
725
+ ) as unknown as RemediationStateSpec,
726
+ desiredTargetState: parseJsonRecord(
727
+ row.desired_target_state_json,
728
+ ) as unknown as RemediationStateSpec,
729
+ requestedAutonomy: row.requested_autonomy as AgentAutonomyLevel,
730
+ effectiveAutonomy: row.effective_autonomy as AgentAutonomyLevel,
731
+ policyDecisionReason: row.policy_decision_reason,
732
+ status: row.status as RemediationProposalStatus,
733
+ grantExpiresAt: row.grant_expires_at,
734
+ approvedByUserId: row.approved_by_user_id,
735
+ approvedAt: row.approved_at,
736
+ claimedByRunnerId: row.claimed_by_runner_id,
737
+ claimedAt: row.claimed_at,
738
+ claimExpiresAt: row.claim_expires_at,
739
+ remediationAgentRunId: row.remediation_agent_run_id,
740
+ resultingChangeId: row.resulting_change_id,
741
+ failureReason: row.failure_reason,
742
+ createdAt: row.created_at,
743
+ updatedAt: row.updated_at,
744
+ expiresAt: row.expires_at,
745
+ ...(grantToken ? { grantToken } : {}),
746
+ });
747
+
748
+ /**
749
+ * Evaluates the RFC 0013 § 6.B Action-Specific Authorization Matrix & Circuit Breaker
750
+ * (resolving P1 #1 and P1 #4).
751
+ */
752
+ function evaluateActionPolicyMatrix(
753
+ actionKind: RemediationActionKind,
754
+ causalProofTier: string,
755
+ recentProposalsLastHour: number,
756
+ ): {
757
+ effectiveAutonomy: AgentAutonomyLevel;
758
+ reason: string;
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).
762
+ if (recentProposalsLastHour >= 3) {
763
+ return {
764
+ effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
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)`,
766
+ };
767
+ }
768
+
769
+ switch (actionKind) {
770
+ case "open_code_pr": {
771
+ if (
772
+ causalProofTier === "counterfactual_verified" ||
773
+ causalProofTier === "cohort_rate_isolated" ||
774
+ causalProofTier === "corroborated_candidate"
775
+ ) {
776
+ return {
777
+ effectiveAutonomy: AgentAutonomyLevel.AutonomousWrite,
778
+ reason: `Non-merging PR creation permitted as autonomous_write for causalProofTier=${causalProofTier}`,
779
+ };
780
+ }
781
+ return {
782
+ effectiveAutonomy: AgentAutonomyLevel.HumanApprovedWrite,
783
+ reason: `open_code_pr requires human_approved_write when causalProofTier=${causalProofTier}`,
784
+ };
785
+ }
786
+ case "toggle_feature_flag":
787
+ case "rollback_deploy": {
788
+ if (
789
+ causalProofTier === "counterfactual_verified" ||
790
+ causalProofTier === "cohort_rate_isolated"
791
+ ) {
792
+ return {
793
+ effectiveAutonomy: AgentAutonomyLevel.AutonomousWrite,
794
+ reason: `${actionKind} authorized as autonomous_write with causalProofTier=${causalProofTier}`,
795
+ };
796
+ }
797
+ if (causalProofTier === "corroborated_candidate") {
798
+ return {
799
+ effectiveAutonomy: AgentAutonomyLevel.HumanApprovedWrite,
800
+ reason: `${actionKind} with causalProofTier=corroborated_candidate (structural overlap or sequential cutover without concurrent cohort isolation) requires human_approved_write`,
801
+ };
802
+ }
803
+ return {
804
+ effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
805
+ reason: `${actionKind} blocked_by_policy: causalProofTier=${causalProofTier} (unverified fault locus cannot authorize production ${actionKind})`,
806
+ };
807
+ }
808
+ case "apply_config":
809
+ case "promote_agent_prompt": {
810
+ if (causalProofTier === "counterfactual_verified") {
811
+ return {
812
+ effectiveAutonomy: AgentAutonomyLevel.AutonomousWrite,
813
+ reason: `${actionKind} authorized as autonomous_write with counterfactual_verified proof`,
814
+ };
815
+ }
816
+ if (
817
+ causalProofTier === "cohort_rate_isolated" ||
818
+ causalProofTier === "corroborated_candidate"
819
+ ) {
820
+ return {
821
+ effectiveAutonomy: AgentAutonomyLevel.HumanApprovedWrite,
822
+ reason: `${actionKind} requires counterfactual_verified for autonomous_write; parked for human_approved_write (tier=${causalProofTier})`,
823
+ };
824
+ }
825
+ return {
826
+ effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
827
+ reason: `${actionKind} blocked_by_policy: causalProofTier=${causalProofTier}`,
828
+ };
829
+ }
830
+ }
831
+ }
832
+
833
+ export class RemediationGateError extends Error {
834
+ constructor(
835
+ message: string,
836
+ readonly statusCode: number = 400,
837
+ ) {
838
+ super(message);
839
+ this.name = "RemediationGateError";
840
+ }
841
+ }
842
+
843
+ /**
844
+ * Pre-execution remediation proposal gate (RFC 0013 § 6.B & P1 #4).
845
+ */
846
+ export async function proposeRemediationAction(
847
+ db: SqlDb,
848
+ projectId: string,
849
+ input: RemediationProposalInput,
850
+ options: { logger?: Logger } = {},
851
+ ): Promise<RemediationProposal> {
852
+ const logger = options.logger ?? consoleLogger;
853
+ const nowIso = new Date().toISOString();
854
+ const environment = (input.environment || "default").trim().toLowerCase();
855
+
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 => {
865
+ if (
866
+ existing.action_kind !== input.actionKind ||
867
+ existing.target_node_id !== input.targetNodeId
868
+ ) {
869
+ throw new RemediationGateError(
870
+ `Idempotency key ${input.idempotencyKey} already used for a different action or target node`,
871
+ 409,
872
+ );
873
+ }
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
+ );
892
+ }
893
+
894
+ const finding = await getInvestigationFinding(
895
+ db,
896
+ projectId,
897
+ input.findingId,
898
+ environment,
899
+ );
900
+ if (!finding) {
901
+ throw new RemediationGateError(
902
+ `Finding ${input.findingId} not found in environment ${environment}`,
903
+ 404,
904
+ );
905
+ }
906
+
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.
910
+ const oneHourAgo = new Date(Date.now() - 3600_000).toISOString();
911
+ const recentCountRow = await db
912
+ .prepare(
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,
932
+ )
933
+ .first<{ cnt: number }>();
934
+
935
+ const recentCount = Number(recentCountRow?.cnt ?? 0);
936
+ const requestedAutonomy =
937
+ input.requestedAutonomy ?? AgentAutonomyLevel.AutonomousWrite;
938
+
939
+ const policy = evaluateActionPolicyMatrix(
940
+ input.actionKind,
941
+ finding.causalProofTier,
942
+ recentCount,
943
+ );
944
+
945
+ // Never escalate autonomy higher than what the caller requested
946
+ let effectiveAutonomy = policy.effectiveAutonomy;
947
+ let policyReason = policy.reason;
948
+ if (
949
+ requestedAutonomy === AgentAutonomyLevel.HumanApprovedWrite &&
950
+ effectiveAutonomy === AgentAutonomyLevel.AutonomousWrite
951
+ ) {
952
+ effectiveAutonomy = AgentAutonomyLevel.HumanApprovedWrite;
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})`;
964
+ }
965
+
966
+ let status: RemediationProposalStatus = "pending_human_approval";
967
+ let grantToken: string | undefined;
968
+ let grantTokenHash: string | null = null;
969
+ let grantExpiresAt: string | null = null;
970
+
971
+ if (effectiveAutonomy === AgentAutonomyLevel.AutonomousWrite) {
972
+ status = "grant_issued";
973
+ grantToken = `rgt_${randomHex(16)}`;
974
+ grantTokenHash = await sha256Hex(grantToken);
975
+ grantExpiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
976
+ } else if (effectiveAutonomy === AgentAutonomyLevel.BlockedByPolicy) {
977
+ status = "rejected";
978
+ }
979
+
980
+ const id = `rem_${randomHex(10)}`;
981
+ const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
982
+
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
993
+ .prepare(
994
+ `INSERT INTO remediation_proposals (
995
+ project_id, environment, id, incident_id, finding_id,
996
+ idempotency_key, action_kind, target_node_id,
997
+ expected_current_state_json, desired_target_state_json,
998
+ requested_autonomy, effective_autonomy, policy_decision_reason,
999
+ status, grant_token_hash, grant_expires_at,
1000
+ created_at, updated_at, expires_at
1001
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1002
+ ON CONFLICT DO NOTHING`,
1003
+ )
1004
+ .bind(
1005
+ projectId,
1006
+ environment,
1007
+ id,
1008
+ input.incidentId,
1009
+ input.findingId,
1010
+ input.idempotencyKey,
1011
+ input.actionKind,
1012
+ input.targetNodeId,
1013
+ JSON.stringify(input.expectedCurrentState),
1014
+ JSON.stringify(input.desiredTargetState),
1015
+ requestedAutonomy,
1016
+ effectiveAutonomy,
1017
+ policyReason,
1018
+ status,
1019
+ grantTokenHash,
1020
+ grantExpiresAt,
1021
+ nowIso,
1022
+ nowIso,
1023
+ expiresAt,
1024
+ )
1025
+ .run();
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
+
1038
+ const created = await db
1039
+ .prepare(
1040
+ `SELECT * FROM remediation_proposals
1041
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1042
+ )
1043
+ .bind(projectId, environment, id)
1044
+ .first<ProposalRow>();
1045
+
1046
+ if (!created) {
1047
+ throw new Error(`Failed to load created remediation proposal ${id}`);
1048
+ }
1049
+ return mapProposalRow(created, grantToken);
1050
+ }
1051
+
1052
+ export async function getRemediationProposal(
1053
+ db: SqlDb,
1054
+ projectId: string,
1055
+ proposalId: string,
1056
+ environment?: string,
1057
+ ): Promise<RemediationProposal | null> {
1058
+ const row = environment
1059
+ ? await db
1060
+ .prepare(
1061
+ `SELECT * FROM remediation_proposals
1062
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1063
+ )
1064
+ .bind(projectId, environment.toLowerCase(), proposalId)
1065
+ .first<ProposalRow>()
1066
+ : await db
1067
+ .prepare(
1068
+ `SELECT * FROM remediation_proposals
1069
+ WHERE project_id = ? AND id = ?
1070
+ ORDER BY created_at DESC
1071
+ LIMIT 1`,
1072
+ )
1073
+ .bind(projectId, proposalId)
1074
+ .first<ProposalRow>();
1075
+
1076
+ return row ? mapProposalRow(row) : null;
1077
+ }
1078
+
1079
+ export async function listRemediationProposals(
1080
+ db: SqlDb,
1081
+ options: {
1082
+ projectId: string;
1083
+ environment?: string;
1084
+ status?: string;
1085
+ incidentId?: string;
1086
+ findingId?: string;
1087
+ limit?: number;
1088
+ },
1089
+ ): Promise<RemediationProposal[]> {
1090
+ const conditions: string[] = ["project_id = ?"];
1091
+ const binds: unknown[] = [options.projectId];
1092
+
1093
+ if (options.environment) {
1094
+ conditions.push("environment = ?");
1095
+ binds.push(options.environment.toLowerCase());
1096
+ }
1097
+ if (options.status) {
1098
+ conditions.push("status = ?");
1099
+ binds.push(options.status);
1100
+ }
1101
+ if (options.incidentId) {
1102
+ conditions.push("incident_id = ?");
1103
+ binds.push(options.incidentId);
1104
+ }
1105
+ if (options.findingId) {
1106
+ conditions.push("finding_id = ?");
1107
+ binds.push(options.findingId);
1108
+ }
1109
+
1110
+ const limit = Math.max(1, Math.min(200, options.limit ?? 50));
1111
+ binds.push(limit);
1112
+
1113
+ const rows = await db
1114
+ .prepare(
1115
+ `SELECT * FROM remediation_proposals
1116
+ WHERE ${conditions.join(" AND ")}
1117
+ ORDER BY created_at DESC
1118
+ LIMIT ?`,
1119
+ )
1120
+ .bind(...binds)
1121
+ .all<ProposalRow>();
1122
+
1123
+ return rows.results.map((r) => mapProposalRow(r));
1124
+ }
1125
+
1126
+ /**
1127
+ * Human dashboard session approval for `pending_human_approval` proposals
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`.
1133
+ */
1134
+ export async function approveRemediationProposal(
1135
+ db: SqlDb,
1136
+ projectId: string,
1137
+ proposalId: string,
1138
+ options: {
1139
+ environment?: string;
1140
+ approvedByUserId: string;
1141
+ },
1142
+ ): Promise<RemediationProposal> {
1143
+ const row = await loadProposalRow(
1144
+ db,
1145
+ projectId,
1146
+ proposalId,
1147
+ options.environment,
1148
+ );
1149
+ if (!row) {
1150
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
1151
+ }
1152
+ if (row.status !== "pending_human_approval") {
1153
+ throw new RemediationGateError(
1154
+ `Proposal ${proposalId} cannot be approved from status=${row.status}`,
1155
+ 409,
1156
+ );
1157
+ }
1158
+
1159
+ const nowIso = new Date().toISOString();
1160
+ const grantToken = `rgt_${randomHex(16)}`;
1161
+ const grantTokenHash = await sha256Hex(grantToken);
1162
+ const grantExpiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
1163
+
1164
+ const res = await db
1165
+ .prepare(
1166
+ `UPDATE remediation_proposals
1167
+ SET status = 'grant_issued',
1168
+ grant_token_hash = ?,
1169
+ grant_expires_at = ?,
1170
+ approved_by_user_id = ?,
1171
+ approved_at = ?,
1172
+ updated_at = ?
1173
+ WHERE project_id = ? AND environment = ? AND id = ?
1174
+ AND status = 'pending_human_approval'`,
1175
+ )
1176
+ .bind(
1177
+ grantTokenHash,
1178
+ grantExpiresAt,
1179
+ options.approvedByUserId,
1180
+ nowIso,
1181
+ nowIso,
1182
+ projectId,
1183
+ row.environment,
1184
+ row.id,
1185
+ )
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
+ }
1193
+
1194
+ const updated = await db
1195
+ .prepare(
1196
+ `SELECT * FROM remediation_proposals
1197
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1198
+ )
1199
+ .bind(projectId, row.environment, row.id)
1200
+ .first<ProposalRow>();
1201
+
1202
+ return mapProposalRow(updated ?? row, grantToken);
1203
+ }
1204
+
1205
+ export async function rejectRemediationProposal(
1206
+ db: SqlDb,
1207
+ projectId: string,
1208
+ proposalId: string,
1209
+ options: {
1210
+ environment?: string;
1211
+ rejectedByUserId: string;
1212
+ reason?: string;
1213
+ },
1214
+ ): Promise<RemediationProposal> {
1215
+ const row = await loadProposalRow(
1216
+ db,
1217
+ projectId,
1218
+ proposalId,
1219
+ options.environment,
1220
+ );
1221
+ if (!row) {
1222
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
1223
+ }
1224
+ if (
1225
+ row.status !== "pending_human_approval" &&
1226
+ row.status !== "grant_issued"
1227
+ ) {
1228
+ throw new RemediationGateError(
1229
+ `Proposal ${proposalId} cannot be rejected from status=${row.status}`,
1230
+ 409,
1231
+ );
1232
+ }
1233
+
1234
+ const nowIso = new Date().toISOString();
1235
+ const reason = options.reason
1236
+ ? `Rejected by ${options.rejectedByUserId}: ${options.reason}`
1237
+ : `Rejected by ${options.rejectedByUserId}`;
1238
+
1239
+ const res = await db
1240
+ .prepare(
1241
+ `UPDATE remediation_proposals
1242
+ SET status = 'rejected',
1243
+ grant_token_hash = NULL,
1244
+ failure_reason = ?,
1245
+ updated_at = ?
1246
+ WHERE project_id = ? AND environment = ? AND id = ?
1247
+ AND status IN ('pending_human_approval', 'grant_issued')`,
1248
+ )
1249
+ .bind(reason, nowIso, projectId, row.environment, row.id)
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
+ }
1257
+
1258
+ const updated = await db
1259
+ .prepare(
1260
+ `SELECT * FROM remediation_proposals
1261
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1262
+ )
1263
+ .bind(projectId, row.environment, row.id)
1264
+ .first<ProposalRow>();
1265
+
1266
+ return mapProposalRow(updated ?? row);
1267
+ }
1268
+
1269
+ /**
1270
+ * Single-use grant redemption with optimistic `expectedCurrentState` verification
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.
1278
+ */
1279
+ export async function claimRemediationGrant(
1280
+ db: SqlDb,
1281
+ projectId: string,
1282
+ proposalId: string,
1283
+ options: {
1284
+ environment?: string;
1285
+ grantToken: string;
1286
+ runnerId?: string;
1287
+ nowIso?: string;
1288
+ logger?: Logger;
1289
+ },
1290
+ ): Promise<RemediationProposal> {
1291
+ const logger = options.logger ?? consoleLogger;
1292
+ const nowIso = options.nowIso ?? new Date().toISOString();
1293
+ const row = await loadProposalRow(
1294
+ db,
1295
+ projectId,
1296
+ proposalId,
1297
+ options.environment,
1298
+ );
1299
+
1300
+ if (!row) {
1301
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
1302
+ }
1303
+ if (row.status !== "grant_issued") {
1304
+ throw new RemediationGateError(
1305
+ `Grant cannot be claimed from status=${row.status} (grants are single-use)`,
1306
+ 409,
1307
+ );
1308
+ }
1309
+ if (row.grant_expires_at && row.grant_expires_at <= nowIso) {
1310
+ await db
1311
+ .prepare(
1312
+ `UPDATE remediation_proposals
1313
+ SET status = 'expired', grant_token_hash = NULL, updated_at = ?
1314
+ WHERE project_id = ? AND environment = ? AND id = ?
1315
+ AND status = 'grant_issued'`,
1316
+ )
1317
+ .bind(nowIso, projectId, row.environment, row.id)
1318
+ .run();
1319
+ throw new RemediationGateError(
1320
+ `Grant for proposal ${proposalId} expired at ${row.grant_expires_at}`,
1321
+ 410,
1322
+ );
1323
+ }
1324
+
1325
+ const incomingHash = await sha256Hex(options.grantToken);
1326
+ if (
1327
+ !row.grant_token_hash ||
1328
+ !constantTimeEqual(incomingHash, row.grant_token_hash)
1329
+ ) {
1330
+ throw new RemediationGateError("Invalid grantToken", 403);
1331
+ }
1332
+
1333
+ // Optimistic version/commit/config check against ontology_nodes
1334
+ const expected = parseJsonRecord(
1335
+ row.expected_current_state_json,
1336
+ ) as unknown as RemediationStateSpec;
1337
+ const targetNode = await db
1338
+ .prepare(
1339
+ `SELECT current_version, current_commit_sha, current_config_digest
1340
+ FROM ontology_nodes
1341
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1342
+ )
1343
+ .bind(projectId, row.environment, row.target_node_id)
1344
+ .first<{
1345
+ current_version: string | null;
1346
+ current_commit_sha: string | null;
1347
+ current_config_digest: string | null;
1348
+ }>();
1349
+
1350
+ if (targetNode) {
1351
+ const lockFailure =
1352
+ expected.version &&
1353
+ targetNode.current_version &&
1354
+ targetNode.current_version !== expected.version
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);
1371
+ }
1372
+ }
1373
+
1374
+ const claimExpiresAt = new Date(
1375
+ Date.parse(nowIso) + 5 * 60_000,
1376
+ ).toISOString();
1377
+ const runnerId = options.runnerId ?? "autosre-runner";
1378
+
1379
+ const results = await db.batch([
1380
+ db
1381
+ .prepare(
1382
+ `UPDATE remediation_proposals
1383
+ SET status = 'claimed_executing',
1384
+ grant_token_hash = NULL,
1385
+ claimed_by_runner_id = ?,
1386
+ claimed_at = ?,
1387
+ claim_expires_at = ?,
1388
+ updated_at = ?
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 > ?)`,
1393
+ )
1394
+ .bind(
1395
+ runnerId,
1396
+ nowIso,
1397
+ claimExpiresAt,
1398
+ nowIso,
1399
+ projectId,
1400
+ row.environment,
1401
+ row.id,
1402
+ row.grant_token_hash,
1403
+ nowIso,
1404
+ ),
1405
+ db
1406
+ .prepare(
1407
+ `UPDATE incident_investigations
1408
+ SET status = 'remediating', updated_at = ?
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
+ )`,
1417
+ )
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
+ ),
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
+ }
1436
+
1437
+ const updated = await db
1438
+ .prepare(
1439
+ `SELECT * FROM remediation_proposals
1440
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1441
+ )
1442
+ .bind(projectId, row.environment, row.id)
1443
+ .first<ProposalRow>();
1444
+
1445
+ return mapProposalRow(updated ?? row);
1446
+ }
1447
+
1448
+ /**
1449
+ * Records remediation completion, updates `ontology_nodes` to desired state,
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.
1455
+ */
1456
+ export async function completeRemediationProposal(
1457
+ db: SqlDb,
1458
+ projectId: string,
1459
+ proposalId: string,
1460
+ options: {
1461
+ environment?: string;
1462
+ runnerId?: string;
1463
+ resultingChangeId?: string | null;
1464
+ remediationAgentRunId?: string | null;
1465
+ baselineRequestRatePerMin?: number | null;
1466
+ nowIso?: string;
1467
+ },
1468
+ ): Promise<RemediationProposal> {
1469
+ const nowIso = options.nowIso ?? new Date().toISOString();
1470
+ const row = await loadProposalRow(
1471
+ db,
1472
+ projectId,
1473
+ proposalId,
1474
+ options.environment,
1475
+ );
1476
+
1477
+ if (!row) {
1478
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
1479
+ }
1480
+ if (row.status !== "claimed_executing") {
1481
+ throw new RemediationGateError(
1482
+ `Proposal ${proposalId} cannot be completed from status=${row.status} (must be claimed_executing)`,
1483
+ 409,
1484
+ );
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
+ }
1498
+
1499
+ const desired = parseJsonRecord(
1500
+ row.desired_target_state_json,
1501
+ ) as unknown as RemediationStateSpec;
1502
+ const nextWatchdogAt = new Date(
1503
+ Date.parse(nowIso) + 2 * 60_000,
1504
+ ).toISOString();
1505
+
1506
+ const finding = await getInvestigationFinding(
1507
+ db,
1508
+ projectId,
1509
+ row.finding_id,
1510
+ row.environment,
1511
+ );
1512
+ const updatedChain: CausalTriple[] = finding ? [...finding.causalChain] : [];
1513
+ if (options.resultingChangeId) {
1514
+ updatedChain.push({
1515
+ subjectKind: "finding",
1516
+ subjectId: row.finding_id,
1517
+ predicate: "mitigated_by_change",
1518
+ objectKind: "change",
1519
+ objectId: options.resultingChangeId,
1520
+ relationshipCertainty: "exact",
1521
+ causalProofTier: finding?.causalProofTier ?? "cohort_rate_isolated",
1522
+ confidence: 0.9,
1523
+ reason: `Remediation proposal ${row.id} (${row.action_kind}) applied change ${options.resultingChangeId}`,
1524
+ });
1525
+ }
1526
+ if (options.remediationAgentRunId) {
1527
+ updatedChain.push({
1528
+ subjectKind: "finding",
1529
+ subjectId: row.finding_id,
1530
+ predicate: "remediated_by_agent_run",
1531
+ objectKind: "agent_run",
1532
+ objectId: options.remediationAgentRunId,
1533
+ relationshipCertainty: "exact",
1534
+ causalProofTier: finding?.causalProofTier ?? "cohort_rate_isolated",
1535
+ confidence: 0.9,
1536
+ reason: `Remediation executed by agent run ${options.remediationAgentRunId}`,
1537
+ });
1538
+ }
1539
+
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([
1549
+ db
1550
+ .prepare(
1551
+ `UPDATE remediation_proposals
1552
+ SET status = 'completed',
1553
+ resulting_change_id = COALESCE(?, resulting_change_id),
1554
+ remediation_agent_run_id = COALESCE(?, remediation_agent_run_id),
1555
+ updated_at = ?
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 >= ?)`,
1560
+ )
1561
+ .bind(
1562
+ options.resultingChangeId ?? null,
1563
+ options.remediationAgentRunId ?? null,
1564
+ nowIso,
1565
+ projectId,
1566
+ row.environment,
1567
+ row.id,
1568
+ options.runnerId,
1569
+ nowIso,
1570
+ ),
1571
+ db
1572
+ .prepare(
1573
+ `UPDATE ontology_nodes
1574
+ SET current_version = COALESCE(?, current_version),
1575
+ current_commit_sha = COALESCE(?, current_commit_sha),
1576
+ current_config_digest = COALESCE(?, current_config_digest),
1577
+ last_seen_at = ?
1578
+ WHERE project_id = ? AND environment = ? AND id = ?
1579
+ AND ${completedGuard}`,
1580
+ )
1581
+ .bind(
1582
+ desired.version ?? null,
1583
+ desired.commitSha ?? null,
1584
+ desired.configDigest ?? null,
1585
+ nowIso,
1586
+ projectId,
1587
+ row.environment,
1588
+ row.target_node_id,
1589
+ ...guardBinds,
1590
+ ),
1591
+ db
1592
+ .prepare(
1593
+ `UPDATE investigation_findings
1594
+ SET recovery_status = 'monitoring',
1595
+ watchdog_step = 1,
1596
+ consecutive_ok_checks = 0,
1597
+ baseline_request_rate_per_min = COALESCE(?, baseline_request_rate_per_min),
1598
+ next_watchdog_check_at = ?,
1599
+ causal_chain_json = ?,
1600
+ updated_at = ?
1601
+ WHERE project_id = ? AND environment = ? AND id = ?
1602
+ AND ${completedGuard}`,
1603
+ )
1604
+ .bind(
1605
+ options.baselineRequestRatePerMin ?? null,
1606
+ nextWatchdogAt,
1607
+ JSON.stringify(updatedChain),
1608
+ nowIso,
1609
+ projectId,
1610
+ row.environment,
1611
+ row.finding_id,
1612
+ ...guardBinds,
1613
+ ),
1614
+ db
1615
+ .prepare(
1616
+ `UPDATE incident_investigations
1617
+ SET status = 'verifying', updated_at = ?
1618
+ WHERE project_id = ? AND environment = ? AND id = ?
1619
+ AND ${completedGuard}`,
1620
+ )
1621
+ .bind(nowIso, projectId, row.environment, row.incident_id, ...guardBinds),
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
+ }
1629
+
1630
+ const updated = await db
1631
+ .prepare(
1632
+ `SELECT * FROM remediation_proposals
1633
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1634
+ )
1635
+ .bind(projectId, row.environment, row.id)
1636
+ .first<ProposalRow>();
1637
+
1638
+ return mapProposalRow(updated ?? row);
1639
+ }
1640
+
1641
+ export async function failRemediationProposal(
1642
+ db: SqlDb,
1643
+ projectId: string,
1644
+ proposalId: string,
1645
+ options: {
1646
+ environment?: string;
1647
+ runnerId?: string;
1648
+ failureReason: string;
1649
+ nowIso?: string;
1650
+ },
1651
+ ): Promise<RemediationProposal> {
1652
+ const nowIso = options.nowIso ?? new Date().toISOString();
1653
+ const row = await loadProposalRow(
1654
+ db,
1655
+ projectId,
1656
+ proposalId,
1657
+ options.environment,
1658
+ );
1659
+
1660
+ if (!row) {
1661
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
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
+ }
1675
+
1676
+ const results = await db.batch([
1677
+ db
1678
+ .prepare(
1679
+ `UPDATE remediation_proposals
1680
+ SET status = 'failed_execution',
1681
+ failure_reason = ?,
1682
+ updated_at = ?
1683
+ WHERE project_id = ? AND environment = ? AND id = ?
1684
+ AND status = 'claimed_executing'
1685
+ AND claimed_by_runner_id = ?`,
1686
+ )
1687
+ .bind(
1688
+ options.failureReason,
1689
+ nowIso,
1690
+ projectId,
1691
+ row.environment,
1692
+ row.id,
1693
+ options.runnerId,
1694
+ ),
1695
+ db
1696
+ .prepare(
1697
+ `UPDATE incident_investigations
1698
+ SET status = 'escalated', updated_at = ?
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
+ )`,
1705
+ )
1706
+ .bind(
1707
+ nowIso,
1708
+ projectId,
1709
+ row.environment,
1710
+ row.incident_id,
1711
+ projectId,
1712
+ row.environment,
1713
+ row.id,
1714
+ nowIso,
1715
+ ),
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
+ }
1723
+
1724
+ const updated = await db
1725
+ .prepare(
1726
+ `SELECT * FROM remediation_proposals
1727
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1728
+ )
1729
+ .bind(projectId, row.environment, row.id)
1730
+ .first<ProposalRow>();
1731
+
1732
+ return mapProposalRow(updated ?? row);
1733
+ }
1734
+
1735
+ export interface WatchdogEvaluationSummary {
1736
+ evaluated: number;
1737
+ recovered: number;
1738
+ inconclusiveLowTraffic: number;
1739
+ stillDegraded: number;
1740
+ timedOutClaims: number;
1741
+ expiredGrants: number;
1742
+ /** Findings whose evaluation threw; logged and retried on the next tick. */
1743
+ failed: number;
1744
+ }
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
+
1755
+ /**
1756
+ * Post-Remediation Watchdog & Lease/Claim Timeout Evaluator (RFC 0013 § 5.B, § 6.B & P1 #3).
1757
+ *
1758
+ * Strictly separates:
1759
+ * - `recovery_status`: whether the service recovered under comparable traffic (`N >= minComparableRequests`)
1760
+ * - `causal_verification_status`: `"operationally_corroborated"` (never promoted to `"counterfactual_verified"`
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.
1768
+ */
1769
+ export async function evaluateRemediationWatchdogs(
1770
+ db: SqlDb,
1771
+ options: {
1772
+ nowIso?: string;
1773
+ minComparableRequests?: number;
1774
+ projectId?: string;
1775
+ logger?: Logger;
1776
+ } = {},
1777
+ ): Promise<WatchdogEvaluationSummary> {
1778
+ const logger = options.logger ?? consoleLogger;
1779
+ const nowIso = options.nowIso ?? new Date().toISOString();
1780
+ const minComparableRequests = options.minComparableRequests ?? 50;
1781
+ const projectId = options.projectId;
1782
+ const projectClause = projectId ? " AND project_id = ?" : "";
1783
+ const projectBinds = projectId ? [projectId] : [];
1784
+
1785
+ // 1. Expire unclaimed grants past grant_expires_at
1786
+ const expiredGrantsRes = await db
1787
+ .prepare(
1788
+ `UPDATE remediation_proposals
1789
+ SET status = 'expired', grant_token_hash = NULL, updated_at = ?
1790
+ WHERE status = 'grant_issued'
1791
+ AND grant_expires_at IS NOT NULL
1792
+ AND grant_expires_at <= ?${projectClause}`,
1793
+ )
1794
+ .bind(nowIso, nowIso, ...projectBinds)
1795
+ .run();
1796
+
1797
+ // 2. Time out claimed_executing proposals past claim_expires_at and escalate their incidents
1798
+ const timedOutRows = await db
1799
+ .prepare(
1800
+ `SELECT project_id, environment, id, incident_id
1801
+ FROM remediation_proposals
1802
+ WHERE status = 'claimed_executing'
1803
+ AND claim_expires_at IS NOT NULL
1804
+ AND claim_expires_at <= ?${projectClause}`,
1805
+ )
1806
+ .bind(nowIso, ...projectBinds)
1807
+ .all<{
1808
+ project_id: string;
1809
+ environment: string;
1810
+ id: string;
1811
+ incident_id: string;
1812
+ }>();
1813
+
1814
+ let timedOutClaims = 0;
1815
+ for (const t of timedOutRows.results) {
1816
+ const res = await db.batch([
1817
+ db
1818
+ .prepare(
1819
+ `UPDATE remediation_proposals
1820
+ SET status = 'execution_timed_out',
1821
+ failure_reason = 'Runner did not complete or fail within 5m claim window',
1822
+ updated_at = ?
1823
+ WHERE project_id = ? AND environment = ? AND id = ?
1824
+ AND status = 'claimed_executing'`,
1825
+ )
1826
+ .bind(nowIso, t.project_id, t.environment, t.id),
1827
+ db
1828
+ .prepare(
1829
+ `UPDATE incident_investigations
1830
+ SET status = 'escalated', updated_at = ?
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
+ )`,
1837
+ )
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
+ ),
1848
+ ]);
1849
+ if (res[0]?.meta.changes === 1) timedOutClaims += 1;
1850
+ }
1851
+
1852
+ // 3. Evaluate findings in `monitoring` where `next_watchdog_check_at <= nowIso`
1853
+ const dueFindings = await db
1854
+ .prepare(
1855
+ `SELECT
1856
+ project_id, environment, id, incident_id,
1857
+ fault_locus_entity_id, root_cause_entity_id,
1858
+ watchdog_step, consecutive_ok_checks, baseline_request_rate_per_min,
1859
+ causal_verification_status, updated_at
1860
+ FROM investigation_findings
1861
+ WHERE recovery_status = 'monitoring'
1862
+ AND next_watchdog_check_at IS NOT NULL
1863
+ AND next_watchdog_check_at <= ?${projectClause}
1864
+ LIMIT 50`,
1865
+ )
1866
+ .bind(nowIso, ...projectBinds)
1867
+ .all<{
1868
+ project_id: string;
1869
+ environment: string;
1870
+ id: string;
1871
+ incident_id: string | null;
1872
+ fault_locus_entity_id: string;
1873
+ root_cause_entity_id: string | null;
1874
+ watchdog_step: number;
1875
+ consecutive_ok_checks: number | null;
1876
+ baseline_request_rate_per_min: number | null;
1877
+ causal_verification_status: string;
1878
+ updated_at: string;
1879
+ }>();
1880
+
1881
+ let recovered = 0;
1882
+ let inconclusiveLowTraffic = 0;
1883
+ let stillDegraded = 0;
1884
+ let failed = 0;
1885
+
1886
+ for (const f of dueFindings.results) {
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,
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;
2016
+ await db
2017
+ .prepare(
2018
+ `UPDATE investigation_findings
2019
+ SET recovery_status = 'still_degraded',
2020
+ causal_verification_status = 'refuted',
2021
+ consecutive_ok_checks = 0,
2022
+ next_watchdog_check_at = NULL,
2023
+ updated_at = ?
2024
+ WHERE project_id = ? AND environment = ? AND id = ?`,
2025
+ )
2026
+ .bind(nowIso, f.project_id, f.environment, f.id)
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;
2039
+ }
2040
+
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();
2051
+ await db
2052
+ .prepare(
2053
+ `UPDATE investigation_findings
2054
+ SET watchdog_step = ?, consecutive_ok_checks = ?,
2055
+ next_watchdog_check_at = ?, updated_at = ?
2056
+ WHERE project_id = ? AND environment = ? AND id = ?`,
2057
+ )
2058
+ .bind(
2059
+ nextStep,
2060
+ okChecks,
2061
+ nextCheck,
2062
+ nowIso,
2063
+ f.project_id,
2064
+ f.environment,
2065
+ f.id,
2066
+ )
2067
+ .run();
2068
+ continue;
2069
+ }
2070
+
2071
+ recovered += 1;
2072
+ const nextCausalVerification =
2073
+ f.causal_verification_status === "counterfactual_verified"
2074
+ ? "counterfactual_verified"
2075
+ : "operationally_corroborated";
2076
+
2077
+ await db
2078
+ .prepare(
2079
+ `UPDATE investigation_findings
2080
+ SET recovery_status = 'recovered',
2081
+ causal_verification_status = ?,
2082
+ watchdog_step = 3,
2083
+ consecutive_ok_checks = ?,
2084
+ next_watchdog_check_at = NULL,
2085
+ updated_at = ?
2086
+ WHERE project_id = ? AND environment = ? AND id = ?`,
2087
+ )
2088
+ .bind(
2089
+ nextCausalVerification,
2090
+ okChecks,
2091
+ nowIso,
2092
+ f.project_id,
2093
+ f.environment,
2094
+ f.id,
2095
+ )
2096
+ .run();
2097
+
2098
+ if (f.incident_id) {
2099
+ await db
2100
+ .prepare(
2101
+ `UPDATE incident_investigations
2102
+ SET status = 'resolved', resolved_at = ?, updated_at = ?
2103
+ WHERE project_id = ? AND environment = ? AND id = ?`,
2104
+ )
2105
+ .bind(nowIso, nowIso, f.project_id, f.environment, f.incident_id)
2106
+ .run();
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
+ });
2115
+ }
2116
+ }
2117
+
2118
+ return {
2119
+ evaluated: dueFindings.results.length,
2120
+ recovered,
2121
+ inconclusiveLowTraffic,
2122
+ stillDegraded,
2123
+ timedOutClaims,
2124
+ expiredGrants: expiredGrantsRes.meta.changes,
2125
+ failed,
2126
+ };
2127
+ }