From d40a3d21ddbffc8dcb38516b8484fb6b9fb47c19 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 14 May 2026 05:46:56 +0200 Subject: [PATCH] feat(self-feedback): causal-link relations between entries (v64 migration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses sf-mp4rxkwx-jz0soh (gap:no-causal-links-between-self-feedback- entries). Third sibling of the consolidating reflection entry sf-mp4w89mv-3ulqp4 (data-plane-isolation cluster). Schema v64 adds self_feedback_relations: from_id TEXT NOT NULL (FK → self_feedback.id) to_id TEXT NOT NULL (FK → self_feedback.id) relation_kind TEXT NOT NULL (CHECK: closed enum of 5 kinds) created_at TEXT NOT NULL PRIMARY KEY (from_id, to_id, relation_kind) CHECK (from_id != to_id) INDEX on (to_id, relation_kind) for inbound queries Allowed kinds: supersedes, duplicate_of, blocks, root_cause_of, partial_fix_of. The composite PK allows multiple kinds between the same pair (e.g. "A supersedes B AND blocks B") but prevents exact triple duplicates. Helpers in sf-db-self-feedback.js: SELF_FEEDBACK_RELATION_KINDS frozen array of allowed kinds linkEntries(from, to, kind) inserts; returns true on new row, false on PK collision (idempotent), throws on FK / CHECK / unknown-kind getRelatedEntries(id) returns [{id, relationKind, direction: 'outbound'|'inbound'}] — inbound + outbound in one call Implementation note: linkEntries uses plain INSERT (NOT INSERT OR IGNORE) so CHECK and FK violations surface as thrown errors. Idempotency for PK collisions is implemented by catching the specific error message. INSERT OR IGNORE would have silently swallowed self-loops and broken FKs — exactly the kind of writer-layer bug we just fixed in 83c28b756 and the upsertRequirement repair in f92022730. Tests: sf-db-migration.test.mjs — 2 assertion bumps (63 → 64) + new self_feedback_relations table-exists check self-feedback-relations.test.mjs (new, 9 tests) — SELF_FEEDBACK_RELATION_KINDS enum shape linkEntries inserts new triple linkEntries idempotent on duplicate linkEntries allows multiple kinds same pair linkEntries throws on unknown kind (writer-layer) linkEntries throws on self-loop (CHECK) linkEntries throws on missing FK getRelatedEntries returns outbound + inbound getRelatedEntries empty for unlinked entries 1610/1610 SF extension tests pass; typecheck clean. Note on dispatch: this work was first attempted via "sf headless -p" to dogfood per memory rule. The dispatch ran 99s with 19 tool calls but went off-script — modified 10+ files in packages/ai/providers/ (adding wireModelId field across all providers, separate refactor) and never touched sf-db-schema.js or the relations table. Hand-coded fallback applied; off-script-dispatch pattern logged as another data point in sf-mp4rxkwb-l4baga (triage-not-a-first-class-unit-type). The wireModelId provider changes remain uncommitted in the working tree for operator review — they may be valuable but were not the requested work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../extensions/sf/sf-db/sf-db-schema.js | 72 ++++++++- .../sf/sf-db/sf-db-self-feedback.js | 108 +++++++++++++ .../sf/tests/self-feedback-relations.test.mjs | 148 ++++++++++++++++++ .../sf/tests/sf-db-migration.test.mjs | 16 +- 4 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 src/resources/extensions/sf/tests/self-feedback-relations.test.mjs 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", () => {