@xaccefy/pi-casefile 0.9.4 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Internal shared plumbing for the casefile ledger modules.
3
+ *
4
+ * The single home for the DB handle, record builder, validation, transaction
5
+ * wrapper, and text helpers. Both ledger.ts and its sibling modules
6
+ * (confirmation.ts) import these from here — one copy, no drift. NOT a public
7
+ * API — external callers import from ledger.ts, which re-exports what they need.
8
+ *
9
+ * Circular-import note: this module must stay leaf-like. It may import types
10
+ * from ledger.ts but no runtime values, or the ledger ⇄ sibling cycle gains
11
+ * an edge.
12
+ */
13
+
14
+ import { createHash, randomUUID } from "node:crypto";
15
+ import type { CaseRecord, EvidenceItem, NormalizedCaseInput } from "./ledger.ts";
16
+ import type { DatabaseSync } from "./sqlite-compat/index.ts";
17
+
18
+ // ── Text helpers ─────────────────────────────────────────────────────
19
+
20
+ export function normalizeList(values: string[] | undefined): string[] {
21
+ return Array.from(new Set((values ?? []).map((v) => v.trim()).filter(Boolean)));
22
+ }
23
+
24
+ export function normalizeText(value: string | undefined): string | undefined {
25
+ const trimmed = value?.trim();
26
+ return trimmed || undefined;
27
+ }
28
+
29
+ export function stableShortId(input: string): string {
30
+ return createHash("sha1").update(input).digest("hex").slice(0, 10);
31
+ }
32
+
33
+ export { normalizeMatchText };
34
+
35
+ function normalizeMatchText(value: string | undefined): string {
36
+ return normalizeText(value)?.toLowerCase().replace(/\s+/g, " ") ?? "";
37
+ }
38
+
39
+ // ── DB handle (module-global; ledger.ts owns lifecycle) ─────────────
40
+
41
+ let dbInstance: DatabaseSync | undefined;
42
+ let opener: (() => DatabaseSync) | undefined;
43
+
44
+ /** ledger.ts registers its schema-init opener here at module load. */
45
+ export function setDbOpener(impl: () => DatabaseSync): void {
46
+ opener = impl;
47
+ }
48
+
49
+ export function getDb(): DatabaseSync {
50
+ if (!dbInstance && opener) {
51
+ // Lazy open through the owner (schema init + safe-state checks) so any
52
+ // sibling module can start the ledger, not just ledger.ts call sites.
53
+ dbInstance = opener();
54
+ }
55
+ if (!dbInstance) {
56
+ throw new Error("Ledger database not initialized — open it via ledger.ts first");
57
+ }
58
+ return dbInstance;
59
+ }
60
+
61
+ /** Called by ledger.ts's getDb() after opening (or reopening) the database. */
62
+ export function setDbInstance(db: DatabaseSync): void {
63
+ dbInstance = db;
64
+ }
65
+
66
+ export function hasDbInstance(): boolean {
67
+ return dbInstance !== undefined;
68
+ }
69
+
70
+ export function closeDb(): void {
71
+ if (!dbInstance) return;
72
+ try {
73
+ dbInstance.close();
74
+ } catch {
75
+ // Best-effort close.
76
+ }
77
+ dbInstance = undefined;
78
+ }
79
+
80
+ // ── Record building & persistence primitives ────────────────────────
81
+
82
+ export function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRecord {
83
+ const timestamp = new Date().toISOString();
84
+ const title = ("title" in input ? input.title : existing?.title)?.trim() ?? "";
85
+ const id = existing?.id ?? `case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
86
+
87
+ return {
88
+ id,
89
+ title,
90
+ status: input.status ?? existing?.status ?? "hypothesis",
91
+ // Once a case has been investigating/confirmed it never forgets — the kill
92
+ // gate must not be defeatable by demoting first.
93
+ everAdvanced:
94
+ existing?.everAdvanced === true ||
95
+ input.status === "investigating" ||
96
+ input.status === "confirmed",
97
+ confidence: input.confidence ?? existing?.confidence ?? "low",
98
+ severity: input.severity ?? existing?.severity,
99
+ priority: input.priority ?? existing?.priority,
100
+ target: input.target !== undefined ? normalizeText(input.target) : existing?.target,
101
+ endpoint: input.endpoint !== undefined ? normalizeText(input.endpoint) : existing?.endpoint,
102
+ bugClass: input.bugClass !== undefined ? normalizeText(input.bugClass) : existing?.bugClass,
103
+ summary: input.summary !== undefined ? normalizeText(input.summary) : existing?.summary,
104
+ evidence: input.evidence !== undefined ? normalizeText(input.evidence) : existing?.evidence,
105
+ impact: input.impact !== undefined ? normalizeText(input.impact) : existing?.impact,
106
+ nextStep: input.nextStep !== undefined ? normalizeText(input.nextStep) : existing?.nextStep,
107
+ poc: input.poc !== undefined ? normalizeText(input.poc) : existing?.poc,
108
+ remediation:
109
+ input.remediation !== undefined ? normalizeText(input.remediation) : existing?.remediation,
110
+ references: normalizeList(input.references ?? existing?.references),
111
+ blockers: normalizeList(input.blockers ?? existing?.blockers),
112
+ tags: normalizeList(input.tags ?? existing?.tags),
113
+ assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
114
+ disproveIf: normalizeList(input.disproveIf ?? existing?.disproveIf),
115
+ pocVerified: input.pocVerified ?? existing?.pocVerified,
116
+ disconfirmation:
117
+ input.disconfirmation !== undefined
118
+ ? normalizeText(input.disconfirmation)
119
+ : existing?.disconfirmation,
120
+ invariant: input.invariant !== undefined ? normalizeText(input.invariant) : existing?.invariant,
121
+ disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
122
+ controlVerified: input.controlVerified ?? existing?.controlVerified,
123
+ pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
124
+ confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
125
+ reportedAt: input.reportedAt ?? existing?.reportedAt,
126
+ reportPath: input.reportPath ?? existing?.reportPath,
127
+ retryPolicy:
128
+ input.retryPolicy !== undefined
129
+ ? normalizeRetryPolicy(input.retryPolicy)
130
+ : existing?.retryPolicy,
131
+ evidenceItems: existing?.evidenceItems ?? [],
132
+ coverageItems: existing?.coverageItems ?? [],
133
+ linkedCases: existing?.linkedCases ?? [],
134
+ createdAt: existing?.createdAt ?? timestamp,
135
+ updatedAt: timestamp,
136
+ };
137
+ }
138
+
139
+ /** Normalize a retry policy: attempts 1–10, at most 8 distinct fallback models. */
140
+ export function normalizeRetryPolicy(policy: unknown): CaseRecord["retryPolicy"] {
141
+ if (policy === null || policy === undefined) return undefined;
142
+ if (typeof policy !== "object" || Array.isArray(policy)) {
143
+ throw new Error("retry_policy must be an object: { max_attempts, fallback_models? }");
144
+ }
145
+ const p = policy as { max_attempts?: unknown; fallback_models?: unknown };
146
+ if (
147
+ typeof p.max_attempts !== "number" ||
148
+ !Number.isInteger(p.max_attempts) ||
149
+ p.max_attempts < 1 ||
150
+ p.max_attempts > 10
151
+ ) {
152
+ throw new Error("retry_policy.max_attempts must be an integer between 1 and 10");
153
+ }
154
+ if (p.fallback_models !== undefined && p.fallback_models !== null) {
155
+ if (!Array.isArray(p.fallback_models) || p.fallback_models.length > 8) {
156
+ throw new Error("retry_policy.fallback_models must be an array of at most 8 model names");
157
+ }
158
+ if (
159
+ !p.fallback_models.every(
160
+ (m) => typeof m === "string" && m.trim().length > 0 && m.trim() === m,
161
+ )
162
+ ) {
163
+ throw new Error("retry_policy.fallback_models entries must be non-empty trimmed strings");
164
+ }
165
+ }
166
+ const fallbackModels = Array.isArray(p.fallback_models)
167
+ ? Array.from(new Set(p.fallback_models.map((m) => m as string)))
168
+ : undefined;
169
+ return fallbackModels?.length
170
+ ? { max_attempts: p.max_attempts, fallback_models: fallbackModels }
171
+ : { max_attempts: p.max_attempts };
172
+ }
173
+
174
+ export function validateCase(record: CaseRecord): void {
175
+ if (!record.title.trim()) throw new Error("Case title cannot be empty");
176
+ // Enum membership on the public API path: the tool layer schema-gates these,
177
+ // but direct callers (tests, other integrations) must not be able to persist
178
+ // "bogus" statuses that every later gate and reader would mis-handle.
179
+ const STATUS = [
180
+ "hypothesis",
181
+ "investigating",
182
+ "confirmed",
183
+ "blocked",
184
+ "killed",
185
+ "reported",
186
+ ] as const;
187
+ const CONFIDENCE = ["low", "medium", "high"] as const;
188
+ const SEVERITY = ["info", "low", "medium", "high", "critical"] as const;
189
+ const PRIORITY = ["P0", "P1", "P2", "P3", "P4"] as const;
190
+ if (!(STATUS as readonly string[]).includes(record.status)) {
191
+ throw new Error(`Invalid case status: ${record.status}. Statuses: ${STATUS.join(", ")}`);
192
+ }
193
+ if (!(CONFIDENCE as readonly string[]).includes(record.confidence)) {
194
+ throw new Error(
195
+ `Invalid case confidence: ${record.confidence}. Confidence levels: ${CONFIDENCE.join(", ")}`,
196
+ );
197
+ }
198
+ // Null check (not just undefined): DB reads surface absent columns as null.
199
+ if (record.severity != null && !(SEVERITY as readonly string[]).includes(record.severity)) {
200
+ throw new Error(
201
+ `Invalid case severity: ${record.severity}. Severities: ${SEVERITY.join(", ")}`,
202
+ );
203
+ }
204
+ if (record.priority != null && !(PRIORITY as readonly string[]).includes(record.priority)) {
205
+ throw new Error(
206
+ `Invalid case priority: ${record.priority}. Priorities: ${PRIORITY.join(", ")}`,
207
+ );
208
+ }
209
+ // Retry policy shape is machine-read by retry tooling — re-check on write.
210
+ if (record.retryPolicy !== undefined) normalizeRetryPolicy(record.retryPolicy);
211
+ // Falsification conditions are load-bearing: they are required at creation
212
+ // and must not be erasable later (CaseUpdate({ disproveIf: [] }) would wipe
213
+ // the hypothesis's falsifiability). Re-check on every write.
214
+ if (record.status !== "reported" && !(record.disproveIf ?? []).some((d) => d.trim())) {
215
+ throw new Error(
216
+ "Cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
217
+ "They cannot be cleared once set.",
218
+ );
219
+ }
220
+ // Keep this gate in lockstep with the confirmation module: a case may only
221
+ // be CONFIRMED when it has evidence, a PoC, demonstrated impact, a severity,
222
+ // and a named target (what host/repo/scope this affects).
223
+ if (
224
+ record.status === "confirmed" &&
225
+ (!record.evidence ||
226
+ !record.poc ||
227
+ !record.impact ||
228
+ !record.severity ||
229
+ !record.target ||
230
+ !record.disconfirmation)
231
+ ) {
232
+ throw new Error(
233
+ "Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
234
+ );
235
+ }
236
+ if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
237
+ throw new Error("Blocked cases require at least one blocker");
238
+ }
239
+ if (
240
+ record.status === "killed" &&
241
+ !record.evidence &&
242
+ !record.nextStep &&
243
+ (record.blockers ?? []).length === 0 &&
244
+ (record.assumptions ?? []).length === 0
245
+ ) {
246
+ throw new Error(
247
+ "Killed cases require evidence, next step, blockers, or assumptions explaining why",
248
+ );
249
+ }
250
+ // A case becomes REPORTED only after a report FILE that passes the content
251
+ // gate exists on disk. Existence is not enough: any non-empty file — or a
252
+ // directory — would otherwise flip the case to a permanent, immutable state.
253
+ if (record.status === "reported") {
254
+ // Imported lazily as a type-only dependency: validateReportFile is defined
255
+ // in format helpers within ledger.ts and injected here to avoid a runtime
256
+ // cycle. The setter runs at module init in ledger.ts.
257
+ if (validateReportFileImpl === undefined) {
258
+ throw new Error("Report validation unavailable — ledger not fully initialized");
259
+ }
260
+ const reportError = validateReportFileImpl(record.reportPath, record);
261
+ if (reportError) {
262
+ throw new Error(`Reported cases require a valid report file: ${reportError}`);
263
+ }
264
+ }
265
+ }
266
+
267
+ let validateReportFileImpl:
268
+ | ((reportPath: string | undefined, record: CaseRecord) => string | null)
269
+ | undefined;
270
+
271
+ export function setValidateReportFile(
272
+ impl: (reportPath: string | undefined, record: CaseRecord) => string | null,
273
+ ): void {
274
+ validateReportFileImpl = impl;
275
+ }
276
+
277
+ export function withImmediateTransaction<T>(db: DatabaseSync, fn: () => T): T {
278
+ db.exec("BEGIN IMMEDIATE");
279
+ try {
280
+ const value = fn();
281
+ db.exec("COMMIT");
282
+ return value;
283
+ } catch (err) {
284
+ try {
285
+ db.exec("ROLLBACK");
286
+ } catch {
287
+ // ignore rollback errors
288
+ }
289
+ throw err;
290
+ }
291
+ }
292
+
293
+ export function upsertCase(db: DatabaseSync, record: CaseRecord) {
294
+ // Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
295
+ // wipe case_links when updating an existing primary key.
296
+ const stmt = db.prepare(`
297
+ INSERT INTO cases (
298
+ id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
299
+ summary, evidence, impact, nextStep, poc, remediation,
300
+ references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
301
+ disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
302
+ pending_confirmation_json, confirmer_verdict_json,
303
+ reported_at, report_path, retry_policy_json, invariant, created_at, updated_at
304
+ ) VALUES (
305
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
306
+ ?, ?, ?, ?, ?, ?,
307
+ ?, ?, ?, ?, ?,
308
+ ?, ?, ?, ?,
309
+ ?, ?,
310
+ ?, ?, ?, ?, ?, ?
311
+ )
312
+ ON CONFLICT(id) DO UPDATE SET
313
+ title = excluded.title,
314
+ status = excluded.status,
315
+ ever_advanced = excluded.ever_advanced,
316
+ confidence = excluded.confidence,
317
+ severity = excluded.severity,
318
+ priority = excluded.priority,
319
+ target = excluded.target,
320
+ endpoint = excluded.endpoint,
321
+ bugClass = excluded.bugClass,
322
+ summary = excluded.summary,
323
+ evidence = excluded.evidence,
324
+ impact = excluded.impact,
325
+ nextStep = excluded.nextStep,
326
+ poc = excluded.poc,
327
+ remediation = excluded.remediation,
328
+ references_json = excluded.references_json,
329
+ blockers_json = excluded.blockers_json,
330
+ tags_json = excluded.tags_json,
331
+ assumptions_json = excluded.assumptions_json,
332
+ poc_verified_json = excluded.poc_verified_json,
333
+ disconfirmation = excluded.disconfirmation,
334
+ disconfirmation_verified_json = excluded.disconfirmation_verified_json,
335
+ disprove_if_json = excluded.disprove_if_json,
336
+ control_verified_json = excluded.control_verified_json,
337
+ pending_confirmation_json = excluded.pending_confirmation_json,
338
+ confirmer_verdict_json = excluded.confirmer_verdict_json,
339
+ invariant = excluded.invariant,
340
+ reported_at = excluded.reported_at,
341
+ report_path = excluded.report_path,
342
+ retry_policy_json = excluded.retry_policy_json,
343
+ created_at = excluded.created_at,
344
+ updated_at = excluded.updated_at
345
+ `);
346
+
347
+ stmt.run(
348
+ record.id,
349
+ record.title,
350
+ record.status,
351
+ record.everAdvanced ? 1 : 0,
352
+ record.confidence,
353
+ record.severity || null,
354
+ record.priority || null,
355
+ record.target || null,
356
+ record.endpoint || null,
357
+ record.bugClass || null,
358
+ record.summary || null,
359
+ record.evidence || null,
360
+ record.impact || null,
361
+ record.nextStep || null,
362
+ record.poc || null,
363
+ record.remediation || null,
364
+ JSON.stringify(record.references),
365
+ JSON.stringify(record.blockers),
366
+ JSON.stringify(record.tags),
367
+ JSON.stringify(record.assumptions),
368
+ record.pocVerified ? JSON.stringify(record.pocVerified) : null,
369
+ record.disconfirmation || null,
370
+ record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
371
+ JSON.stringify(record.disproveIf),
372
+ record.controlVerified ? JSON.stringify(record.controlVerified) : null,
373
+ record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
374
+ record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
375
+ record.reportedAt || null,
376
+ record.reportPath || null,
377
+ record.retryPolicy ? JSON.stringify(record.retryPolicy) : null,
378
+ record.invariant || null,
379
+ record.createdAt,
380
+ record.updatedAt,
381
+ );
382
+ }
383
+
384
+ export function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
385
+ db.prepare(
386
+ `INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at, contains_secret, secret_findings_json)
387
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
388
+ ).run(
389
+ item.id,
390
+ item.caseId,
391
+ item.role,
392
+ item.artifactPath ?? null,
393
+ item.sha256 ?? null,
394
+ item.summary,
395
+ item.createdAt,
396
+ item.containsSecret === true ? 1 : 0,
397
+ item.secretFindings?.length ? JSON.stringify(item.secretFindings) : null,
398
+ );
399
+ }
400
+
401
+ // ── Event journal ────────────────────────────────────────────────────
402
+
403
+ export type CaseEvent = {
404
+ caseId: string;
405
+ seq: number;
406
+ timestamp: string;
407
+ eventType: string;
408
+ actor: string;
409
+ payload?: Record<string, unknown>;
410
+ };
411
+
412
+ /**
413
+ * Append one journal event for a case. Append-only: seq is allocated as
414
+ * max(seq)+1 under the caller's transaction, so events land in commit order.
415
+ * Payloads must stay small and secret-free (field NAMES and ids, not values).
416
+ */
417
+ export function appendCaseEvent(
418
+ db: DatabaseSync,
419
+ event: { caseId: string; eventType: string; actor?: string; payload?: Record<string, unknown> },
420
+ ): void {
421
+ const row = db
422
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS max_seq FROM case_events WHERE case_id = ?")
423
+ .get(event.caseId) as { max_seq: number } | undefined;
424
+ const seq = (row?.max_seq ?? 0) + 1;
425
+ db.prepare(
426
+ "INSERT INTO case_events (case_id, seq, timestamp, event_type, actor, payload_json) VALUES (?, ?, ?, ?, ?, ?)",
427
+ ).run(
428
+ event.caseId,
429
+ seq,
430
+ new Date().toISOString(),
431
+ event.eventType,
432
+ event.actor ?? "agent",
433
+ event.payload ? JSON.stringify(event.payload) : null,
434
+ );
435
+ }