diff --git a/src/resources/extensions/sf/sf-db/sf-db-schema.js b/src/resources/extensions/sf/sf-db/sf-db-schema.js index 194688ae4..a4239f4fd 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-schema.js +++ b/src/resources/extensions/sf/sf-db/sf-db-schema.js @@ -15,7 +15,7 @@ function defaultQueryTimeout(operation, fallbackValue) { } } -const SCHEMA_VERSION = 63; +const SCHEMA_VERSION = 64; function indexExists(db, name) { return !!db .prepare( @@ -586,6 +586,29 @@ function ensureContextBoardTable(db) { "CREATE INDEX IF NOT EXISTS idx_context_board_repo_branch ON context_board(repository, branch, added_at ASC)", ); } +function ensureSelfFeedbackRelationsTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS self_feedback_relations ( + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + relation_kind TEXT NOT NULL CHECK (relation_kind IN ( + 'supersedes', + 'duplicate_of', + 'blocks', + 'root_cause_of', + 'partial_fix_of' + )), + created_at TEXT NOT NULL, + PRIMARY KEY (from_id, to_id, relation_kind), + FOREIGN KEY (from_id) REFERENCES self_feedback(id), + FOREIGN KEY (to_id) REFERENCES self_feedback(id), + CHECK (from_id != to_id) + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_self_feedback_relations_to ON self_feedback_relations(to_id, relation_kind)", + ); +} function ensureSpecSchemaTables(db) { // Tier 1.3: Spec/Runtime/Evidence schema separation // Creates 9 normalized tables for milestone, slice, task entities @@ -1185,6 +1208,7 @@ export function initSchema(db, fileBacked, options = {}) { ensureRuntimeCounterTable(db); ensureValidationAttentionMarkersTable(db); ensureContextBoardTable(db); + ensureSelfFeedbackRelationsTable(db); db.exec( `CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`, ); @@ -3282,6 +3306,52 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { }); if (ok) appliedVersion = 63; } + if (appliedVersion < 64) { + const ok = runMigrationStep("v64", () => { + // Schema v64: self_feedback_relations — explicit causal links + // between self-feedback entries (sf-mp4rxkwx-jz0soh). Without + // this table, the ledger is a flat list and patterns like + // "X supersedes Y", "A is the root cause of B, C", or + // "this entry is a duplicate of that one" can only be + // expressed in prose, where reflection passes and detectors + // can't act on them. Adding relational semantics lets the + // requirement-promoter, reflection layer, and operator + // tooling reason about graphs of related findings. + // + // CHECK constraints enforce the closed enum of relation kinds + // AND prevent self-loops. The composite primary key allows + // multiple kinds between the same pair (e.g. supersedes AND + // blocks) but prevents exact duplicates. + db.exec(` + CREATE TABLE IF NOT EXISTS self_feedback_relations ( + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + relation_kind TEXT NOT NULL CHECK (relation_kind IN ( + 'supersedes', + 'duplicate_of', + 'blocks', + 'root_cause_of', + 'partial_fix_of' + )), + created_at TEXT NOT NULL, + PRIMARY KEY (from_id, to_id, relation_kind), + FOREIGN KEY (from_id) REFERENCES self_feedback(id), + FOREIGN KEY (to_id) REFERENCES self_feedback(id), + CHECK (from_id != to_id) + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_self_feedback_relations_to ON self_feedback_relations(to_id, relation_kind)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 64, + ":applied_at": new Date().toISOString(), + }); + }); + if (ok) appliedVersion = 64; + } // Post-migration assertion: ensure critical tables created by historical // migrations are actually present. If a prior migration claimed success but diff --git a/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js b/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js index 84655ae63..4d42b8c7a 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js +++ b/src/resources/extensions/sf/sf-db/sf-db-self-feedback.js @@ -55,6 +55,114 @@ export function listSelfFeedbackEntries() { return rows.map(rowToSelfFeedback); } +/** + * Allowed relation kinds between self-feedback entries (sf-mp4rxkwx-jz0soh, + * v64 schema migration). The CHECK constraint on the table enforces this + * at the DB layer; this constant exists so callers can validate before + * attempting an insert and surface a structured error. + */ +export const SELF_FEEDBACK_RELATION_KINDS = Object.freeze([ + "supersedes", + "duplicate_of", + "blocks", + "root_cause_of", + "partial_fix_of", +]); + +/** + * Insert a directed relation between two self-feedback entries. + * + * `fromId` and `toId` must reference existing self_feedback rows (FK). + * Self-loops are rejected by a CHECK constraint. The same (from, to, kind) + * triple is unique (composite primary key) but multiple kinds between the + * same pair are allowed (e.g. supersedes AND blocks). + * + * Returns true when a row was inserted, false when the triple already + * existed. Throws SFError on FK / CHECK violations or DB unavailability so + * callers see structured failures rather than silent drops. + */ +export function linkEntries(fromId, toId, relationKind) { + const currentDb = _getAdapter(); + if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); + if (!SELF_FEEDBACK_RELATION_KINDS.includes(relationKind)) { + throw new SFError( + SF_STALE_STATE, + `linkEntries: invalid relation_kind "${relationKind}"; allowed: ${SELF_FEEDBACK_RELATION_KINDS.join(", ")}`, + ); + } + // Plain INSERT (NOT "INSERT OR IGNORE") so CHECK and FK violations + // surface as thrown errors. INSERT OR IGNORE silently swallows ALL + // constraint failures — including the from_id != to_id self-loop guard + // and the FK to self_feedback.id — which would let invalid links land + // quietly. We only want idempotency for the composite-PK duplicate + // case, which we detect by error message and return false for. + try { + const result = currentDb + .prepare( + `INSERT INTO self_feedback_relations + (from_id, to_id, relation_kind, created_at) + VALUES (:from_id, :to_id, :relation_kind, :created_at)`, + ) + .run({ + ":from_id": fromId, + ":to_id": toId, + ":relation_kind": relationKind, + ":created_at": new Date().toISOString(), + }); + return result.changes > 0; + } catch (err) { + const msg = err && typeof err === "object" && "message" in err ? String(err.message) : ""; + if ( + msg.includes("UNIQUE constraint failed") || + msg.includes("PRIMARY KEY constraint failed") + ) { + return false; // idempotent: same triple already linked + } + throw err; + } +} + +/** + * Return all relations involving the given entry id, in both directions. + * + * Each result is { id, relationKind, direction } where: + * direction === "outbound" — `entryId` is the from-side; `id` is the to-side + * direction === "inbound" — `entryId` is the to-side; `id` is the from-side + * + * The two-direction surface lets callers ask "what does this entry + * reference, and what references it" in one call. Order is unspecified. + */ +export function getRelatedEntries(entryId) { + const currentDb = _getAdapter(); + if (!currentDb) return []; + const out = []; + const outbound = currentDb + .prepare( + "SELECT to_id AS id, relation_kind FROM self_feedback_relations WHERE from_id = :id", + ) + .all({ ":id": entryId }); + for (const row of outbound) { + out.push({ + id: row.id, + relationKind: row.relation_kind, + direction: "outbound", + }); + } + const inbound = currentDb + .prepare( + "SELECT from_id AS id, relation_kind FROM self_feedback_relations WHERE to_id = :id", + ) + .all({ ":id": entryId }); + for (const row of inbound) { + out.push({ + id: row.id, + relationKind: row.relation_kind, + direction: "inbound", + }); + } + return out; +} + export function resolveSelfFeedbackEntry(entryId, resolution) { const currentDb = _getAdapter(); if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open"); diff --git a/src/resources/extensions/sf/tests/self-feedback-relations.test.mjs b/src/resources/extensions/sf/tests/self-feedback-relations.test.mjs new file mode 100644 index 000000000..badfdb115 --- /dev/null +++ b/src/resources/extensions/sf/tests/self-feedback-relations.test.mjs @@ -0,0 +1,148 @@ +/** + * self-feedback-relations.test.mjs — causal links between entries + * (sf-mp4rxkwx-jz0soh, v64 schema migration). + * + * Covers: linkEntries inserts; CHECK rejects self-loops; CHECK rejects + * unknown relation_kind; getRelatedEntries returns both directions; + * FK rejects when from_id doesn't exist; idempotent re-link returns false. + */ +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + closeDatabase, + getRelatedEntries, + linkEntries, + openDatabase, + SELF_FEEDBACK_RELATION_KINDS, +} from "../sf-db.js"; +import { recordSelfFeedback } from "../self-feedback.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeForgeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-relations-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf"), { recursive: true }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "singularity-forge" }), + ); + openDatabase(join(dir, ".sf", "sf.db")); + return dir; +} + +function fileTwo(project) { + const a = recordSelfFeedback( + { kind: "gap:routing:foo", severity: "low", summary: "A" }, + project, + ); + const b = recordSelfFeedback( + { kind: "gap:routing:bar", severity: "low", summary: "B" }, + project, + ); + expect(a).toBeTruthy(); + expect(b).toBeTruthy(); + return [a.entry.id, b.entry.id]; +} + +describe("SELF_FEEDBACK_RELATION_KINDS", () => { + test("exposes the closed enum of allowed relations", () => { + expect(Array.from(SELF_FEEDBACK_RELATION_KINDS).sort()).toEqual([ + "blocks", + "duplicate_of", + "partial_fix_of", + "root_cause_of", + "supersedes", + ]); + }); +}); + +describe("linkEntries", () => { + test("inserts a new (from, to, kind) triple and returns true", () => { + const project = makeForgeProject(); + const [a, b] = fileTwo(project); + expect(linkEntries(a, b, "supersedes")).toBe(true); + }); + + test("returns false on duplicate insert (idempotent)", () => { + const project = makeForgeProject(); + const [a, b] = fileTwo(project); + expect(linkEntries(a, b, "supersedes")).toBe(true); + expect(linkEntries(a, b, "supersedes")).toBe(false); + }); + + test("allows multiple relation kinds between the same pair", () => { + const project = makeForgeProject(); + const [a, b] = fileTwo(project); + expect(linkEntries(a, b, "supersedes")).toBe(true); + expect(linkEntries(a, b, "blocks")).toBe(true); + }); + + test("throws on an unknown relation_kind (writer-layer guard)", () => { + const project = makeForgeProject(); + const [a, b] = fileTwo(project); + expect(() => linkEntries(a, b, "invented")).toThrow(/invalid relation_kind/); + }); + + test("throws on self-loop (CHECK constraint)", () => { + const project = makeForgeProject(); + const [a] = fileTwo(project); + expect(() => linkEntries(a, a, "supersedes")).toThrow(); + }); + + test("throws on FK violation when from_id is unknown", () => { + const project = makeForgeProject(); + const [, b] = fileTwo(project); + expect(() => + linkEntries("sf-does-not-exist", b, "supersedes"), + ).toThrow(); + }); +}); + +describe("getRelatedEntries", () => { + test("returns outbound and inbound relations for the given id", () => { + const project = makeForgeProject(); + const [a, b] = fileTwo(project); + linkEntries(a, b, "supersedes"); + linkEntries(b, a, "blocks"); // b blocks a — b is from, a is to + + const aRelations = getRelatedEntries(a); + expect(aRelations).toHaveLength(2); + const aOutbound = aRelations.find((r) => r.direction === "outbound"); + const aInbound = aRelations.find((r) => r.direction === "inbound"); + expect(aOutbound).toEqual({ + id: b, + relationKind: "supersedes", + direction: "outbound", + }); + expect(aInbound).toEqual({ + id: b, + relationKind: "blocks", + direction: "inbound", + }); + + const bRelations = getRelatedEntries(b); + expect(bRelations).toHaveLength(2); + }); + + test("returns empty array when entry has no relations", () => { + const project = makeForgeProject(); + const [a] = fileTwo(project); + expect(getRelatedEntries(a)).toEqual([]); + }); +}); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 0f19f3126..6cd15a841 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -273,7 +273,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 63); + assert.equal(version.version, 64); // v61: intent_chapters table exists const chaptersTable = db .prepare( @@ -304,6 +304,16 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", contextBoardTable, "context_board table should exist after v63 migration", ); + // v64: self_feedback_relations table exists (causal links between entries) + const relationsTable = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='self_feedback_relations'", + ) + .get(); + assert.ok( + relationsTable, + "self_feedback_relations table should exist after v64 migration", + ); const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", @@ -345,11 +355,11 @@ test("openDatabase_v52_db_heals_routing_history_and_auto_start_path_works", () = initRoutingHistory(dbPath); }, "initRoutingHistory should not throw on a v52 DB"); - // Schema should have migrated to v63 (current head) + // Schema should have migrated to v64 (current head) const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 63); + assert.equal(version.version, 64); }); test("openDatabase_when_fresh_db_supports_schedule_entries", () => {