hippo-memory 1.15.0 → 1.16.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.
Files changed (97) hide show
  1. package/README.md +862 -861
  2. package/dist/audit.d.ts +1 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +1243 -3
  7. package/dist/cli.js.map +1 -1
  8. package/dist/customer-notes.d.ts +95 -0
  9. package/dist/customer-notes.d.ts.map +1 -0
  10. package/dist/customer-notes.js +296 -0
  11. package/dist/customer-notes.js.map +1 -0
  12. package/dist/db.d.ts.map +1 -1
  13. package/dist/db.js +731 -1
  14. package/dist/db.js.map +1 -1
  15. package/dist/graph-extract.d.ts +39 -0
  16. package/dist/graph-extract.d.ts.map +1 -0
  17. package/dist/graph-extract.js +141 -0
  18. package/dist/graph-extract.js.map +1 -0
  19. package/dist/graph-recall.d.ts +41 -0
  20. package/dist/graph-recall.d.ts.map +1 -0
  21. package/dist/graph-recall.js +246 -0
  22. package/dist/graph-recall.js.map +1 -0
  23. package/dist/graph.d.ts +137 -0
  24. package/dist/graph.d.ts.map +1 -0
  25. package/dist/graph.js +433 -0
  26. package/dist/graph.js.map +1 -0
  27. package/dist/incidents.d.ts +100 -0
  28. package/dist/incidents.d.ts.map +1 -0
  29. package/dist/incidents.js +322 -0
  30. package/dist/incidents.js.map +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/memory.d.ts +6 -0
  36. package/dist/memory.d.ts.map +1 -1
  37. package/dist/memory.js +6 -0
  38. package/dist/memory.js.map +1 -1
  39. package/dist/policies.d.ts +149 -0
  40. package/dist/policies.d.ts.map +1 -0
  41. package/dist/policies.js +380 -0
  42. package/dist/policies.js.map +1 -0
  43. package/dist/processes.d.ts +104 -0
  44. package/dist/processes.d.ts.map +1 -0
  45. package/dist/processes.js +330 -0
  46. package/dist/processes.js.map +1 -0
  47. package/dist/project-briefs.d.ts +126 -0
  48. package/dist/project-briefs.d.ts.map +1 -0
  49. package/dist/project-briefs.js +453 -0
  50. package/dist/project-briefs.js.map +1 -0
  51. package/dist/search.d.ts +7 -0
  52. package/dist/search.d.ts.map +1 -1
  53. package/dist/search.js.map +1 -1
  54. package/dist/server.d.ts.map +1 -1
  55. package/dist/server.js +1028 -16
  56. package/dist/server.js.map +1 -1
  57. package/dist/skills.d.ts +98 -0
  58. package/dist/skills.d.ts.map +1 -0
  59. package/dist/skills.js +339 -0
  60. package/dist/skills.js.map +1 -0
  61. package/dist/src/audit.js.map +1 -1
  62. package/dist/src/cli.js +1243 -3
  63. package/dist/src/cli.js.map +1 -1
  64. package/dist/src/customer-notes.js +296 -0
  65. package/dist/src/customer-notes.js.map +1 -0
  66. package/dist/src/db.js +731 -1
  67. package/dist/src/db.js.map +1 -1
  68. package/dist/src/graph-extract.js +141 -0
  69. package/dist/src/graph-extract.js.map +1 -0
  70. package/dist/src/graph-recall.js +246 -0
  71. package/dist/src/graph-recall.js.map +1 -0
  72. package/dist/src/graph.js +433 -0
  73. package/dist/src/graph.js.map +1 -0
  74. package/dist/src/incidents.js +322 -0
  75. package/dist/src/incidents.js.map +1 -0
  76. package/dist/src/index.js +1 -0
  77. package/dist/src/index.js.map +1 -1
  78. package/dist/src/memory.js +6 -0
  79. package/dist/src/memory.js.map +1 -1
  80. package/dist/src/policies.js +380 -0
  81. package/dist/src/policies.js.map +1 -0
  82. package/dist/src/processes.js +330 -0
  83. package/dist/src/processes.js.map +1 -0
  84. package/dist/src/project-briefs.js +453 -0
  85. package/dist/src/project-briefs.js.map +1 -0
  86. package/dist/src/search.js.map +1 -1
  87. package/dist/src/server.js +1028 -16
  88. package/dist/src/server.js.map +1 -1
  89. package/dist/src/skills.js +339 -0
  90. package/dist/src/skills.js.map +1 -0
  91. package/dist/src/version.js +1 -1
  92. package/dist/version.d.ts +1 -1
  93. package/dist/version.js +1 -1
  94. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  95. package/extensions/openclaw-plugin/package.json +1 -1
  96. package/openclaw.plugin.json +1 -1
  97. package/package.json +2 -2
package/dist/src/db.js CHANGED
@@ -6,7 +6,7 @@ import { cleanupArchivedMirrors } from './raw-archive-mirror-cleanup.js';
6
6
  import { PACKAGE_VERSION, compareSemver } from './version.js';
7
7
  const require = createRequire(import.meta.url);
8
8
  const { DatabaseSync } = require('node:sqlite');
9
- const CURRENT_SCHEMA_VERSION = 30;
9
+ const CURRENT_SCHEMA_VERSION = 37;
10
10
  const MIGRATIONS = [
11
11
  {
12
12
  version: 1,
@@ -1092,6 +1092,736 @@ const MIGRATIONS = [
1092
1092
  }
1093
1093
  },
1094
1094
  },
