feat(self-feedback): causal-link relations between entries (v64 migration)
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) <noreply@anthropic.com>
This commit is contained in:
parent
f92022730b
commit
d40a3d21dd
4 changed files with 340 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue