@obsunified/collector 2.1.1 → 2.2.0

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 (33) 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 +309 -2
  4. package/dist/index.js +4159 -256
  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 +1275 -0
  9. package/src/index.ts +43 -0
  10. package/src/lib/analyses-runner.ts +9 -0
  11. package/src/lib/autosre-change-correlation.ts +780 -0
  12. package/src/lib/autosre-findings.ts +725 -0
  13. package/src/lib/autosre-ontology.ts +914 -0
  14. package/src/lib/autosre-remediation-gate.ts +1663 -0
  15. package/src/lib/eval-cases-store.ts +63 -1
  16. package/src/lib/evidence-references.ts +6 -0
  17. package/src/lib/housekeeping.test.ts +80 -0
  18. package/src/lib/housekeeping.ts +101 -1
  19. package/src/lib/store/ingest.ts +94 -6
  20. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  21. package/src/migrations/alert-state-notified.test.ts +3 -3
  22. package/src/migrations/parity.test.ts +4 -0
  23. package/src/migrations/project-scoped-keys.test.ts +1 -1
  24. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  25. package/src/plugins/alerts-evaluator.ts +17 -1
  26. package/src/plugins/autosre-routes.ts +1019 -0
  27. package/src/plugins/connected-routes/manifest.ts +13 -1
  28. package/src/plugins/connected-routes.ts +271 -0
  29. package/src/plugins/default-span-enrichment.ts +13 -1
  30. package/src/plugins/eval-cases-routes.ts +57 -2
  31. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  32. package/dist/chunk-XKQTVIRE.js.map +0 -1
  33. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -0,0 +1,1663 @@
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 { AgentAutonomyLevel } from "@obsunified/types/constants";
13
+ import { getInvestigationFinding } from "./autosre-findings";
14
+ import { randomHex, sha256Hex } from "./hash";
15
+ import { parseJsonRecord } from "./json";
16
+ import type { SqlDb } from "./sql-db";
17
+ import { assertSafeWebhookUrl } from "./webhook-url";
18
+
19
+ interface IncidentRow {
20
+ project_id: string;
21
+ environment: string;
22
+ id: string;
23
+ dedup_key: string;
24
+ status: string;
25
+ target_node_id: string;
26
+ symptom_anchor_kind: string;
27
+ symptom_anchor_id: string;
28
+ symptom_fingerprint: string | null;
29
+ alert_count: number;
30
+ latest_bundle_json: string | null;
31
+ lease_owner_id: string | null;
32
+ lease_expires_at: string | null;
33
+ dispatch_attempts: number;
34
+ last_dispatched_at: string | null;
35
+ opened_at: string;
36
+ updated_at: string;
37
+ resolved_at: string | null;
38
+ expires_at: string | null;
39
+ }
40
+
41
+ export const mapIncidentRow = (row: IncidentRow): IncidentInvestigation => ({
42
+ id: row.id,
43
+ projectId: row.project_id,
44
+ environment: row.environment,
45
+ dedupKey: row.dedup_key,
46
+ status: row.status as IncidentStatus,
47
+ targetNodeId: row.target_node_id,
48
+ symptomAnchorKind: row.symptom_anchor_kind as EvidenceEntityKind,
49
+ symptomAnchorId: row.symptom_anchor_id,
50
+ symptomFingerprint: row.symptom_fingerprint,
51
+ alertCount: Number(row.alert_count ?? 1),
52
+ latestBundle: row.latest_bundle_json
53
+ ? (parseJsonRecord(row.latest_bundle_json) as Record<string, unknown>)
54
+ : null,
55
+ leaseOwnerId: row.lease_owner_id,
56
+ leaseExpiresAt: row.lease_expires_at,
57
+ dispatchAttempts: Number(row.dispatch_attempts ?? 0),
58
+ lastDispatchedAt: row.last_dispatched_at,
59
+ openedAt: row.opened_at,
60
+ updatedAt: row.updated_at,
61
+ resolvedAt: row.resolved_at,
62
+ expiresAt: row.expires_at,
63
+ });
64
+
65
+ export const ACTIVE_INCIDENT_STATUSES: ReadonlySet<IncidentStatus> = new Set([
66
+ "open",
67
+ "dispatched",
68
+ "leased",
69
+ "remediating",
70
+ "verifying",
71
+ ]);
72
+
73
+ /**
74
+ * Opens or deduplicates an active `incident_investigation` (RFC 0013 § 6.A & P2 #7).
75
+ * Multiple firing alerts or webhook retries for the same `(project_id, environment, target_node_id, symptom_category)`
76
+ * coalesce into a single active incident.
77
+ */
78
+ export async function openOrDeduplicateIncident(
79
+ db: SqlDb,
80
+ projectId: string,
81
+ input: {
82
+ id?: string;
83
+ environment?: string;
84
+ targetNodeId: string;
85
+ symptomCategory?: string;
86
+ symptomAnchorKind: EvidenceEntityKind;
87
+ symptomAnchorId: string;
88
+ symptomFingerprint?: string | null;
89
+ latestBundle?: Record<string, unknown> | null;
90
+ },
91
+ ): Promise<{ incident: IncidentInvestigation; deduplicated: boolean }> {
92
+ const nowIso = new Date().toISOString();
93
+ const environment = (input.environment || "default").trim().toLowerCase();
94
+ const category = (
95
+ input.symptomCategory ??
96
+ input.symptomFingerprint ??
97
+ `${input.symptomAnchorKind}:${input.symptomAnchorId}`
98
+ ).trim();
99
+ const dedupKey = await sha256Hex(
100
+ `${projectId}::${environment}::${input.targetNodeId}::${category}`,
101
+ );
102
+
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>();
115
+
116
+ if (existing) {
117
+ await db
118
+ .prepare(
119
+ `UPDATE incident_investigations
120
+ SET alert_count = alert_count + 1,
121
+ latest_bundle_json = COALESCE(?, latest_bundle_json),
122
+ symptom_fingerprint = COALESCE(?, symptom_fingerprint),
123
+ updated_at = ?
124
+ WHERE project_id = ? AND environment = ? AND id = ?`,
125
+ )
126
+ .bind(
127
+ input.latestBundle ? JSON.stringify(input.latestBundle) : null,
128
+ input.symptomFingerprint ?? null,
129
+ nowIso,
130
+ projectId,
131
+ environment,
132
+ existing.id,
133
+ )
134
+ .run();
135
+
136
+ const updated = await getIncidentInvestigation(
137
+ db,
138
+ projectId,
139
+ existing.id,
140
+ environment,
141
+ );
142
+ return {
143
+ incident: updated ?? mapIncidentRow(existing),
144
+ deduplicated: true,
145
+ };
146
+ }
147
+
148
+ const id = input.id?.trim() || `inc_${randomHex(10)}`;
149
+ const expiresAt = new Date(
150
+ Date.now() + 30 * 24 * 3600_000,
151
+ ).toISOString();
152
+
153
+ await db
154
+ .prepare(
155
+ `INSERT INTO incident_investigations (
156
+ project_id, environment, id, dedup_key, status, target_node_id,
157
+ symptom_anchor_kind, symptom_anchor_id, symptom_fingerprint,
158
+ alert_count, latest_bundle_json, dispatch_attempts,
159
+ opened_at, updated_at, expires_at
160
+ ) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)`,
161
+ )
162
+ .bind(
163
+ projectId,
164
+ environment,
165
+ id,
166
+ dedupKey,
167
+ input.targetNodeId,
168
+ input.symptomAnchorKind,
169
+ input.symptomAnchorId,
170
+ input.symptomFingerprint ?? null,
171
+ input.latestBundle ? JSON.stringify(input.latestBundle) : null,
172
+ nowIso,
173
+ nowIso,
174
+ expiresAt,
175
+ )
176
+ .run();
177
+
178
+ const created = await getIncidentInvestigation(db, projectId, id, environment);
179
+ if (!created) {
180
+ throw new Error(`Failed to load created incident ${id}`);
181
+ }
182
+ return { incident: created, deduplicated: false };
183
+ }
184
+
185
+ export async function getIncidentInvestigation(
186
+ db: SqlDb,
187
+ projectId: string,
188
+ incidentId: string,
189
+ environment?: string,
190
+ ): Promise<IncidentInvestigation | null> {
191
+ const row = environment
192
+ ? await db
193
+ .prepare(
194
+ `SELECT * FROM incident_investigations
195
+ WHERE project_id = ? AND environment = ? AND id = ?`,
196
+ )
197
+ .bind(projectId, environment.toLowerCase(), incidentId)
198
+ .first<IncidentRow>()
199
+ : await db
200
+ .prepare(
201
+ `SELECT * FROM incident_investigations
202
+ WHERE project_id = ? AND id = ?
203
+ ORDER BY opened_at DESC
204
+ LIMIT 1`,
205
+ )
206
+ .bind(projectId, incidentId)
207
+ .first<IncidentRow>();
208
+
209
+ return row ? mapIncidentRow(row) : null;
210
+ }
211
+
212
+ export async function listIncidentInvestigations(
213
+ db: SqlDb,
214
+ options: {
215
+ projectId: string;
216
+ environment?: string;
217
+ status?: string;
218
+ limit?: number;
219
+ },
220
+ ): Promise<IncidentInvestigation[]> {
221
+ const conditions: string[] = ["project_id = ?"];
222
+ const binds: unknown[] = [options.projectId];
223
+
224
+ if (options.environment) {
225
+ conditions.push("environment = ?");
226
+ binds.push(options.environment.toLowerCase());
227
+ }
228
+ if (options.status) {
229
+ conditions.push("status = ?");
230
+ binds.push(options.status);
231
+ }
232
+ const limit = Math.max(1, Math.min(100, options.limit ?? 50));
233
+ binds.push(limit);
234
+
235
+ const rows = await db
236
+ .prepare(
237
+ `SELECT * FROM incident_investigations
238
+ WHERE ${conditions.join(" AND ")}
239
+ ORDER BY opened_at DESC
240
+ LIMIT ?`,
241
+ )
242
+ .bind(...binds)
243
+ .all<IncidentRow>();
244
+
245
+ return rows.results.map(mapIncidentRow);
246
+ }
247
+
248
+ /**
249
+ * Exclusive runner lease acquisition / renewal (RFC 0013 § 6.B & P2 #7).
250
+ * Prevents competing AutoSRE runners from investigating or remediating the same
251
+ * incident simultaneously.
252
+ */
253
+ export async function acquireIncidentLease(
254
+ db: SqlDb,
255
+ projectId: string,
256
+ incidentId: string,
257
+ input: {
258
+ environment?: string;
259
+ runnerId: string;
260
+ leaseDurationSeconds?: number;
261
+ nowIso?: string;
262
+ },
263
+ ): Promise<
264
+ | { acquired: true; incident: IncidentInvestigation }
265
+ | {
266
+ acquired: false;
267
+ reason: string;
268
+ conflictOwnerId: string | null;
269
+ leaseExpiresAt: string | null;
270
+ }
271
+ > {
272
+ const nowIso = input.nowIso ?? new Date().toISOString();
273
+ const incident = await getIncidentInvestigation(
274
+ db,
275
+ projectId,
276
+ incidentId,
277
+ input.environment,
278
+ );
279
+ if (!incident) {
280
+ return {
281
+ acquired: false,
282
+ reason: `Incident ${incidentId} not found`,
283
+ conflictOwnerId: null,
284
+ leaseExpiresAt: null,
285
+ };
286
+ }
287
+
288
+ if (
289
+ incident.leaseOwnerId &&
290
+ incident.leaseOwnerId !== input.runnerId &&
291
+ incident.leaseExpiresAt &&
292
+ incident.leaseExpiresAt > nowIso
293
+ ) {
294
+ return {
295
+ acquired: false,
296
+ reason: `Incident ${incidentId} is already leased by runner ${incident.leaseOwnerId} until ${incident.leaseExpiresAt}`,
297
+ conflictOwnerId: incident.leaseOwnerId,
298
+ leaseExpiresAt: incident.leaseExpiresAt,
299
+ };
300
+ }
301
+
302
+ const durationSec = Math.max(
303
+ 15,
304
+ Math.min(600, input.leaseDurationSeconds ?? 120),
305
+ );
306
+ const leaseExpiresAt = new Date(
307
+ Date.parse(nowIso) + durationSec * 1000,
308
+ ).toISOString();
309
+ const nextStatus: IncidentStatus =
310
+ incident.status === "open" || incident.status === "dispatched"
311
+ ? "leased"
312
+ : incident.status;
313
+
314
+ await db
315
+ .prepare(
316
+ `UPDATE incident_investigations
317
+ SET lease_owner_id = ?,
318
+ lease_expires_at = ?,
319
+ status = ?,
320
+ updated_at = ?
321
+ WHERE project_id = ? AND environment = ? AND id = ?`,
322
+ )
323
+ .bind(
324
+ input.runnerId,
325
+ leaseExpiresAt,
326
+ nextStatus,
327
+ nowIso,
328
+ projectId,
329
+ incident.environment,
330
+ incident.id,
331
+ )
332
+ .run();
333
+
334
+ const updated = await getIncidentInvestigation(
335
+ db,
336
+ projectId,
337
+ incident.id,
338
+ incident.environment,
339
+ );
340
+ return {
341
+ acquired: true,
342
+ incident: updated ?? {
343
+ ...incident,
344
+ leaseOwnerId: input.runnerId,
345
+ leaseExpiresAt,
346
+ status: nextStatus,
347
+ updatedAt: nowIso,
348
+ },
349
+ };
350
+ }
351
+
352
+ async function computeHmacSha256Hex(
353
+ secret: string,
354
+ body: string,
355
+ ): Promise<string> {
356
+ const enc = new TextEncoder();
357
+ const key = await crypto.subtle.importKey(
358
+ "raw",
359
+ enc.encode(secret),
360
+ { name: "HMAC", hash: "SHA-256" },
361
+ false,
362
+ ["sign"],
363
+ );
364
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
365
+ const bytes = new Uint8Array(sig);
366
+ let hex = "";
367
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
368
+ return hex;
369
+ }
370
+
371
+ /**
372
+ * SSRF-safe, HMAC-SHA256 signed AutoSRE incident webhook outbox delivery
373
+ * (RFC 0013 § 6.A).
374
+ */
375
+ export async function dispatchIncidentWebhook(
376
+ db: SqlDb,
377
+ projectId: string,
378
+ incidentId: string,
379
+ webhook: {
380
+ url: string;
381
+ secret?: string;
382
+ allowPrivateNetworks?: boolean;
383
+ fetchImpl?: typeof fetch;
384
+ },
385
+ ): Promise<{ delivered: boolean; status?: number; error?: string }> {
386
+ const incident = await getIncidentInvestigation(db, projectId, incidentId);
387
+ if (!incident) {
388
+ return { delivered: false, error: "Incident not found" };
389
+ }
390
+ if (incident.dispatchAttempts >= 5) {
391
+ return { delivered: false, error: "MAX_DISPATCH_ATTEMPTS (5) exceeded" };
392
+ }
393
+
394
+ const safeUrl = assertSafeWebhookUrl(webhook.url, {
395
+ allowPrivateNetworks: webhook.allowPrivateNetworks,
396
+ });
397
+
398
+ const nowIso = new Date().toISOString();
399
+ const body = JSON.stringify({
400
+ event: "autosre.incident.opened",
401
+ incidentId: incident.id,
402
+ projectId: incident.projectId,
403
+ environment: incident.environment,
404
+ targetNodeId: incident.targetNodeId,
405
+ dedupKey: incident.dedupKey,
406
+ symptomAnchor: {
407
+ kind: incident.symptomAnchorKind,
408
+ id: incident.symptomAnchorId,
409
+ },
410
+ bundle: incident.latestBundle,
411
+ dispatchedAt: nowIso,
412
+ });
413
+
414
+ const headers: Record<string, string> = {
415
+ "Content-Type": "application/json",
416
+ "X-Obs-Incident-Id": incident.id,
417
+ "X-Obs-Deduplication-Key": incident.dedupKey,
418
+ };
419
+ if (webhook.secret) {
420
+ const hmac = await computeHmacSha256Hex(webhook.secret, body);
421
+ headers["X-Obs-Signature-256"] = `sha256=${hmac}`;
422
+ }
423
+
424
+ const fetchFn = webhook.fetchImpl ?? fetch;
425
+ let delivered = false;
426
+ let status: number | undefined;
427
+ let errorMsg: string | undefined;
428
+
429
+ try {
430
+ const res = await fetchFn(safeUrl.toString(), {
431
+ method: "POST",
432
+ headers,
433
+ body,
434
+ });
435
+ status = res.status;
436
+ delivered = res.ok;
437
+ if (!res.ok) {
438
+ errorMsg = `HTTP ${res.status}`;
439
+ }
440
+ } catch (err) {
441
+ errorMsg = err instanceof Error ? err.message : String(err);
442
+ }
443
+
444
+ await db
445
+ .prepare(
446
+ `UPDATE incident_investigations
447
+ SET dispatch_attempts = dispatch_attempts + 1,
448
+ last_dispatched_at = ?,
449
+ status = CASE WHEN ? = 1 AND status = 'open' THEN 'dispatched' ELSE status END,
450
+ updated_at = ?
451
+ WHERE project_id = ? AND environment = ? AND id = ?`,
452
+ )
453
+ .bind(
454
+ nowIso,
455
+ delivered ? 1 : 0,
456
+ nowIso,
457
+ projectId,
458
+ incident.environment,
459
+ incident.id,
460
+ )
461
+ .run();
462
+
463
+ return { delivered, status, error: errorMsg };
464
+ }
465
+
466
+ interface ProposalRow {
467
+ project_id: string;
468
+ environment: string;
469
+ id: string;
470
+ incident_id: string;
471
+ finding_id: string;
472
+ idempotency_key: string;
473
+ action_kind: string;
474
+ target_node_id: string;
475
+ expected_current_state_json: string;
476
+ desired_target_state_json: string;
477
+ requested_autonomy: string;
478
+ effective_autonomy: string;
479
+ policy_decision_reason: string;
480
+ status: string;
481
+ grant_token_hash: string | null;
482
+ grant_expires_at: string | null;
483
+ approved_by_user_id: string | null;
484
+ approved_at: string | null;
485
+ claimed_by_runner_id: string | null;
486
+ claimed_at: string | null;
487
+ claim_expires_at: string | null;
488
+ remediation_agent_run_id: string | null;
489
+ resulting_change_id: string | null;
490
+ failure_reason: string | null;
491
+ created_at: string;
492
+ updated_at: string;
493
+ expires_at: string | null;
494
+ }
495
+
496
+ export const mapProposalRow = (
497
+ row: ProposalRow,
498
+ grantToken?: string,
499
+ ): RemediationProposal => ({
500
+ id: row.id,
501
+ projectId: row.project_id,
502
+ environment: row.environment,
503
+ incidentId: row.incident_id,
504
+ findingId: row.finding_id,
505
+ idempotencyKey: row.idempotency_key,
506
+ actionKind: row.action_kind as RemediationActionKind,
507
+ targetNodeId: row.target_node_id,
508
+ expectedCurrentState: parseJsonRecord(
509
+ row.expected_current_state_json,
510
+ ) as unknown as RemediationStateSpec,
511
+ desiredTargetState: parseJsonRecord(
512
+ row.desired_target_state_json,
513
+ ) as unknown as RemediationStateSpec,
514
+ requestedAutonomy: row.requested_autonomy as AgentAutonomyLevel,
515
+ effectiveAutonomy: row.effective_autonomy as AgentAutonomyLevel,
516
+ policyDecisionReason: row.policy_decision_reason,
517
+ status: row.status as RemediationProposalStatus,
518
+ grantExpiresAt: row.grant_expires_at,
519
+ approvedByUserId: row.approved_by_user_id,
520
+ approvedAt: row.approved_at,
521
+ claimedByRunnerId: row.claimed_by_runner_id,
522
+ claimedAt: row.claimed_at,
523
+ claimExpiresAt: row.claim_expires_at,
524
+ remediationAgentRunId: row.remediation_agent_run_id,
525
+ resultingChangeId: row.resulting_change_id,
526
+ failureReason: row.failure_reason,
527
+ createdAt: row.created_at,
528
+ updatedAt: row.updated_at,
529
+ expiresAt: row.expires_at,
530
+ ...(grantToken ? { grantToken } : {}),
531
+ });
532
+
533
+ /**
534
+ * Evaluates the RFC 0013 § 6.B Action-Specific Authorization Matrix & Circuit Breaker
535
+ * (resolving P1 #1 and P1 #4).
536
+ */
537
+ function evaluateActionPolicyMatrix(
538
+ actionKind: RemediationActionKind,
539
+ causalProofTier: string,
540
+ recentProposalsLastHour: number,
541
+ ): {
542
+ effectiveAutonomy: AgentAutonomyLevel;
543
+ reason: string;
544
+ } {
545
+ if (recentProposalsLastHour >= 3) {
546
+ return {
547
+ effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
548
+ reason: `Circuit breaker tripped: ${recentProposalsLastHour} remediation proposals already recorded in the last hour for this target/fingerprint (max 3/hr)`,
549
+ };
550
+ }
551
+
552
+ switch (actionKind) {
553
+ case "open_code_pr": {
554
+ if (
555
+ causalProofTier === "counterfactual_verified" ||
556
+ causalProofTier === "cohort_rate_isolated" ||
557
+ causalProofTier === "corroborated_candidate"
558
+ ) {
559
+ return {
560
+ effectiveAutonomy: AgentAutonomyLevel.AutonomousWrite,
561
+ reason: `Non-merging PR creation permitted as autonomous_write for causalProofTier=${causalProofTier}`,
562
+ };
563
+ }
564
+ return {
565
+ effectiveAutonomy: AgentAutonomyLevel.HumanApprovedWrite,
566
+ reason: `open_code_pr requires human_approved_write when causalProofTier=${causalProofTier}`,
567
+ };
568
+ }
569
+ case "toggle_feature_flag":
570
+ case "rollback_deploy": {
571
+ if (
572
+ causalProofTier === "counterfactual_verified" ||
573
+ causalProofTier === "cohort_rate_isolated"
574
+ ) {
575
+ return {
576
+ effectiveAutonomy: AgentAutonomyLevel.AutonomousWrite,
577
+ reason: `${actionKind} authorized as autonomous_write with causalProofTier=${causalProofTier}`,
578
+ };
579
+ }
580
+ if (causalProofTier === "corroborated_candidate") {
581
+ return {
582
+ effectiveAutonomy: AgentAutonomyLevel.HumanApprovedWrite,
583
+ reason: `${actionKind} with causalProofTier=corroborated_candidate (structural overlap or sequential cutover without concurrent cohort isolation) requires human_approved_write`,
584
+ };
585
+ }
586
+ return {
587
+ effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
588
+ reason: `${actionKind} blocked_by_policy: causalProofTier=${causalProofTier} (unverified fault locus cannot authorize production ${actionKind})`,
589
+ };
590
+ }
591
+ case "apply_config":
592
+ case "promote_agent_prompt": {
593
+ if (causalProofTier === "counterfactual_verified") {
594
+ return {
595
+ effectiveAutonomy: AgentAutonomyLevel.AutonomousWrite,
596
+ reason: `${actionKind} authorized as autonomous_write with counterfactual_verified proof`,
597
+ };
598
+ }
599
+ if (
600
+ causalProofTier === "cohort_rate_isolated" ||
601
+ causalProofTier === "corroborated_candidate"
602
+ ) {
603
+ return {
604
+ effectiveAutonomy: AgentAutonomyLevel.HumanApprovedWrite,
605
+ reason: `${actionKind} requires counterfactual_verified for autonomous_write; parked for human_approved_write (tier=${causalProofTier})`,
606
+ };
607
+ }
608
+ return {
609
+ effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
610
+ reason: `${actionKind} blocked_by_policy: causalProofTier=${causalProofTier}`,
611
+ };
612
+ }
613
+ }
614
+ }
615
+
616
+ export class RemediationGateError extends Error {
617
+ constructor(
618
+ message: string,
619
+ readonly statusCode: number = 400,
620
+ ) {
621
+ super(message);
622
+ this.name = "RemediationGateError";
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Pre-execution remediation proposal gate (RFC 0013 § 6.B & P1 #4).
628
+ */
629
+ export async function proposeRemediationAction(
630
+ db: SqlDb,
631
+ projectId: string,
632
+ input: RemediationProposalInput,
633
+ ): Promise<RemediationProposal> {
634
+ const nowIso = new Date().toISOString();
635
+ const environment = (input.environment || "default").trim().toLowerCase();
636
+
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) {
647
+ if (
648
+ existing.action_kind !== input.actionKind ||
649
+ existing.target_node_id !== input.targetNodeId
650
+ ) {
651
+ throw new RemediationGateError(
652
+ `Idempotency key ${input.idempotencyKey} already used for a different action or target node`,
653
+ 409,
654
+ );
655
+ }
656
+ return mapProposalRow(existing);
657
+ }
658
+
659
+ const finding = await getInvestigationFinding(
660
+ db,
661
+ projectId,
662
+ input.findingId,
663
+ environment,
664
+ );
665
+ if (!finding) {
666
+ throw new RemediationGateError(
667
+ `Finding ${input.findingId} not found in environment ${environment}`,
668
+ 404,
669
+ );
670
+ }
671
+
672
+ // 2. Circuit breaker count in last 60 minutes
673
+ const oneHourAgo = new Date(Date.now() - 3600_000).toISOString();
674
+ const recentCountRow = await db
675
+ .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 >= ?`,
681
+ )
682
+ .bind(projectId, environment, input.targetNodeId, oneHourAgo)
683
+ .first<{ cnt: number }>();
684
+
685
+ const recentCount = Number(recentCountRow?.cnt ?? 0);
686
+ const requestedAutonomy =
687
+ input.requestedAutonomy ?? AgentAutonomyLevel.AutonomousWrite;
688
+
689
+ const policy = evaluateActionPolicyMatrix(
690
+ input.actionKind,
691
+ finding.causalProofTier,
692
+ recentCount,
693
+ );
694
+
695
+ // Never escalate autonomy higher than what the caller requested
696
+ let effectiveAutonomy = policy.effectiveAutonomy;
697
+ let policyReason = policy.reason;
698
+ if (
699
+ requestedAutonomy === AgentAutonomyLevel.HumanApprovedWrite &&
700
+ effectiveAutonomy === AgentAutonomyLevel.AutonomousWrite
701
+ ) {
702
+ effectiveAutonomy = AgentAutonomyLevel.HumanApprovedWrite;
703
+ policyReason = `Caller explicitly requested human_approved_write (${policy.reason})`;
704
+ }
705
+
706
+ let status: RemediationProposalStatus = "pending_human_approval";
707
+ let grantToken: string | undefined;
708
+ let grantTokenHash: string | null = null;
709
+ let grantExpiresAt: string | null = null;
710
+
711
+ if (effectiveAutonomy === AgentAutonomyLevel.AutonomousWrite) {
712
+ status = "grant_issued";
713
+ grantToken = `rgt_${randomHex(16)}`;
714
+ grantTokenHash = await sha256Hex(grantToken);
715
+ grantExpiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
716
+ } else if (effectiveAutonomy === AgentAutonomyLevel.BlockedByPolicy) {
717
+ status = "rejected";
718
+ }
719
+
720
+ const id = `rem_${randomHex(10)}`;
721
+ const expiresAt = new Date(
722
+ Date.now() + 30 * 24 * 3600_000,
723
+ ).toISOString();
724
+
725
+ await db
726
+ .prepare(
727
+ `INSERT INTO remediation_proposals (
728
+ project_id, environment, id, incident_id, finding_id,
729
+ idempotency_key, action_kind, target_node_id,
730
+ expected_current_state_json, desired_target_state_json,
731
+ requested_autonomy, effective_autonomy, policy_decision_reason,
732
+ status, grant_token_hash, grant_expires_at,
733
+ created_at, updated_at, expires_at
734
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
735
+ )
736
+ .bind(
737
+ projectId,
738
+ environment,
739
+ id,
740
+ input.incidentId,
741
+ input.findingId,
742
+ input.idempotencyKey,
743
+ input.actionKind,
744
+ input.targetNodeId,
745
+ JSON.stringify(input.expectedCurrentState),
746
+ JSON.stringify(input.desiredTargetState),
747
+ requestedAutonomy,
748
+ effectiveAutonomy,
749
+ policyReason,
750
+ status,
751
+ grantTokenHash,
752
+ grantExpiresAt,
753
+ nowIso,
754
+ nowIso,
755
+ expiresAt,
756
+ )
757
+ .run();
758
+
759
+ const created = await db
760
+ .prepare(
761
+ `SELECT * FROM remediation_proposals
762
+ WHERE project_id = ? AND environment = ? AND id = ?`,
763
+ )
764
+ .bind(projectId, environment, id)
765
+ .first<ProposalRow>();
766
+
767
+ if (!created) {
768
+ throw new Error(`Failed to load created remediation proposal ${id}`);
769
+ }
770
+ return mapProposalRow(created, grantToken);
771
+ }
772
+
773
+ export async function getRemediationProposal(
774
+ db: SqlDb,
775
+ projectId: string,
776
+ proposalId: string,
777
+ environment?: string,
778
+ ): Promise<RemediationProposal | null> {
779
+ const row = environment
780
+ ? await db
781
+ .prepare(
782
+ `SELECT * FROM remediation_proposals
783
+ WHERE project_id = ? AND environment = ? AND id = ?`,
784
+ )
785
+ .bind(projectId, environment.toLowerCase(), proposalId)
786
+ .first<ProposalRow>()
787
+ : await db
788
+ .prepare(
789
+ `SELECT * FROM remediation_proposals
790
+ WHERE project_id = ? AND id = ?
791
+ ORDER BY created_at DESC
792
+ LIMIT 1`,
793
+ )
794
+ .bind(projectId, proposalId)
795
+ .first<ProposalRow>();
796
+
797
+ return row ? mapProposalRow(row) : null;
798
+ }
799
+
800
+ export async function listRemediationProposals(
801
+ db: SqlDb,
802
+ options: {
803
+ projectId: string;
804
+ environment?: string;
805
+ status?: string;
806
+ incidentId?: string;
807
+ findingId?: string;
808
+ limit?: number;
809
+ },
810
+ ): Promise<RemediationProposal[]> {
811
+ const conditions: string[] = ["project_id = ?"];
812
+ const binds: unknown[] = [options.projectId];
813
+
814
+ if (options.environment) {
815
+ conditions.push("environment = ?");
816
+ binds.push(options.environment.toLowerCase());
817
+ }
818
+ if (options.status) {
819
+ conditions.push("status = ?");
820
+ binds.push(options.status);
821
+ }
822
+ if (options.incidentId) {
823
+ conditions.push("incident_id = ?");
824
+ binds.push(options.incidentId);
825
+ }
826
+ if (options.findingId) {
827
+ conditions.push("finding_id = ?");
828
+ binds.push(options.findingId);
829
+ }
830
+
831
+ const limit = Math.max(1, Math.min(100, options.limit ?? 50));
832
+ binds.push(limit);
833
+
834
+ const rows = await db
835
+ .prepare(
836
+ `SELECT * FROM remediation_proposals
837
+ WHERE ${conditions.join(" AND ")}
838
+ ORDER BY created_at DESC
839
+ LIMIT ?`,
840
+ )
841
+ .bind(...binds)
842
+ .all<ProposalRow>();
843
+
844
+ return rows.results.map((r) => mapProposalRow(r));
845
+ }
846
+
847
+ /**
848
+ * Human dashboard session approval for `pending_human_approval` proposals
849
+ * (RFC 0013 § 6.B & P1 #4). Mints a single-use `grantToken`.
850
+ */
851
+ export async function approveRemediationProposal(
852
+ db: SqlDb,
853
+ projectId: string,
854
+ proposalId: string,
855
+ options: {
856
+ environment?: string;
857
+ approvedByUserId: string;
858
+ },
859
+ ): 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
+
877
+ if (!row) {
878
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
879
+ }
880
+ if (row.status !== "pending_human_approval") {
881
+ throw new RemediationGateError(
882
+ `Proposal ${proposalId} cannot be approved from status=${row.status}`,
883
+ 409,
884
+ );
885
+ }
886
+
887
+ const nowIso = new Date().toISOString();
888
+ const grantToken = `rgt_${randomHex(16)}`;
889
+ const grantTokenHash = await sha256Hex(grantToken);
890
+ const grantExpiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
891
+
892
+ await db
893
+ .prepare(
894
+ `UPDATE remediation_proposals
895
+ SET status = 'grant_issued',
896
+ grant_token_hash = ?,
897
+ grant_expires_at = ?,
898
+ approved_by_user_id = ?,
899
+ approved_at = ?,
900
+ updated_at = ?
901
+ WHERE project_id = ? AND environment = ? AND id = ?`,
902
+ )
903
+ .bind(
904
+ grantTokenHash,
905
+ grantExpiresAt,
906
+ options.approvedByUserId,
907
+ nowIso,
908
+ nowIso,
909
+ projectId,
910
+ row.environment,
911
+ row.id,
912
+ )
913
+ .run();
914
+
915
+ const updated = await db
916
+ .prepare(
917
+ `SELECT * FROM remediation_proposals
918
+ WHERE project_id = ? AND environment = ? AND id = ?`,
919
+ )
920
+ .bind(projectId, row.environment, row.id)
921
+ .first<ProposalRow>();
922
+
923
+ return mapProposalRow(updated ?? row, grantToken);
924
+ }
925
+
926
+ export async function rejectRemediationProposal(
927
+ db: SqlDb,
928
+ projectId: string,
929
+ proposalId: string,
930
+ options: {
931
+ environment?: string;
932
+ rejectedByUserId: string;
933
+ reason?: string;
934
+ },
935
+ ): 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
+
953
+ if (!row) {
954
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
955
+ }
956
+ if (
957
+ row.status !== "pending_human_approval" &&
958
+ row.status !== "grant_issued"
959
+ ) {
960
+ throw new RemediationGateError(
961
+ `Proposal ${proposalId} cannot be rejected from status=${row.status}`,
962
+ 409,
963
+ );
964
+ }
965
+
966
+ const nowIso = new Date().toISOString();
967
+ const reason = options.reason
968
+ ? `Rejected by ${options.rejectedByUserId}: ${options.reason}`
969
+ : `Rejected by ${options.rejectedByUserId}`;
970
+
971
+ await db
972
+ .prepare(
973
+ `UPDATE remediation_proposals
974
+ SET status = 'rejected',
975
+ grant_token_hash = NULL,
976
+ failure_reason = ?,
977
+ updated_at = ?
978
+ WHERE project_id = ? AND environment = ? AND id = ?`,
979
+ )
980
+ .bind(reason, nowIso, projectId, row.environment, row.id)
981
+ .run();
982
+
983
+ const updated = await db
984
+ .prepare(
985
+ `SELECT * FROM remediation_proposals
986
+ WHERE project_id = ? AND environment = ? AND id = ?`,
987
+ )
988
+ .bind(projectId, row.environment, row.id)
989
+ .first<ProposalRow>();
990
+
991
+ return mapProposalRow(updated ?? row);
992
+ }
993
+
994
+ /**
995
+ * Single-use grant redemption with optimistic `expectedCurrentState` verification
996
+ * against `ontology_nodes` (RFC 0013 § 6.B & P1 #4).
997
+ */
998
+ export async function claimRemediationGrant(
999
+ db: SqlDb,
1000
+ projectId: string,
1001
+ proposalId: string,
1002
+ options: {
1003
+ environment?: string;
1004
+ grantToken: string;
1005
+ runnerId?: string;
1006
+ nowIso?: string;
1007
+ },
1008
+ ): Promise<RemediationProposal> {
1009
+ 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>();
1026
+
1027
+ if (!row) {
1028
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
1029
+ }
1030
+ if (row.status !== "grant_issued") {
1031
+ throw new RemediationGateError(
1032
+ `Grant cannot be claimed from status=${row.status} (grants are single-use)`,
1033
+ 409,
1034
+ );
1035
+ }
1036
+ if (row.grant_expires_at && row.grant_expires_at <= nowIso) {
1037
+ await db
1038
+ .prepare(
1039
+ `UPDATE remediation_proposals
1040
+ SET status = 'expired', updated_at = ?
1041
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1042
+ )
1043
+ .bind(nowIso, projectId, row.environment, row.id)
1044
+ .run();
1045
+ throw new RemediationGateError(
1046
+ `Grant for proposal ${proposalId} expired at ${row.grant_expires_at}`,
1047
+ 410,
1048
+ );
1049
+ }
1050
+
1051
+ const incomingHash = await sha256Hex(options.grantToken);
1052
+ if (!row.grant_token_hash || incomingHash !== row.grant_token_hash) {
1053
+ throw new RemediationGateError("Invalid grantToken", 403);
1054
+ }
1055
+
1056
+ // Optimistic version/commit/config check against ontology_nodes
1057
+ const expected = parseJsonRecord(
1058
+ row.expected_current_state_json,
1059
+ ) as unknown as RemediationStateSpec;
1060
+ const targetNode = await db
1061
+ .prepare(
1062
+ `SELECT current_version, current_commit_sha, current_config_digest
1063
+ FROM ontology_nodes
1064
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1065
+ )
1066
+ .bind(projectId, row.environment, row.target_node_id)
1067
+ .first<{
1068
+ current_version: string | null;
1069
+ current_commit_sha: string | null;
1070
+ current_config_digest: string | null;
1071
+ }>();
1072
+
1073
+ if (targetNode) {
1074
+ if (
1075
+ expected.version &&
1076
+ targetNode.current_version &&
1077
+ 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
+ );
1103
+ }
1104
+ }
1105
+
1106
+ const claimExpiresAt = new Date(
1107
+ Date.parse(nowIso) + 5 * 60_000,
1108
+ ).toISOString();
1109
+
1110
+ await db.batch([
1111
+ db
1112
+ .prepare(
1113
+ `UPDATE remediation_proposals
1114
+ SET status = 'claimed_executing',
1115
+ grant_token_hash = NULL,
1116
+ claimed_by_runner_id = ?,
1117
+ claimed_at = ?,
1118
+ claim_expires_at = ?,
1119
+ updated_at = ?
1120
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1121
+ )
1122
+ .bind(
1123
+ options.runnerId ?? "autosre-runner",
1124
+ nowIso,
1125
+ claimExpiresAt,
1126
+ nowIso,
1127
+ projectId,
1128
+ row.environment,
1129
+ row.id,
1130
+ ),
1131
+ db
1132
+ .prepare(
1133
+ `UPDATE incident_investigations
1134
+ SET status = 'remediating', updated_at = ?
1135
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1136
+ )
1137
+ .bind(nowIso, projectId, row.environment, row.incident_id),
1138
+ ]);
1139
+
1140
+ const updated = await db
1141
+ .prepare(
1142
+ `SELECT * FROM remediation_proposals
1143
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1144
+ )
1145
+ .bind(projectId, row.environment, row.id)
1146
+ .first<ProposalRow>();
1147
+
1148
+ return mapProposalRow(updated ?? row);
1149
+ }
1150
+
1151
+ /**
1152
+ * Records remediation completion, updates `ontology_nodes` to desired state,
1153
+ * and arms the +2m/+5m/+15m post-remediation watchdog on the finding.
1154
+ */
1155
+ export async function completeRemediationProposal(
1156
+ db: SqlDb,
1157
+ projectId: string,
1158
+ proposalId: string,
1159
+ options: {
1160
+ environment?: string;
1161
+ resultingChangeId?: string | null;
1162
+ remediationAgentRunId?: string | null;
1163
+ baselineRequestRatePerMin?: number | null;
1164
+ nowIso?: string;
1165
+ },
1166
+ ): Promise<RemediationProposal> {
1167
+ 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>();
1184
+
1185
+ if (!row) {
1186
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
1187
+ }
1188
+ if (row.status !== "claimed_executing") {
1189
+ throw new RemediationGateError(
1190
+ `Proposal ${proposalId} cannot be completed from status=${row.status} (must be claimed_executing)`,
1191
+ 409,
1192
+ );
1193
+ }
1194
+
1195
+ const desired = parseJsonRecord(
1196
+ row.desired_target_state_json,
1197
+ ) as unknown as RemediationStateSpec;
1198
+ const nextWatchdogAt = new Date(
1199
+ Date.parse(nowIso) + 2 * 60_000,
1200
+ ).toISOString();
1201
+
1202
+ const finding = await getInvestigationFinding(
1203
+ db,
1204
+ projectId,
1205
+ row.finding_id,
1206
+ row.environment,
1207
+ );
1208
+ const updatedChain: CausalTriple[] = finding ? [...finding.causalChain] : [];
1209
+ if (options.resultingChangeId) {
1210
+ updatedChain.push({
1211
+ subjectKind: "finding",
1212
+ subjectId: row.finding_id,
1213
+ predicate: "mitigated_by_change",
1214
+ objectKind: "change",
1215
+ objectId: options.resultingChangeId,
1216
+ relationshipCertainty: "exact",
1217
+ causalProofTier: finding?.causalProofTier ?? "cohort_rate_isolated",
1218
+ confidence: 0.9,
1219
+ reason: `Remediation proposal ${row.id} (${row.action_kind}) applied change ${options.resultingChangeId}`,
1220
+ });
1221
+ }
1222
+ if (options.remediationAgentRunId) {
1223
+ updatedChain.push({
1224
+ subjectKind: "finding",
1225
+ subjectId: row.finding_id,
1226
+ predicate: "remediated_by_agent_run",
1227
+ objectKind: "agent_run",
1228
+ objectId: options.remediationAgentRunId,
1229
+ relationshipCertainty: "exact",
1230
+ causalProofTier: finding?.causalProofTier ?? "cohort_rate_isolated",
1231
+ confidence: 0.9,
1232
+ reason: `Remediation executed by agent run ${options.remediationAgentRunId}`,
1233
+ });
1234
+ }
1235
+
1236
+ await db.batch([
1237
+ db
1238
+ .prepare(
1239
+ `UPDATE remediation_proposals
1240
+ SET status = 'completed',
1241
+ resulting_change_id = COALESCE(?, resulting_change_id),
1242
+ remediation_agent_run_id = COALESCE(?, remediation_agent_run_id),
1243
+ updated_at = ?
1244
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1245
+ )
1246
+ .bind(
1247
+ options.resultingChangeId ?? null,
1248
+ options.remediationAgentRunId ?? null,
1249
+ nowIso,
1250
+ projectId,
1251
+ row.environment,
1252
+ row.id,
1253
+ ),
1254
+ db
1255
+ .prepare(
1256
+ `UPDATE ontology_nodes
1257
+ SET current_version = COALESCE(?, current_version),
1258
+ current_commit_sha = COALESCE(?, current_commit_sha),
1259
+ current_config_digest = COALESCE(?, current_config_digest),
1260
+ last_seen_at = ?
1261
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1262
+ )
1263
+ .bind(
1264
+ desired.version ?? null,
1265
+ desired.commitSha ?? null,
1266
+ desired.configDigest ?? null,
1267
+ nowIso,
1268
+ projectId,
1269
+ row.environment,
1270
+ row.target_node_id,
1271
+ ),
1272
+ db
1273
+ .prepare(
1274
+ `UPDATE investigation_findings
1275
+ SET recovery_status = 'monitoring',
1276
+ watchdog_step = 1,
1277
+ baseline_request_rate_per_min = COALESCE(?, baseline_request_rate_per_min),
1278
+ next_watchdog_check_at = ?,
1279
+ causal_chain_json = ?,
1280
+ updated_at = ?
1281
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1282
+ )
1283
+ .bind(
1284
+ options.baselineRequestRatePerMin ?? null,
1285
+ nextWatchdogAt,
1286
+ JSON.stringify(updatedChain),
1287
+ nowIso,
1288
+ projectId,
1289
+ row.environment,
1290
+ row.finding_id,
1291
+ ),
1292
+ db
1293
+ .prepare(
1294
+ `UPDATE incident_investigations
1295
+ SET status = 'verifying', updated_at = ?
1296
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1297
+ )
1298
+ .bind(nowIso, projectId, row.environment, row.incident_id),
1299
+ ]);
1300
+
1301
+ const updated = await db
1302
+ .prepare(
1303
+ `SELECT * FROM remediation_proposals
1304
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1305
+ )
1306
+ .bind(projectId, row.environment, row.id)
1307
+ .first<ProposalRow>();
1308
+
1309
+ return mapProposalRow(updated ?? row);
1310
+ }
1311
+
1312
+ export async function failRemediationProposal(
1313
+ db: SqlDb,
1314
+ projectId: string,
1315
+ proposalId: string,
1316
+ options: {
1317
+ environment?: string;
1318
+ failureReason: string;
1319
+ nowIso?: string;
1320
+ },
1321
+ ): Promise<RemediationProposal> {
1322
+ 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>();
1339
+
1340
+ if (!row) {
1341
+ throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
1342
+ }
1343
+
1344
+ await db.batch([
1345
+ db
1346
+ .prepare(
1347
+ `UPDATE remediation_proposals
1348
+ SET status = 'failed_execution',
1349
+ failure_reason = ?,
1350
+ updated_at = ?
1351
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1352
+ )
1353
+ .bind(options.failureReason, nowIso, projectId, row.environment, row.id),
1354
+ db
1355
+ .prepare(
1356
+ `UPDATE incident_investigations
1357
+ SET status = 'escalated', updated_at = ?
1358
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1359
+ )
1360
+ .bind(nowIso, projectId, row.environment, row.incident_id),
1361
+ ]);
1362
+
1363
+ const updated = await db
1364
+ .prepare(
1365
+ `SELECT * FROM remediation_proposals
1366
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1367
+ )
1368
+ .bind(projectId, row.environment, row.id)
1369
+ .first<ProposalRow>();
1370
+
1371
+ return mapProposalRow(updated ?? row);
1372
+ }
1373
+
1374
+ export interface WatchdogEvaluationSummary {
1375
+ evaluated: number;
1376
+ recovered: number;
1377
+ inconclusiveLowTraffic: number;
1378
+ stillDegraded: number;
1379
+ timedOutClaims: number;
1380
+ expiredGrants: number;
1381
+ }
1382
+
1383
+ /**
1384
+ * Post-Remediation Watchdog & Lease/Claim Timeout Evaluator (RFC 0013 § 5.B, § 6.B & P1 #3).
1385
+ *
1386
+ * Strictly separates:
1387
+ * - `recovery_status`: whether the service recovered under comparable traffic (`N >= minComparableRequests`)
1388
+ * - `causal_verification_status`: `"operationally_corroborated"` (never promoted to `"counterfactual_verified"`
1389
+ * by telemetry recovery alone!).
1390
+ */
1391
+ export async function evaluateRemediationWatchdogs(
1392
+ db: SqlDb,
1393
+ options: {
1394
+ nowIso?: string;
1395
+ minComparableRequests?: number;
1396
+ } = {},
1397
+ ): Promise<WatchdogEvaluationSummary> {
1398
+ const nowIso = options.nowIso ?? new Date().toISOString();
1399
+ const minComparableRequests = options.minComparableRequests ?? 50;
1400
+
1401
+ // 1. Expire unclaimed grants past grant_expires_at
1402
+ const expiredGrantsRes = await db
1403
+ .prepare(
1404
+ `UPDATE remediation_proposals
1405
+ SET status = 'expired', grant_token_hash = NULL, updated_at = ?
1406
+ WHERE status = 'grant_issued'
1407
+ AND grant_expires_at IS NOT NULL
1408
+ AND grant_expires_at <= ?`,
1409
+ )
1410
+ .bind(nowIso, nowIso)
1411
+ .run();
1412
+
1413
+ // 2. Time out claimed_executing proposals past claim_expires_at and escalate their incidents
1414
+ const timedOutRows = await db
1415
+ .prepare(
1416
+ `SELECT project_id, environment, id, incident_id
1417
+ FROM remediation_proposals
1418
+ WHERE status = 'claimed_executing'
1419
+ AND claim_expires_at IS NOT NULL
1420
+ AND claim_expires_at <= ?`,
1421
+ )
1422
+ .bind(nowIso)
1423
+ .all<{
1424
+ project_id: string;
1425
+ environment: string;
1426
+ id: string;
1427
+ incident_id: string;
1428
+ }>();
1429
+
1430
+ for (const t of timedOutRows.results) {
1431
+ await db.batch([
1432
+ db
1433
+ .prepare(
1434
+ `UPDATE remediation_proposals
1435
+ SET status = 'execution_timed_out',
1436
+ failure_reason = 'Runner did not complete or fail within 5m claim window',
1437
+ updated_at = ?
1438
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1439
+ )
1440
+ .bind(nowIso, t.project_id, t.environment, t.id),
1441
+ db
1442
+ .prepare(
1443
+ `UPDATE incident_investigations
1444
+ SET status = 'escalated', updated_at = ?
1445
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1446
+ )
1447
+ .bind(nowIso, t.project_id, t.environment, t.incident_id),
1448
+ ]);
1449
+ }
1450
+
1451
+ // 3. Evaluate findings in `monitoring` where `next_watchdog_check_at <= nowIso`
1452
+ const dueFindings = await db
1453
+ .prepare(
1454
+ `SELECT
1455
+ project_id, environment, id, incident_id,
1456
+ fault_locus_entity_id, root_cause_entity_id,
1457
+ watchdog_step, baseline_request_rate_per_min,
1458
+ causal_verification_status, updated_at
1459
+ FROM investigation_findings
1460
+ WHERE recovery_status = 'monitoring'
1461
+ AND next_watchdog_check_at IS NOT NULL
1462
+ AND next_watchdog_check_at <= ?
1463
+ LIMIT 50`,
1464
+ )
1465
+ .bind(nowIso)
1466
+ .all<{
1467
+ project_id: string;
1468
+ environment: string;
1469
+ id: string;
1470
+ incident_id: string | null;
1471
+ fault_locus_entity_id: string;
1472
+ root_cause_entity_id: string | null;
1473
+ watchdog_step: number;
1474
+ baseline_request_rate_per_min: number | null;
1475
+ causal_verification_status: string;
1476
+ updated_at: string;
1477
+ }>();
1478
+
1479
+ let recovered = 0;
1480
+ let inconclusiveLowTraffic = 0;
1481
+ let stillDegraded = 0;
1482
+
1483
+ 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,
1566
+ ).toISOString();
1567
+ await db
1568
+ .prepare(
1569
+ `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',
1582
+ next_watchdog_check_at = NULL,
1583
+ updated_at = ?
1584
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1585
+ )
1586
+ .bind(nowIso, f.project_id, f.environment, f.id)
1587
+ .run();
1588
+ }
1589
+ continue;
1590
+ }
1591
+
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) {
1606
+ await db
1607
+ .prepare(
1608
+ `UPDATE incident_investigations
1609
+ SET status = 'escalated', updated_at = ?
1610
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1611
+ )
1612
+ .bind(nowIso, f.project_id, f.environment, f.incident_id)
1613
+ .run();
1614
+ }
1615
+ } else {
1616
+ // Traffic is sufficient AND error rate recovered (<5%)
1617
+ recovered += 1;
1618
+ const nextCausalVerification =
1619
+ f.causal_verification_status === "counterfactual_verified"
1620
+ ? "counterfactual_verified"
1621
+ : "operationally_corroborated";
1622
+
1623
+ await db
1624
+ .prepare(
1625
+ `UPDATE investigation_findings
1626
+ SET recovery_status = 'recovered',
1627
+ causal_verification_status = ?,
1628
+ watchdog_step = 3,
1629
+ next_watchdog_check_at = NULL,
1630
+ updated_at = ?
1631
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1632
+ )
1633
+ .bind(
1634
+ nextCausalVerification,
1635
+ nowIso,
1636
+ f.project_id,
1637
+ f.environment,
1638
+ f.id,
1639
+ )
1640
+ .run();
1641
+
1642
+ if (f.incident_id) {
1643
+ await db
1644
+ .prepare(
1645
+ `UPDATE incident_investigations
1646
+ SET status = 'resolved', resolved_at = ?, updated_at = ?
1647
+ WHERE project_id = ? AND environment = ? AND id = ?`,
1648
+ )
1649
+ .bind(nowIso, nowIso, f.project_id, f.environment, f.incident_id)
1650
+ .run();
1651
+ }
1652
+ }
1653
+ }
1654
+
1655
+ return {
1656
+ evaluated: dueFindings.results.length,
1657
+ recovered,
1658
+ inconclusiveLowTraffic,
1659
+ stillDegraded,
1660
+ timedOutClaims: timedOutRows.results.length,
1661
+ expiredGrants: expiredGrantsRes.meta.changes,
1662
+ };
1663
+ }