/** * `SqliteConflictStore` - stores the per-decision audit log * (`fact_conflicts`) and the deferred queue (`conflict_check_pending`) * written by the multi-stage conflict resolution pipeline shipped in * `@graphorin/memory` Phase 10b. * * The class implements the structural `ConflictMemoryStoreExt` * surface defined in `@graphorin/memory/internal/storage-adapter.ts`. * No `@graphorin/memory` import lives here - the store-side stays * dependency-free per the layered architecture. * * @packageDocumentation */ import type { SessionScope } from '@graphorin/core'; import type { SqliteConnection } from './connection.js'; /** * Stage label written into `fact_conflicts.stage` / * `conflict_check_pending.stage`. Stable lowercase identifier so * downstream tooling can pattern-match without parsing prose. * * @stable */ export type ConflictPipelineStage = | 'exact-dedup' | 'embedding-three-zone' | 'heuristic-regex' | 'subject-predicate' | 'defer-to-deep'; /** * Final outcome the pipeline produced for a candidate fact write. * `'admit'` is the default no-conflict path; the other variants record * an active intervention. * * @stable */ export type ConflictPipelineDecision = | 'admit' | 'dedup' | 'supersede' | 'pending' /** Deep-phase judge failed repeatedly; row closed un-adjudicated. */ | 'judge-unparseable'; /** * Per-decision row written by `runConflictPipeline(...)`. One row per * `SemanticMemory.remember(...)` invocation, even when no conflict was * detected (so operators can audit pipeline coverage). * * @stable */ export interface ConflictAuditInput { readonly scope: SessionScope; readonly candidateId: string; readonly existingId?: string; readonly decision: ConflictPipelineDecision; readonly stage: ConflictPipelineStage; /** `'hot' | 'near-dup' | 'conflict-check' | 'cold' | 'heuristic' | 'subject-predicate'` (Stage 2/3/4). */ readonly detectionZone?: string; /** Cosine similarity captured during Stage 2. `null` for non-embedding stages. */ readonly similarity?: number; readonly reason?: string; /** Defaults to `'sync-write'`. Future LLM judge will pass `'consolidator-deep'`. */ readonly detectedBy?: string; } /** * Row shape returned from `recordDecision(...)`. * * @stable */ export interface ConflictAuditRow { readonly id: number; readonly detectedAt: number; } /** * Per-pending row enqueued for the deep-phase LLM judge. * * @stable */ export interface PendingConflictInput { readonly scope: SessionScope; readonly factId: string; readonly candidateText: string; readonly stage: ConflictPipelineStage; readonly reason?: string; /** Top-K conflicting existing fact ids surfaced by Stage 2. */ readonly conflictingIds?: ReadonlyArray; } /** * Read-back shape for `listPending(...)`. Surfaces the row id so the * deep phase can claim + resolve it later. * * @stable */ export interface PendingConflictRow { readonly id: number; readonly scopeUserId: string; readonly factId: string; readonly candidateText: string; readonly stage: string; readonly reason: string | null; readonly enqueuedAt: number; readonly attemptedAt: number | null; readonly resolvedAt: number | null; readonly decision: string | null; /** Top-K conflicting existing fact ids; empty when omitted at enqueue. */ readonly conflictingIds: ReadonlyArray; } /** * Parse the JSON-encoded `conflicting_ids_json` column. Empty / null / * malformed payloads degrade to an empty list so a corrupt row never * crashes the deep-phase reader. * * @internal */ function parseConflictingIds(json: string | null): ReadonlyArray { if (json === null || json.length === 0) return []; try { const parsed: unknown = JSON.parse(json); if (!Array.isArray(parsed)) return []; return parsed.filter((value): value is string => typeof value === 'string'); } catch { return []; } } /** * SQLite-backed conflict store. Constructed by `SqliteMemoryStore`; * never instantiated directly by application code. * * @stable */ export class SqliteConflictStore { readonly #conn: SqliteConnection; constructor(conn: SqliteConnection) { this.#conn = conn; } /** Record a single decision row. Returns the autogenerated id. */ async recordDecision(input: ConflictAuditInput): Promise { const detectedAt = Date.now(); this.#conn.run( `INSERT INTO fact_conflicts ( scope_user_id, candidate_id, existing_id, decision, stage, detection_zone, similarity, reason, detected_by, detected_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ input.scope.userId, input.candidateId, input.existingId ?? null, input.decision, input.stage, input.detectionZone ?? null, input.similarity ?? null, input.reason ?? null, input.detectedBy ?? 'sync-write', detectedAt, ], ); const row = this.#conn.get<{ id: number | bigint }>('SELECT last_insert_rowid() AS id', []); const id = Number(row?.id ?? 0); return { id, detectedAt }; } /** Enqueue a candidate fact + the conflicting fact ids for the deep phase. */ async enqueuePending(input: PendingConflictInput): Promise<{ readonly id: number }> { const enqueuedAt = Date.now(); const conflictingJson = input.conflictingIds !== undefined && input.conflictingIds.length > 0 ? JSON.stringify([...input.conflictingIds]) : null; this.#conn.run( `INSERT INTO conflict_check_pending ( scope_user_id, fact_id, candidate_text, stage, reason, enqueued_at, conflicting_ids_json ) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ input.scope.userId, input.factId, input.candidateText, input.stage, input.reason ?? null, enqueuedAt, conflictingJson, ], ); const row = this.#conn.get<{ id: number | bigint }>('SELECT last_insert_rowid() AS id', []); return { id: Number(row?.id ?? 0) }; } /** List pending rows for the supplied user, oldest-first. */ async listPending(scope: SessionScope, limit = 50): Promise> { const rows = this.#conn.all<{ id: number; scope_user_id: string; fact_id: string; candidate_text: string; stage: string; reason: string | null; enqueued_at: number; attempted_at: number | null; resolved_at: number | null; decision: string | null; conflicting_ids_json: string | null; }>( `SELECT id, scope_user_id, fact_id, candidate_text, stage, reason, enqueued_at, attempted_at, resolved_at, decision, conflicting_ids_json FROM conflict_check_pending WHERE scope_user_id = ? AND resolved_at IS NULL ORDER BY enqueued_at ASC, id ASC LIMIT ?`, [scope.userId, limit], ); return rows.map((row) => ({ id: row.id, scopeUserId: row.scope_user_id, factId: row.fact_id, candidateText: row.candidate_text, stage: row.stage, reason: row.reason, enqueuedAt: row.enqueued_at, attemptedAt: row.attempted_at, resolvedAt: row.resolved_at, decision: row.decision, conflictingIds: parseConflictingIds(row.conflicting_ids_json), })); } /** Mark a pending row resolved (used by the deep phase in Phase 10c). */ async markResolved(id: number, decision: ConflictPipelineDecision): Promise { this.#conn.run( `UPDATE conflict_check_pending SET resolved_at = ?, decision = ? WHERE id = ?`, [Date.now(), decision, id], ); } /** * Stamp `attempted_at` on a pending row whose deep-phase judge call * failed. The deep phase closes the row as * `'judge-unparseable'` on the NEXT failure, bounding how often a * poisoned row can be re-billed. * * @stable */ async markAttempted(id: number, attemptedAt?: number): Promise { this.#conn.run('UPDATE conflict_check_pending SET attempted_at = ? WHERE id = ?', [ attemptedAt ?? Date.now(), id, ]); } /** * Read-back helper used by the audit-replay surface (Phase 14c) and * the test suite - returns up to `limit` recent rows for the scope. * * @stable */ async listRecentDecisions( scope: SessionScope, limit = 50, ): Promise< ReadonlyArray<{ readonly id: number; readonly candidateId: string; readonly existingId: string | null; readonly decision: string; readonly stage: string; readonly detectionZone: string | null; readonly similarity: number | null; readonly reason: string | null; readonly detectedBy: string; readonly detectedAt: number; }> > { const rows = this.#conn.all<{ id: number; candidate_id: string; existing_id: string | null; decision: string; stage: string; detection_zone: string | null; similarity: number | null; reason: string | null; detected_by: string; detected_at: number; }>( `SELECT id, candidate_id, existing_id, decision, stage, detection_zone, similarity, reason, detected_by, detected_at FROM fact_conflicts WHERE scope_user_id = ? ORDER BY id DESC LIMIT ?`, [scope.userId, limit], ); return rows.map((row) => ({ id: row.id, candidateId: row.candidate_id, existingId: row.existing_id, decision: row.decision, stage: row.stage, detectionZone: row.detection_zone, similarity: row.similarity, reason: row.reason, detectedBy: row.detected_by, detectedAt: row.detected_at, })); } }