1095
+ {
1096
+ version: 31,
1097
+ up: (db) => {
1098
+ // E2 incident first-class object (docs/plans/2026-05-29-e2-incident-object.md).
1099
+ // Mirrors the v30 decisions block but for an open -> resolved -> closed
1100
+ // lifecycle (NOT supersede): there is no superseded_by self-FK and no
1101
+ // supersede trigger. An incident is a postmortem capsule: a recorded
1102
+ // operational event with a lifecycle and optional linked receipts (the
1103
+ // memories that are its evidence, stored as a JSON array of ids in
1104
+ // linked_memory_ids). The memory mirror is kept for recall but is not
1105
+ // authoritative; memory_id is NULLABLE with ON DELETE SET NULL so
1106
+ // forget/consolidate/archive does not lose an incident.
1107
+ //
1108
+ // status (open|resolved|closed): resolved records a resolution_text +
1109
+ // resolved_at and stays on record; closed is a terminal retire reachable
1110
+ // from open or resolved. Cross-tenant safety: BEFORE INSERT + BEFORE
1111
+ // UPDATE triggers enforce incidents.tenant_id == the referenced memory's
1112
+ // tenant_id (verbatim mirror of the v30 decisions tenant-match triggers).
1113
+ if (!tableExists(db, 'incidents')) {
1114
+ db.exec(`
1115
+ CREATE TABLE incidents (
1116
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1117
+ memory_id TEXT,
1118
+ tenant_id TEXT NOT NULL,
1119
+ incident_text TEXT NOT NULL,
1120
+ context TEXT,
1121
+ status TEXT NOT NULL DEFAULT 'open'
1122
+ CHECK (status IN ('open', 'resolved', 'closed')),
1123
+ resolution_text TEXT,
1124
+ resolved_at TEXT,
1125
+ closed_at TEXT,
1126
+ linked_memory_ids TEXT NOT NULL DEFAULT '[]',
1127
+ created_at TEXT NOT NULL,
1128
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
1129
+ )
1130
+ `);
1131
+ db.exec(`
1132
+ CREATE INDEX IF NOT EXISTS idx_incidents_tenant_status
1133
+ ON incidents(tenant_id, status)
1134
+ `);
1135
+ db.exec(`
1136
+ CREATE INDEX IF NOT EXISTS idx_incidents_memory
1137
+ ON incidents(memory_id) WHERE memory_id IS NOT NULL
1138
+ `);
1139
+ // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1140
+ // v30 decisions tenant-match triggers; no supersede trigger).
1141
+ db.exec(`
1142
+ CREATE TRIGGER IF NOT EXISTS trg_incidents_tenant_match_insert
1143
+ BEFORE INSERT ON incidents
1144
+ WHEN NEW.memory_id IS NOT NULL
1145
+ BEGIN
1146
+ SELECT CASE
1147
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1148
+ THEN RAISE(ABORT, 'incidents.tenant_id must match memories.tenant_id for the referenced memory')
1149
+ END;
1150
+ END
1151
+ `);
1152
+ db.exec(`
1153
+ CREATE TRIGGER IF NOT EXISTS trg_incidents_tenant_match_update
1154
+ BEFORE UPDATE ON incidents
1155
+ WHEN NEW.memory_id IS NOT NULL
1156
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1157
+ BEGIN
1158
+ SELECT CASE
1159
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1160
+ THEN RAISE(ABORT, 'incidents.tenant_id must match memories.tenant_id for the referenced memory')
1161
+ END;
1162
+ END
1163
+ `);
1164
+ }
1165
+ },
1166
+ },
1167
+ {
1168
+ version: 32,
1169
+ up: (db) => {
1170
+ // E2 process first-class object (docs/plans/2026-05-29-e2-process-object.md).
1171
+ // A process is a "living process map": a named, ordered list of steps that
1172
+ // evolves. Unlike incident (open->resolved->closed, no supersede), process
1173
+ // REUSES the v30 decisions supersede path as its delta mechanism: a process
1174
+ // evolves by being superseded by a NEW VERSION that records what changed
1175
+ // (change_summary) and the full new state (steps), carrying a derived
1176
+ // version counter. So this table combines the v31 incidents tenant-match
1177
+ // trigger pair (vs the referenced memory) WITH the v30 decisions
1178
+ // superseded_by self-FK + supersede tenant-match trigger.
1179
+ //
1180
+ // status (active|superseded|closed): superseded carries a self-FK
1181
+ // superseded_by to the successor version; closed is a terminal
1182
+ // retire-without-successor (only an active head closes). The memory mirror
1183
+ // is kept for recall but is not authoritative; memory_id is NULLABLE with
1184
+ // ON DELETE SET NULL so forget/consolidate/archive does not lose a process.
1185
+ // steps is a JSON-encoded array of step strings (scoped v1; a normalized
1186
+ // process_steps table is deferred). version is server-derived
1187
+ // (predecessor.version + 1); change_summary is set on a successor row only.
1188
+ if (!tableExists(db, 'processes')) {
1189
+ db.exec(`
1190
+ CREATE TABLE processes (
1191
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1192
+ memory_id TEXT,
1193
+ tenant_id TEXT NOT NULL,
1194
+ process_name TEXT NOT NULL,
1195
+ description TEXT,
1196
+ steps TEXT NOT NULL DEFAULT '[]',
1197
+ version INTEGER NOT NULL DEFAULT 1,
1198
+ status TEXT NOT NULL DEFAULT 'active'
1199
+ CHECK (status IN ('active', 'superseded', 'closed')),
1200
+ superseded_by INTEGER,
1201
+ superseded_at TEXT,
1202
+ change_summary TEXT,
1203
+ closed_at TEXT,
1204
+ created_at TEXT NOT NULL,
1205
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1206
+ FOREIGN KEY (superseded_by) REFERENCES processes(id) ON DELETE SET NULL
1207
+ )
1208
+ `);
1209
+ db.exec(`
1210
+ CREATE INDEX IF NOT EXISTS idx_processes_tenant_status
1211
+ ON processes(tenant_id, status)
1212
+ `);
1213
+ db.exec(`
1214
+ CREATE INDEX IF NOT EXISTS idx_processes_memory
1215
+ ON processes(memory_id) WHERE memory_id IS NOT NULL
1216
+ `);
1217
+ // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1218
+ // v31 incidents / v30 decisions tenant-match triggers).
1219
+ db.exec(`
1220
+ CREATE TRIGGER IF NOT EXISTS trg_processes_tenant_match_insert
1221
+ BEFORE INSERT ON processes
1222
+ WHEN NEW.memory_id IS NOT NULL
1223
+ BEGIN
1224
+ SELECT CASE
1225
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1226
+ THEN RAISE(ABORT, 'processes.tenant_id must match memories.tenant_id for the referenced memory')
1227
+ END;
1228
+ END
1229
+ `);
1230
+ db.exec(`
1231
+ CREATE TRIGGER IF NOT EXISTS trg_processes_tenant_match_update
1232
+ BEFORE UPDATE ON processes
1233
+ WHEN NEW.memory_id IS NOT NULL
1234
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1235
+ BEGIN
1236
+ SELECT CASE
1237
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1238
+ THEN RAISE(ABORT, 'processes.tenant_id must match memories.tenant_id for the referenced memory')
1239
+ END;
1240
+ END
1241
+ `);
1242
+ // Cross-tenant safety vs the successor process (self-FK; verbatim mirror
1243
+ // of the v30 decisions supersede trigger). superseded_by is set only via
1244
+ // the supersede UPDATE; the successor must share the tenant. The successor
1245
+ // row already exists in the same transaction when this fires.
1246
+ db.exec(`
1247
+ CREATE TRIGGER IF NOT EXISTS trg_processes_supersede_tenant_match_update
1248
+ BEFORE UPDATE ON processes
1249
+ WHEN NEW.superseded_by IS NOT NULL
1250
+ AND NEW.superseded_by IS NOT OLD.superseded_by
1251
+ BEGIN
1252
+ SELECT CASE
1253
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM processes WHERE id = NEW.superseded_by)
1254
+ THEN RAISE(ABORT, 'processes.superseded_by must reference a process in the same tenant')
1255
+ END;
1256
+ END
1257
+ `);
1258
+ }
1259
+ },
1260
+ },
1261
+ {
1262
+ version: 33,
1263
+ up: (db) => {
1264
+ // E2 policy first-class object (docs/plans/2026-05-30-e2-policy-object.md).
1265
+ // The "bi-temporal-first" object type: a named rule/statement that is in
1266
+ // force over an EFFECTIVE-TIME range (valid_from required, valid_to nullable
1267
+ // = open-ended) and evolves via the v32 processes supersede machinery
1268
+ // (superseded_by self-FK + supersede tenant-match trigger + version +
1269
+ // change_summary). This table = the v32 processes table MINUS `steps`
1270
+ // (a policy has policy_text, not an ordered step list) PLUS the first-class
1271
+ // effective-time columns valid_from/valid_to. Valid-time is the queryable
1272
+ // axis (the as-of query loadPoliciesAsOf); transaction-time is present via
1273
+ // created_at + the supersede chain's superseded_at (time-travel deferred).
1274
+ //
1275
+ // All date inputs are normalized to canonical ISO-8601 datetime
1276
+ // (toISOString) at the store boundary before persist/compare, so the
1277
+ // fixed-width values sort lexically and the half-open [valid_from, valid_to)
1278
+ // as-of comparison is correct (plan-eng-critic round-1 CRIT fix).
1279
+ if (!tableExists(db, 'policies')) {
1280
+ db.exec(`
1281
+ CREATE TABLE policies (
1282
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1283
+ memory_id TEXT,
1284
+ tenant_id TEXT NOT NULL,
1285
+ policy_name TEXT NOT NULL,
1286
+ policy_text TEXT NOT NULL,
1287
+ valid_from TEXT NOT NULL,
1288
+ valid_to TEXT,
1289
+ version INTEGER NOT NULL DEFAULT 1,
1290
+ status TEXT NOT NULL DEFAULT 'active'
1291
+ CHECK (status IN ('active', 'superseded', 'closed')),
1292
+ superseded_by INTEGER,
1293
+ superseded_at TEXT,
1294
+ change_summary TEXT,
1295
+ closed_at TEXT,
1296
+ created_at TEXT NOT NULL,
1297
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1298
+ FOREIGN KEY (superseded_by) REFERENCES policies(id) ON DELETE SET NULL
1299
+ )
1300
+ `);
1301
+ db.exec(`
1302
+ CREATE INDEX IF NOT EXISTS idx_policies_tenant_status
1303
+ ON policies(tenant_id, status)
1304
+ `);
1305
+ db.exec(`
1306
+ CREATE INDEX IF NOT EXISTS idx_policies_memory
1307
+ ON policies(memory_id) WHERE memory_id IS NOT NULL
1308
+ `);
1309
+ // Supports the as-of query (active policies in force at a valid-time).
1310
+ db.exec(`
1311
+ CREATE INDEX IF NOT EXISTS idx_policies_asof
1312
+ ON policies(tenant_id, valid_from)
1313
+ `);
1314
+ // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1315
+ // v32 processes tenant-match triggers).
1316
+ db.exec(`
1317
+ CREATE TRIGGER IF NOT EXISTS trg_policies_tenant_match_insert
1318
+ BEFORE INSERT ON policies
1319
+ WHEN NEW.memory_id IS NOT NULL
1320
+ BEGIN
1321
+ SELECT CASE
1322
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1323
+ THEN RAISE(ABORT, 'policies.tenant_id must match memories.tenant_id for the referenced memory')
1324
+ END;
1325
+ END
1326
+ `);
1327
+ db.exec(`
1328
+ CREATE TRIGGER IF NOT EXISTS trg_policies_tenant_match_update
1329
+ BEFORE UPDATE ON policies
1330
+ WHEN NEW.memory_id IS NOT NULL
1331
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1332
+ BEGIN
1333
+ SELECT CASE
1334
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1335
+ THEN RAISE(ABORT, 'policies.tenant_id must match memories.tenant_id for the referenced memory')
1336
+ END;
1337
+ END
1338
+ `);
1339
+ // Cross-tenant safety vs the successor policy (self-FK; verbatim mirror of
1340
+ // the v32 processes supersede trigger).
1341
+ db.exec(`
1342
+ CREATE TRIGGER IF NOT EXISTS trg_policies_supersede_tenant_match_update
1343
+ BEFORE UPDATE ON policies
1344
+ WHEN NEW.superseded_by IS NOT NULL
1345
+ AND NEW.superseded_by IS NOT OLD.superseded_by
1346
+ BEGIN
1347
+ SELECT CASE
1348
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM policies WHERE id = NEW.superseded_by)
1349
+ THEN RAISE(ABORT, 'policies.superseded_by must reference a policy in the same tenant')
1350
+ END;
1351
+ END
1352
+ `);
1353
+ }
1354
+ },
1355
+ },
1356
+ {
1357
+ version: 34,
1358
+ up: (db) => {
1359
+ // E2 skill first-class object (docs/plans/2026-05-30-e2-skill-object.md).
1360
+ // A skill is a reusable, agent-followable capability: an `instructions` body
1361
+ // + an optional `trigger_text` (when to apply), evolving via the v32
1362
+ // processes supersede machinery (superseded_by self-FK + supersede
1363
+ // tenant-match trigger + version + change_summary). This table = the v32
1364
+ // processes table MINUS `steps` (a skill's content is a single instructions
1365
+ // body) PLUS `instructions` (NOT NULL) and `trigger_text`. "Executable" is
1366
+ // scoped to an agent-followable instruction that EXPORTS into the agent's
1367
+ // in-force rules (AGENTS.md / CLAUDE.md) via exportSkills; literal code
1368
+ // execution is deferred. NOTE: the column is `trigger_text`, NOT `trigger`,
1369
+ // because TRIGGER is a SQLite reserved keyword.
1370
+ if (!tableExists(db, 'skills')) {
1371
+ db.exec(`
1372
+ CREATE TABLE skills (
1373
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1374
+ memory_id TEXT,
1375
+ tenant_id TEXT NOT NULL,
1376
+ skill_name TEXT NOT NULL,
1377
+ instructions TEXT NOT NULL,
1378
+ trigger_text TEXT,
1379
+ version INTEGER NOT NULL DEFAULT 1,
1380
+ status TEXT NOT NULL DEFAULT 'active'
1381
+ CHECK (status IN ('active', 'superseded', 'closed')),
1382
+ superseded_by INTEGER,
1383
+ superseded_at TEXT,
1384
+ change_summary TEXT,
1385
+ closed_at TEXT,
1386
+ created_at TEXT NOT NULL,
1387
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1388
+ FOREIGN KEY (superseded_by) REFERENCES skills(id) ON DELETE SET NULL
1389
+ )
1390
+ `);
1391
+ db.exec(`
1392
+ CREATE INDEX IF NOT EXISTS idx_skills_tenant_status
1393
+ ON skills(tenant_id, status)
1394
+ `);
1395
+ db.exec(`
1396
+ CREATE INDEX IF NOT EXISTS idx_skills_memory
1397
+ ON skills(memory_id) WHERE memory_id IS NOT NULL
1398
+ `);
1399
+ // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1400
+ // v32 processes tenant-match triggers).
1401
+ db.exec(`
1402
+ CREATE TRIGGER IF NOT EXISTS trg_skills_tenant_match_insert
1403
+ BEFORE INSERT ON skills
1404
+ WHEN NEW.memory_id IS NOT NULL
1405
+ BEGIN
1406
+ SELECT CASE
1407
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1408
+ THEN RAISE(ABORT, 'skills.tenant_id must match memories.tenant_id for the referenced memory')
1409
+ END;
1410
+ END
1411
+ `);
1412
+ db.exec(`
1413
+ CREATE TRIGGER IF NOT EXISTS trg_skills_tenant_match_update
1414
+ BEFORE UPDATE ON skills
1415
+ WHEN NEW.memory_id IS NOT NULL
1416
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1417
+ BEGIN
1418
+ SELECT CASE
1419
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1420
+ THEN RAISE(ABORT, 'skills.tenant_id must match memories.tenant_id for the referenced memory')
1421
+ END;
1422
+ END
1423
+ `);
1424
+ // Cross-tenant safety vs the successor skill (self-FK; verbatim mirror of
1425
+ // the v32 processes supersede trigger).
1426
+ db.exec(`
1427
+ CREATE TRIGGER IF NOT EXISTS trg_skills_supersede_tenant_match_update
1428
+ BEFORE UPDATE ON skills
1429
+ WHEN NEW.superseded_by IS NOT NULL
1430
+ AND NEW.superseded_by IS NOT OLD.superseded_by
1431
+ BEGIN
1432
+ SELECT CASE
1433
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM skills WHERE id = NEW.superseded_by)
1434
+ THEN RAISE(ABORT, 'skills.superseded_by must reference a skill in the same tenant')
1435
+ END;
1436
+ END
1437
+ `);
1438
+ }
1439
+ },
1440
+ },
1441
+ {
1442
+ version: 35,
1443
+ up: (db) => {
1444
+ // E2 project_brief first-class object
1445
+ // (docs/plans/2026-05-30-e2-project-brief-object.md). A project_brief is the
1446
+ // living, repo-scoped summary of a repository's state: a `summary` body
1447
+ // scoped to a `repo`, evolving via the v34 skills supersede machinery
1448
+ // (superseded_by self-FK + supersede tenant-match trigger + version +
1449
+ // change_summary). This table = the v34 skills table with
1450
+ // skill_name/trigger_text replaced by `repo` (the repo-scoping dimension)
1451
+ // PLUS `summary` (the brief body). The distinguishing op (refreshBrief, in
1452
+ // src/project-briefs.ts) auto-assembles the summary from the repo's receipts
1453
+ // (memory rows tagged path:<repo>); it needs no schema support beyond `repo`.
1454
+ // All column names were checked against SQLite reserved words (skill-episode
1455
+ // lesson re: `trigger`): repo/summary/version/status/etc. are non-reserved.
1456
+ if (!tableExists(db, 'project_briefs')) {
1457
+ db.exec(`
1458
+ CREATE TABLE project_briefs (
1459
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1460
+ memory_id TEXT,
1461
+ tenant_id TEXT NOT NULL,
1462
+ repo TEXT NOT NULL,
1463
+ summary TEXT NOT NULL,
1464
+ version INTEGER NOT NULL DEFAULT 1,
1465
+ status TEXT NOT NULL DEFAULT 'active'
1466
+ CHECK (status IN ('active', 'superseded', 'closed')),
1467
+ superseded_by INTEGER,
1468
+ superseded_at TEXT,
1469
+ change_summary TEXT,
1470
+ closed_at TEXT,
1471
+ created_at TEXT NOT NULL,
1472
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1473
+ FOREIGN KEY (superseded_by) REFERENCES project_briefs(id) ON DELETE SET NULL
1474
+ )
1475
+ `);
1476
+ db.exec(`
1477
+ CREATE INDEX IF NOT EXISTS idx_project_briefs_tenant_status
1478
+ ON project_briefs(tenant_id, status)
1479
+ `);
1480
+ db.exec(`
1481
+ CREATE INDEX IF NOT EXISTS idx_project_briefs_memory
1482
+ ON project_briefs(memory_id) WHERE memory_id IS NOT NULL
1483
+ `);
1484
+ db.exec(`
1485
+ CREATE INDEX IF NOT EXISTS idx_project_briefs_repo
1486
+ ON project_briefs(tenant_id, repo, status)
1487
+ `);
1488
+ // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1489
+ // v34 skills tenant-match triggers).
1490
+ db.exec(`
1491
+ CREATE TRIGGER IF NOT EXISTS trg_project_briefs_tenant_match_insert
1492
+ BEFORE INSERT ON project_briefs
1493
+ WHEN NEW.memory_id IS NOT NULL
1494
+ BEGIN
1495
+ SELECT CASE
1496
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1497
+ THEN RAISE(ABORT, 'project_briefs.tenant_id must match memories.tenant_id for the referenced memory')
1498
+ END;
1499
+ END
1500
+ `);
1501
+ db.exec(`
1502
+ CREATE TRIGGER IF NOT EXISTS trg_project_briefs_tenant_match_update
1503
+ BEFORE UPDATE ON project_briefs
1504
+ WHEN NEW.memory_id IS NOT NULL
1505
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1506
+ BEGIN
1507
+ SELECT CASE
1508
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1509
+ THEN RAISE(ABORT, 'project_briefs.tenant_id must match memories.tenant_id for the referenced memory')
1510
+ END;
1511
+ END
1512
+ `);
1513
+ // Cross-tenant safety vs the successor brief (self-FK; verbatim mirror of
1514
+ // the v34 skills supersede trigger).
1515
+ db.exec(`
1516
+ CREATE TRIGGER IF NOT EXISTS trg_project_briefs_supersede_tenant_match_update
1517
+ BEFORE UPDATE ON project_briefs
1518
+ WHEN NEW.superseded_by IS NOT NULL
1519
+ AND NEW.superseded_by IS NOT OLD.superseded_by
1520
+ BEGIN
1521
+ SELECT CASE
1522
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM project_briefs WHERE id = NEW.superseded_by)
1523
+ THEN RAISE(ABORT, 'project_briefs.superseded_by must reference a project_brief in the same tenant')
1524
+ END;
1525
+ END
1526
+ `);
1527
+ }
1528
+ },
1529
+ },
1530
+ {
1531
+ version: 36,
1532
+ up: (db) => {
1533
+ // E2 customer_note first-class object (the LAST E2 object)
1534
+ // (docs/plans/2026-06-01-e2-customer-note-object.md). A customer_note is a
1535
+ // discrete note recorded against an account/customer entity, evolving via the
1536
+ // v35 project_briefs supersede machinery (superseded_by self-FK + supersede
1537
+ // tenant-match trigger + version + change_summary). This table = the v35
1538
+ // project_briefs table with repo/summary replaced by `customer` (the
1539
+ // entity-scoping dimension; a free-form account/customer id - the entities
1540
+ // table is unbuilt E3.1, so a FK is deferred) PLUS `note` (the note body).
1541
+ // MANY notes per customer (each its own supersede chain), unlike the
1542
+ // one-summary-per-repo project_brief. All column names checked against SQLite
1543
+ // reserved words (skill-episode lesson, codebase-audit rule 10): customer/note/
1544
+ // version/status/etc. are non-reserved.
1545
+ if (!tableExists(db, 'customer_notes')) {
1546
+ db.exec(`
1547
+ CREATE TABLE customer_notes (
1548
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1549
+ memory_id TEXT,
1550
+ tenant_id TEXT NOT NULL,
1551
+ customer TEXT NOT NULL,
1552
+ note TEXT NOT NULL,
1553
+ version INTEGER NOT NULL DEFAULT 1,
1554
+ status TEXT NOT NULL DEFAULT 'active'
1555
+ CHECK (status IN ('active', 'superseded', 'closed')),
1556
+ superseded_by INTEGER,
1557
+ superseded_at TEXT,
1558
+ change_summary TEXT,
1559
+ closed_at TEXT,
1560
+ created_at TEXT NOT NULL,
1561
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1562
+ FOREIGN KEY (superseded_by) REFERENCES customer_notes(id) ON DELETE SET NULL
1563
+ )
1564
+ `);
1565
+ db.exec(`
1566
+ CREATE INDEX IF NOT EXISTS idx_customer_notes_tenant_status
1567
+ ON customer_notes(tenant_id, status)
1568
+ `);
1569
+ db.exec(`
1570
+ CREATE INDEX IF NOT EXISTS idx_customer_notes_memory
1571
+ ON customer_notes(memory_id) WHERE memory_id IS NOT NULL
1572
+ `);
1573
+ db.exec(`
1574
+ CREATE INDEX IF NOT EXISTS idx_customer_notes_customer
1575
+ ON customer_notes(tenant_id, customer, status)
1576
+ `);
1577
+ // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1578
+ // v35 project_briefs tenant-match triggers).
1579
+ db.exec(`
1580
+ CREATE TRIGGER IF NOT EXISTS trg_customer_notes_tenant_match_insert
1581
+ BEFORE INSERT ON customer_notes
1582
+ WHEN NEW.memory_id IS NOT NULL
1583
+ BEGIN
1584
+ SELECT CASE
1585
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1586
+ THEN RAISE(ABORT, 'customer_notes.tenant_id must match memories.tenant_id for the referenced memory')
1587
+ END;
1588
+ END
1589
+ `);
1590
+ db.exec(`
1591
+ CREATE TRIGGER IF NOT EXISTS trg_customer_notes_tenant_match_update
1592
+ BEFORE UPDATE ON customer_notes
1593
+ WHEN NEW.memory_id IS NOT NULL
1594
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1595
+ BEGIN
1596
+ SELECT CASE
1597
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1598
+ THEN RAISE(ABORT, 'customer_notes.tenant_id must match memories.tenant_id for the referenced memory')
1599
+ END;
1600
+ END
1601
+ `);
1602
+ // Cross-tenant safety vs the successor note (self-FK; verbatim mirror of the
1603
+ // v35 project_briefs supersede trigger).
1604
+ db.exec(`
1605
+ CREATE TRIGGER IF NOT EXISTS trg_customer_notes_supersede_tenant_match_update
1606
+ BEFORE UPDATE ON customer_notes
1607
+ WHEN NEW.superseded_by IS NOT NULL
1608
+ AND NEW.superseded_by IS NOT OLD.superseded_by
1609
+ BEGIN
1610
+ SELECT CASE
1611
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM customer_notes WHERE id = NEW.superseded_by)
1612
+ THEN RAISE(ABORT, 'customer_notes.superseded_by must reference a customer_note in the same tenant')
1613
+ END;
1614
+ END
1615
+ `);
1616
+ }
1617
+ },
1618
+ },
1619
+ {
1620
+ version: 37,
1621
+ up: (db) => {
1622
+ // E3.3 graph-on-consolidated guard (docs/plans/2026-06-01-e3-graph-guard.md).
1623
+ // The graph layer (entities + relations) sits ON TOP OF consolidated state and
1624
+ // must NEVER index the raw layer. The substrate: entities + relations +
1625
+ // graph_extraction_queue, each FK-ing to memories and guarded so they can only
1626
+ // reference CONSOLIDATED memories (kind IN ('distilled','superseded')), never
1627
+ // kind='raw'. New tables -> real CHECK constraints (unlike the ALTER'd memories,
1628
+ // whose kind CHECK lives in triggers). The kind/source MATCH (source_kind ==
1629
+ // the FK'd memory's actual kind) cannot be a CHECK (CHECK can't subquery), so it
1630
+ // is a BEFORE INSERT *and* BEFORE UPDATE trigger (the subquery-capable pattern
1631
+ // from the v30 decisions / predictions tenant-match triggers). Both INSERT and
1632
+ // UPDATE are guarded: an INSERT-only guard is bypassable via a raw SQL UPDATE
1633
+ // that moves a row onto a raw memory (plan-eng-critic 2026-06-01). All column
1634
+ // names checked vs SQL reserved words (rule 10): rel_type avoids REFERENCES.
1635
+ if (!tableExists(db, 'entities')) {
1636
+ db.exec(`
1637
+ CREATE TABLE entities (
1638
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1639
+ tenant_id TEXT NOT NULL,
1640
+ entity_type TEXT NOT NULL
1641
+ CHECK (entity_type IN ('person', 'project', 'customer', 'system', 'policy', 'decision')),
1642
+ name TEXT NOT NULL,
1643
+ memory_id TEXT NOT NULL,
1644
+ source_kind TEXT NOT NULL CHECK (source_kind IN ('distilled', 'superseded')),
1645
+ created_at TEXT NOT NULL,
1646
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
1647
+ )
1648
+ `);
1649
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_tenant ON entities(tenant_id)`);
1650
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_memory ON entities(memory_id)`);
1651
+ db.exec(`
1652
+ CREATE TABLE relations (
1653
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1654
+ tenant_id TEXT NOT NULL,
1655
+ from_entity_id INTEGER NOT NULL,
1656
+ to_entity_id INTEGER NOT NULL,
1657
+ rel_type TEXT NOT NULL
1658
+ CHECK (rel_type IN ('owns', 'supersedes', 'depends-on', 'blocked-by', 'references')),
1659
+ memory_id TEXT NOT NULL,
1660
+ source_kind TEXT NOT NULL CHECK (source_kind IN ('distilled', 'superseded')),
1661
+ created_at TEXT NOT NULL,
1662
+ FOREIGN KEY (from_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
1663
+ FOREIGN KEY (to_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
1664
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
1665
+ )
1666
+ `);
1667
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_tenant ON relations(tenant_id)`);
1668
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(from_entity_id)`);
1669
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(to_entity_id)`);
1670
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_memory ON relations(memory_id)`);
1671
+ db.exec(`
1672
+ CREATE TABLE graph_extraction_queue (
1673
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1674
+ tenant_id TEXT NOT NULL,
1675
+ memory_id TEXT NOT NULL,
1676
+ kind TEXT NOT NULL CHECK (kind IN ('distilled', 'superseded')),
1677
+ status TEXT NOT NULL DEFAULT 'pending'
1678
+ CHECK (status IN ('pending', 'processed', 'skipped')),
1679
+ enqueued_at TEXT NOT NULL,
1680
+ processed_at TEXT,
1681
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
1682
+ )
1683
+ `);
1684
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_graph_queue_status ON graph_extraction_queue(tenant_id, status)`);
1685
+ // entities guard: source_kind must equal the FK'd memory's actual kind (so a
1686
+ // raw memory or a lying source_kind both ABORT), and tenant must match. Both
1687
+ // INSERT and UPDATE (UPDATE fires when memory_id/source_kind/tenant_id change).
1688
+ db.exec(`
1689
+ CREATE TRIGGER IF NOT EXISTS trg_entities_consolidated_only_insert
1690
+ BEFORE INSERT ON entities
1691
+ BEGIN
1692
+ SELECT CASE
1693
+ WHEN NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1694
+ THEN RAISE(ABORT, 'entities.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1695
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1696
+ THEN RAISE(ABORT, 'entities.tenant_id must match memories.tenant_id for the referenced memory')
1697
+ END;
1698
+ END
1699
+ `);
1700
+ db.exec(`
1701
+ CREATE TRIGGER IF NOT EXISTS trg_entities_consolidated_only_update
1702
+ BEFORE UPDATE ON entities
1703
+ WHEN NEW.memory_id IS NOT OLD.memory_id
1704
+ OR NEW.source_kind IS NOT OLD.source_kind
1705
+ OR NEW.tenant_id IS NOT OLD.tenant_id
1706
+ BEGIN
1707
+ SELECT CASE
1708
+ WHEN NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1709
+ THEN RAISE(ABORT, 'entities.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1710
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1711
+ THEN RAISE(ABORT, 'entities.tenant_id must match memories.tenant_id for the referenced memory')
1712
+ END;
1713
+ END
1714
+ `);
1715
+ // relations guard: source_kind must equal the FK'd memory's kind; tenant must
1716
+ // match the memory AND both endpoint entities (no cross-tenant edges).
1717
+ db.exec(`
1718
+ CREATE TRIGGER IF NOT EXISTS trg_relations_consolidated_only_insert
1719
+ BEFORE INSERT ON relations
1720
+ BEGIN
1721
+ SELECT CASE
1722
+ WHEN NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1723
+ THEN RAISE(ABORT, 'relations.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1724
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1725
+ THEN RAISE(ABORT, 'relations.tenant_id must match memories.tenant_id for the referenced memory')
1726
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.from_entity_id)
1727
+ THEN RAISE(ABORT, 'relations.tenant_id must match the from_entity tenant (no cross-tenant edges)')
1728
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.to_entity_id)
1729
+ THEN RAISE(ABORT, 'relations.tenant_id must match the to_entity tenant (no cross-tenant edges)')
1730
+ END;
1731
+ END
1732
+ `);
1733
+ db.exec(`
1734
+ CREATE TRIGGER IF NOT EXISTS trg_relations_consolidated_only_update
1735
+ BEFORE UPDATE ON relations
1736
+ WHEN NEW.memory_id IS NOT OLD.memory_id
1737
+ OR NEW.source_kind IS NOT OLD.source_kind
1738
+ OR NEW.tenant_id IS NOT OLD.tenant_id
1739
+ OR NEW.from_entity_id IS NOT OLD.from_entity_id
1740
+ OR NEW.to_entity_id IS NOT OLD.to_entity_id
1741
+ BEGIN
1742
+ SELECT CASE
1743
+ WHEN NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1744
+ THEN RAISE(ABORT, 'relations.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1745
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1746
+ THEN RAISE(ABORT, 'relations.tenant_id must match memories.tenant_id for the referenced memory')
1747
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.from_entity_id)
1748
+ THEN RAISE(ABORT, 'relations.tenant_id must match the from_entity tenant (no cross-tenant edges)')
1749
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.to_entity_id)
1750
+ THEN RAISE(ABORT, 'relations.tenant_id must match the to_entity tenant (no cross-tenant edges)')
1751
+ END;
1752
+ END
1753
+ `);
1754
+ // graph_extraction_queue guard: kind must equal the FK'd memory's actual kind
1755
+ // (so a raw memory ABORTs), and tenant must match. INSERT and UPDATE.
1756
+ db.exec(`
1757
+ CREATE TRIGGER IF NOT EXISTS trg_graph_queue_consolidated_only_insert
1758
+ BEFORE INSERT ON graph_extraction_queue
1759
+ BEGIN
1760
+ SELECT CASE
1761
+ WHEN NEW.kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1762
+ THEN RAISE(ABORT, 'graph_extraction_queue.kind must equal the referenced memory kind; only consolidated memories are queued (no raw)')
1763
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1764
+ THEN RAISE(ABORT, 'graph_extraction_queue.tenant_id must match memories.tenant_id for the referenced memory')
1765
+ END;
1766
+ END
1767
+ `);
1768
+ db.exec(`
1769
+ CREATE TRIGGER IF NOT EXISTS trg_graph_queue_consolidated_only_update
1770
+ BEFORE UPDATE ON graph_extraction_queue
1771
+ WHEN NEW.memory_id IS NOT OLD.memory_id
1772
+ OR NEW.kind IS NOT OLD.kind
1773
+ OR NEW.tenant_id IS NOT OLD.tenant_id
1774
+ BEGIN
1775
+ SELECT CASE
1776
+ WHEN NEW.kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1777
+ THEN RAISE(ABORT, 'graph_extraction_queue.kind must equal the referenced memory kind; only consolidated memories are queued (no raw)')
1778
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1779
+ THEN RAISE(ABORT, 'graph_extraction_queue.tenant_id must match memories.tenant_id for the referenced memory')
1780
+ END;
1781
+ END
1782
+ `);
1783
+ // Reverse guard (codex-review-critic 2026-06-01, P1): the graph-table triggers
1784
+ // only fire on writes to the GRAPH tables. They do NOT fire when an
1785
+ // already-indexed memory is later mutated. So 'UPDATE memories SET kind=raw'
1786
+ // (or a tenant change) on a memory the graph references would silently leave
1787
+ // entity/relation/queue rows pointing at a raw / cross-tenant memory while
1788
+ // their source_kind stays 'distilled' - bypassing the central 'graph never
1789
+ // indexes raw' invariant after insertion. This trigger closes that direction:
1790
+ // a memory cannot be reclassified to raw, nor moved cross-tenant, WHILE the
1791
+ // graph references it (rebuild/remove the graph rows first). Cheap: the EXISTS
1792
+ // checks are only evaluated when kind actually becomes raw or tenant changes.
1793
+ db.exec(`
1794
+ CREATE TRIGGER IF NOT EXISTS trg_memories_graph_referenced_guard
1795
+ BEFORE UPDATE ON memories
1796
+ WHEN (NEW.kind IS NOT OLD.kind OR NEW.tenant_id IS NOT OLD.tenant_id)
1797
+ AND (
1798
+ EXISTS (SELECT 1 FROM entities WHERE memory_id = OLD.id)
1799
+ OR EXISTS (SELECT 1 FROM relations WHERE memory_id = OLD.id)
1800
+ OR EXISTS (SELECT 1 FROM graph_extraction_queue WHERE memory_id = OLD.id)
1801
+ )
1802
+ BEGIN
1803
+ SELECT RAISE(ABORT, 'cannot change the kind or tenant of a memory while the graph references it (E3.3 graph-on-consolidated guard); a graph-referenced memory is immutable in kind/tenant - rebuild/remove the graph rows first, or rebuild them after supersession');
1804
+ END
1805
+ `);
1806
+ // Reverse guard #2 (codex-review-critic 2026-06-01 retry, P2): an entity that is
1807
+ // a relation endpoint cannot be moved cross-tenant. The entity UPDATE trigger
1808
+ // validates the entity against its source memory, but an existing relation
1809
+ // pointing at the entity is NOT re-validated, so a raw 'UPDATE entities SET
1810
+ // tenant_id=?, memory_id=?' to another tenant would leave a tenant-A relation
1811
+ // pointing at a tenant-B entity. Block the tenant move while the entity is
1812
+ // referenced by any relation (rebuild the relations first).
1813
+ db.exec(`
1814
+ CREATE TRIGGER IF NOT EXISTS trg_entities_no_tenant_move_when_referenced
1815
+ BEFORE UPDATE ON entities
1816
+ WHEN NEW.tenant_id IS NOT OLD.tenant_id
1817
+ AND EXISTS (SELECT 1 FROM relations WHERE from_entity_id = OLD.id OR to_entity_id = OLD.id)
1818
+ BEGIN
1819
+ SELECT RAISE(ABORT, 'cannot move an entity cross-tenant while a relation references it as an endpoint (E3.3 graph-on-consolidated guard); rebuild/remove the relations first');
1820
+ END
1821
+ `);
1822
+ }
1823
+ },
1824
+ },
1095
1825
  ];
1096
1826
  function tableHasColumn(db, tableName, columnName) {
1097
1827
  if (!/^[a-z_]+$/i.test(tableName))