@xaccefy/pi-casefile 0.10.0 → 0.11.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.
@@ -124,6 +124,10 @@ export function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord):
124
124
  confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
125
125
  reportedAt: input.reportedAt ?? existing?.reportedAt,
126
126
  reportPath: input.reportPath ?? existing?.reportPath,
127
+ retryPolicy:
128
+ input.retryPolicy !== undefined
129
+ ? normalizeRetryPolicy(input.retryPolicy)
130
+ : existing?.retryPolicy,
127
131
  evidenceItems: existing?.evidenceItems ?? [],
128
132
  coverageItems: existing?.coverageItems ?? [],
129
133
  linkedCases: existing?.linkedCases ?? [],
@@ -132,8 +136,78 @@ export function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord):
132
136
  };
133
137
  }
134
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
+
135
174
  export function validateCase(record: CaseRecord): void {
136
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);
137
211
  // Falsification conditions are load-bearing: they are required at creation
138
212
  // and must not be erasable later (CaseUpdate({ disproveIf: [] }) would wipe
139
213
  // the hypothesis's falsifiability). Re-check on every write.
@@ -226,14 +300,14 @@ export function upsertCase(db: DatabaseSync, record: CaseRecord) {
226
300
  references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
227
301
  disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
228
302
  pending_confirmation_json, confirmer_verdict_json,
229
- reported_at, report_path, invariant, created_at, updated_at
303
+ reported_at, report_path, retry_policy_json, invariant, created_at, updated_at
230
304
  ) VALUES (
231
305
  ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
232
306
  ?, ?, ?, ?, ?, ?,
233
307
  ?, ?, ?, ?, ?,
234
308
  ?, ?, ?, ?,
235
309
  ?, ?,
236
- ?, ?, ?, ?, ?
310
+ ?, ?, ?, ?, ?, ?
237
311
  )
238
312
  ON CONFLICT(id) DO UPDATE SET
239
313
  title = excluded.title,
@@ -265,6 +339,7 @@ export function upsertCase(db: DatabaseSync, record: CaseRecord) {
265
339
  invariant = excluded.invariant,
266
340
  reported_at = excluded.reported_at,
267
341
  report_path = excluded.report_path,
342
+ retry_policy_json = excluded.retry_policy_json,
268
343
  created_at = excluded.created_at,
269
344
  updated_at = excluded.updated_at
270
345
  `);
@@ -299,6 +374,7 @@ export function upsertCase(db: DatabaseSync, record: CaseRecord) {
299
374
  record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
300
375
  record.reportedAt || null,
301
376
  record.reportPath || null,
377
+ record.retryPolicy ? JSON.stringify(record.retryPolicy) : null,
302
378
  record.invariant || null,
303
379
  record.createdAt,
304
380
  record.updatedAt,
@@ -307,8 +383,8 @@ export function upsertCase(db: DatabaseSync, record: CaseRecord) {
307
383
 
308
384
  export function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
309
385
  db.prepare(
310
- `INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at)
311
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
386
+ `INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at, contains_secret, secret_findings_json)
387
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
312
388
  ).run(
313
389
  item.id,
314
390
  item.caseId,
@@ -317,5 +393,43 @@ export function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
317
393
  item.sha256 ?? null,
318
394
  item.summary,
319
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,
320
434
  );
321
435
  }