@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.
- package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +309 -2
- package/dist/index.js +4159 -256
- package/dist/index.js.map +1 -1
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +2 -2
- package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
- package/src/index.ts +43 -0
- package/src/lib/analyses-runner.ts +9 -0
- package/src/lib/autosre-change-correlation.ts +780 -0
- package/src/lib/autosre-findings.ts +725 -0
- package/src/lib/autosre-ontology.ts +914 -0
- package/src/lib/autosre-remediation-gate.ts +1663 -0
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +80 -0
- package/src/lib/housekeeping.ts +101 -1
- package/src/lib/store/ingest.ts +94 -6
- package/src/migrations/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/alert-state-notified.test.ts +3 -3
- package/src/migrations/parity.test.ts +4 -0
- package/src/migrations/project-scoped-keys.test.ts +1 -1
- package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
- package/src/plugins/alerts-evaluator.ts +17 -1
- package/src/plugins/autosre-routes.ts +1019 -0
- package/src/plugins/connected-routes/manifest.ts +13 -1
- package/src/plugins/connected-routes.ts +271 -0
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +57 -2
- package/src/plugins/evidence-retrieval-routes.ts +300 -4
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /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
|
+
}
|