hippo-memory 1.14.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 (106) 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 +1594 -229
  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 +1286 -472
  14. package/dist/db.js.map +1 -1
  15. package/dist/decisions.d.ts +91 -0
  16. package/dist/decisions.d.ts.map +1 -0
  17. package/dist/decisions.js +278 -0
  18. package/dist/decisions.js.map +1 -0
  19. package/dist/graph-extract.d.ts +39 -0
  20. package/dist/graph-extract.d.ts.map +1 -0
  21. package/dist/graph-extract.js +141 -0
  22. package/dist/graph-extract.js.map +1 -0
  23. package/dist/graph-recall.d.ts +41 -0
  24. package/dist/graph-recall.d.ts.map +1 -0
  25. package/dist/graph-recall.js +246 -0
  26. package/dist/graph-recall.js.map +1 -0
  27. package/dist/graph.d.ts +137 -0
  28. package/dist/graph.d.ts.map +1 -0
  29. package/dist/graph.js +433 -0
  30. package/dist/graph.js.map +1 -0
  31. package/dist/incidents.d.ts +100 -0
  32. package/dist/incidents.d.ts.map +1 -0
  33. package/dist/incidents.js +322 -0
  34. package/dist/incidents.js.map +1 -0
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/memory.d.ts +6 -0
  40. package/dist/memory.d.ts.map +1 -1
  41. package/dist/memory.js +6 -0
  42. package/dist/memory.js.map +1 -1
  43. package/dist/policies.d.ts +149 -0
  44. package/dist/policies.d.ts.map +1 -0
  45. package/dist/policies.js +380 -0
  46. package/dist/policies.js.map +1 -0
  47. package/dist/processes.d.ts +104 -0
  48. package/dist/processes.d.ts.map +1 -0
  49. package/dist/processes.js +330 -0
  50. package/dist/processes.js.map +1 -0
  51. package/dist/project-briefs.d.ts +126 -0
  52. package/dist/project-briefs.d.ts.map +1 -0
  53. package/dist/project-briefs.js +453 -0
  54. package/dist/project-briefs.js.map +1 -0
  55. package/dist/search.d.ts +7 -0
  56. package/dist/search.d.ts.map +1 -1
  57. package/dist/search.js.map +1 -1
  58. package/dist/server.d.ts.map +1 -1
  59. package/dist/server.js +1181 -8
  60. package/dist/server.js.map +1 -1
  61. package/dist/skills.d.ts +98 -0
  62. package/dist/skills.d.ts.map +1 -0
  63. package/dist/skills.js +339 -0
  64. package/dist/skills.js.map +1 -0
  65. package/dist/src/audit.js.map +1 -1
  66. package/dist/src/cli.js +1594 -229
  67. package/dist/src/cli.js.map +1 -1
  68. package/dist/src/customer-notes.js +296 -0
  69. package/dist/src/customer-notes.js.map +1 -0
  70. package/dist/src/db.js +1286 -472
  71. package/dist/src/db.js.map +1 -1
  72. package/dist/src/decisions.js +278 -0
  73. package/dist/src/decisions.js.map +1 -0
  74. package/dist/src/graph-extract.js +141 -0
  75. package/dist/src/graph-extract.js.map +1 -0
  76. package/dist/src/graph-recall.js +246 -0
  77. package/dist/src/graph-recall.js.map +1 -0
  78. package/dist/src/graph.js +433 -0
  79. package/dist/src/graph.js.map +1 -0
  80. package/dist/src/incidents.js +322 -0
  81. package/dist/src/incidents.js.map +1 -0
  82. package/dist/src/index.js +1 -0
  83. package/dist/src/index.js.map +1 -1
  84. package/dist/src/memory.js +6 -0
  85. package/dist/src/memory.js.map +1 -1
  86. package/dist/src/policies.js +380 -0
  87. package/dist/src/policies.js.map +1 -0
  88. package/dist/src/processes.js +330 -0
  89. package/dist/src/processes.js.map +1 -0
  90. package/dist/src/project-briefs.js +453 -0
  91. package/dist/src/project-briefs.js.map +1 -0
  92. package/dist/src/search.js.map +1 -1
  93. package/dist/src/server.js +1181 -8
  94. package/dist/src/server.js.map +1 -1
  95. package/dist/src/skills.js +339 -0
  96. package/dist/src/skills.js.map +1 -0
  97. package/dist/src/version.js +1 -1
  98. package/dist/src/version.js.map +1 -1
  99. package/dist/version.d.ts +1 -1
  100. package/dist/version.d.ts.map +1 -1
  101. package/dist/version.js +1 -1
  102. package/dist/version.js.map +1 -1
  103. package/extensions/openclaw-plugin/openclaw.plugin.json +46 -46
  104. package/extensions/openclaw-plugin/package.json +14 -14
  105. package/openclaw.plugin.json +45 -45
  106. package/package.json +75 -75
package/dist/db.js CHANGED
@@ -6,83 +6,83 @@ 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 = 29;
9
+ const CURRENT_SCHEMA_VERSION = 37;
10
10
  const MIGRATIONS = [
11
11
  {
12
12
  version: 1,
13
13
  up: (db) => {
14
- db.exec(`
15
- CREATE TABLE IF NOT EXISTS memories (
16
- id TEXT PRIMARY KEY,
17
- created TEXT NOT NULL,
18
- last_retrieved TEXT NOT NULL,
19
- retrieval_count INTEGER NOT NULL,
20
- strength REAL NOT NULL,
21
- half_life_days REAL NOT NULL,
22
- layer TEXT NOT NULL,
23
- tags_json TEXT NOT NULL,
24
- emotional_valence TEXT NOT NULL,
25
- schema_fit REAL NOT NULL,
26
- source TEXT NOT NULL,
27
- outcome_score REAL,
28
- conflicts_with_json TEXT NOT NULL,
29
- pinned INTEGER NOT NULL,
30
- confidence TEXT NOT NULL,
31
- content TEXT NOT NULL,
32
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
33
- );
34
-
35
- CREATE INDEX IF NOT EXISTS idx_memories_layer_created ON memories(layer, created);
36
- CREATE INDEX IF NOT EXISTS idx_memories_last_retrieved ON memories(last_retrieved);
37
-
38
- CREATE TABLE IF NOT EXISTS consolidation_runs (
39
- id INTEGER PRIMARY KEY AUTOINCREMENT,
40
- timestamp TEXT NOT NULL,
41
- decayed INTEGER NOT NULL,
42
- merged INTEGER NOT NULL,
43
- removed INTEGER NOT NULL
44
- );
14
+ db.exec(`
15
+ CREATE TABLE IF NOT EXISTS memories (
16
+ id TEXT PRIMARY KEY,
17
+ created TEXT NOT NULL,
18
+ last_retrieved TEXT NOT NULL,
19
+ retrieval_count INTEGER NOT NULL,
20
+ strength REAL NOT NULL,
21
+ half_life_days REAL NOT NULL,
22
+ layer TEXT NOT NULL,
23
+ tags_json TEXT NOT NULL,
24
+ emotional_valence TEXT NOT NULL,
25
+ schema_fit REAL NOT NULL,
26
+ source TEXT NOT NULL,
27
+ outcome_score REAL,
28
+ conflicts_with_json TEXT NOT NULL,
29
+ pinned INTEGER NOT NULL,
30
+ confidence TEXT NOT NULL,
31
+ content TEXT NOT NULL,
32
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_memories_layer_created ON memories(layer, created);
36
+ CREATE INDEX IF NOT EXISTS idx_memories_last_retrieved ON memories(last_retrieved);
37
+
38
+ CREATE TABLE IF NOT EXISTS consolidation_runs (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ timestamp TEXT NOT NULL,
41
+ decayed INTEGER NOT NULL,
42
+ merged INTEGER NOT NULL,
43
+ removed INTEGER NOT NULL
44
+ );
45
45
  `);
46
46
  },
47
47
  },
48
48
  {
49
49
  version: 2,
50
50
  up: (db) => {
51
- db.exec(`
52
- CREATE TABLE IF NOT EXISTS task_snapshots (
53
- id INTEGER PRIMARY KEY AUTOINCREMENT,
54
- task TEXT NOT NULL,
55
- summary TEXT NOT NULL,
56
- next_step TEXT NOT NULL,
57
- status TEXT NOT NULL,
58
- source TEXT NOT NULL,
59
- created_at TEXT NOT NULL,
60
- updated_at TEXT NOT NULL
61
- );
62
-
63
- CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated
64
- ON task_snapshots(status, updated_at DESC, id DESC);
51
+ db.exec(`
52
+ CREATE TABLE IF NOT EXISTS task_snapshots (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ task TEXT NOT NULL,
55
+ summary TEXT NOT NULL,
56
+ next_step TEXT NOT NULL,
57
+ status TEXT NOT NULL,
58
+ source TEXT NOT NULL,
59
+ created_at TEXT NOT NULL,
60
+ updated_at TEXT NOT NULL
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated
64
+ ON task_snapshots(status, updated_at DESC, id DESC);
65
65
  `);
66
66
  },
67
67
  },
68
68
  {
69
69
  version: 3,
70
70
  up: (db) => {
71
- db.exec(`
72
- CREATE TABLE IF NOT EXISTS memory_conflicts (
73
- id INTEGER PRIMARY KEY AUTOINCREMENT,
74
- memory_a_id TEXT NOT NULL,
75
- memory_b_id TEXT NOT NULL,
76
- reason TEXT NOT NULL,
77
- score REAL NOT NULL DEFAULT 0,
78
- status TEXT NOT NULL,
79
- detected_at TEXT NOT NULL,
80
- updated_at TEXT NOT NULL,
81
- UNIQUE(memory_a_id, memory_b_id)
82
- );
83
-
84
- CREATE INDEX IF NOT EXISTS idx_memory_conflicts_status_updated
85
- ON memory_conflicts(status, updated_at DESC, id DESC);
71
+ db.exec(`
72
+ CREATE TABLE IF NOT EXISTS memory_conflicts (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ memory_a_id TEXT NOT NULL,
75
+ memory_b_id TEXT NOT NULL,
76
+ reason TEXT NOT NULL,
77
+ score REAL NOT NULL DEFAULT 0,
78
+ status TEXT NOT NULL,
79
+ detected_at TEXT NOT NULL,
80
+ updated_at TEXT NOT NULL,
81
+ UNIQUE(memory_a_id, memory_b_id)
82
+ );
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_memory_conflicts_status_updated
85
+ ON memory_conflicts(status, updated_at DESC, id DESC);
86
86
  `);
87
87
  },
88
88
  },
@@ -92,67 +92,67 @@ const MIGRATIONS = [
92
92
  if (!tableHasColumn(db, 'task_snapshots', 'session_id')) {
93
93
  db.exec(`ALTER TABLE task_snapshots ADD COLUMN session_id TEXT`);
94
94
  }
95
- db.exec(`
96
- CREATE TABLE IF NOT EXISTS session_events (
97
- id INTEGER PRIMARY KEY AUTOINCREMENT,
98
- session_id TEXT NOT NULL,
99
- task TEXT,
100
- event_type TEXT NOT NULL,
101
- content TEXT NOT NULL,
102
- source TEXT NOT NULL,
103
- metadata_json TEXT NOT NULL,
104
- created_at TEXT NOT NULL
105
- );
106
-
107
- CREATE INDEX IF NOT EXISTS idx_session_events_session_created
108
- ON session_events(session_id, created_at DESC, id DESC);
109
-
110
- CREATE INDEX IF NOT EXISTS idx_session_events_task_created
111
- ON session_events(task, created_at DESC, id DESC);
95
+ db.exec(`
96
+ CREATE TABLE IF NOT EXISTS session_events (
97
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
98
+ session_id TEXT NOT NULL,
99
+ task TEXT,
100
+ event_type TEXT NOT NULL,
101
+ content TEXT NOT NULL,
102
+ source TEXT NOT NULL,
103
+ metadata_json TEXT NOT NULL,
104
+ created_at TEXT NOT NULL
105
+ );
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_session_events_session_created
108
+ ON session_events(session_id, created_at DESC, id DESC);
109
+
110
+ CREATE INDEX IF NOT EXISTS idx_session_events_task_created
111
+ ON session_events(task, created_at DESC, id DESC);
112
112
  `);
113
113
  },
114
114
  },
115
115
  {
116
116
  version: 5,
117
117
  up: (db) => {
118
- db.exec(`
119
- CREATE TABLE IF NOT EXISTS session_handoffs (
120
- id INTEGER PRIMARY KEY AUTOINCREMENT,
121
- session_id TEXT NOT NULL,
122
- repo_root TEXT,
123
- task_id TEXT,
124
- summary TEXT NOT NULL,
125
- next_action TEXT,
126
- artifacts_json TEXT NOT NULL DEFAULT '[]',
127
- created_at TEXT NOT NULL
128
- );
129
-
130
- CREATE INDEX IF NOT EXISTS idx_session_handoffs_session
131
- ON session_handoffs(session_id, created_at DESC);
118
+ db.exec(`
119
+ CREATE TABLE IF NOT EXISTS session_handoffs (
120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ session_id TEXT NOT NULL,
122
+ repo_root TEXT,
123
+ task_id TEXT,
124
+ summary TEXT NOT NULL,
125
+ next_action TEXT,
126
+ artifacts_json TEXT NOT NULL DEFAULT '[]',
127
+ created_at TEXT NOT NULL
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_session_handoffs_session
131
+ ON session_handoffs(session_id, created_at DESC);
132
132
  `);
133
133
  },
134
134
  },
135
135
  {
136
136
  version: 6,
137
137
  up: (db) => {
138
- db.exec(`
139
- CREATE TABLE IF NOT EXISTS working_memory (
140
- id INTEGER PRIMARY KEY AUTOINCREMENT,
141
- scope TEXT NOT NULL,
142
- session_id TEXT,
143
- task_id TEXT,
144
- importance REAL NOT NULL DEFAULT 0,
145
- content TEXT NOT NULL,
146
- metadata_json TEXT NOT NULL DEFAULT '{}',
147
- created_at TEXT NOT NULL,
148
- updated_at TEXT NOT NULL
149
- );
150
-
151
- CREATE INDEX IF NOT EXISTS idx_working_memory_scope
152
- ON working_memory(scope, importance DESC, created_at DESC);
153
-
154
- CREATE INDEX IF NOT EXISTS idx_working_memory_session
155
- ON working_memory(session_id, created_at DESC);
138
+ db.exec(`
139
+ CREATE TABLE IF NOT EXISTS working_memory (
140
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
141
+ scope TEXT NOT NULL,
142
+ session_id TEXT,
143
+ task_id TEXT,
144
+ importance REAL NOT NULL DEFAULT 0,
145
+ content TEXT NOT NULL,
146
+ metadata_json TEXT NOT NULL DEFAULT '{}',
147
+ created_at TEXT NOT NULL,
148
+ updated_at TEXT NOT NULL
149
+ );
150
+
151
+ CREATE INDEX IF NOT EXISTS idx_working_memory_scope
152
+ ON working_memory(scope, importance DESC, created_at DESC);
153
+
154
+ CREATE INDEX IF NOT EXISTS idx_working_memory_session
155
+ ON working_memory(session_id, created_at DESC);
156
156
  `);
157
157
  },
158
158
  },
@@ -193,9 +193,9 @@ const MIGRATIONS = [
193
193
  if (!tableHasColumn(db, 'memories', 'source_session_id')) {
194
194
  db.exec(`ALTER TABLE memories ADD COLUMN source_session_id TEXT`);
195
195
  }
196
- db.exec(`
197
- CREATE INDEX IF NOT EXISTS idx_memories_source_session_id
198
- ON memories(source_session_id) WHERE source_session_id IS NOT NULL
196
+ db.exec(`
197
+ CREATE INDEX IF NOT EXISTS idx_memories_source_session_id
198
+ ON memories(source_session_id) WHERE source_session_id IS NOT NULL
199
199
  `);
200
200
  },
201
201
  },
@@ -257,44 +257,44 @@ const MIGRATIONS = [
257
257
  db.exec(`UPDATE memories SET kind = 'superseded' WHERE kind IS NULL AND superseded_by IS NOT NULL`);
258
258
  db.exec(`UPDATE memories SET kind = 'distilled' WHERE kind IS NULL`);
259
259
  // raw_archive: legitimate path for kind='raw' removal (used by archiveRawMemory).
260
- db.exec(`
261
- CREATE TABLE IF NOT EXISTS raw_archive (
262
- id INTEGER PRIMARY KEY AUTOINCREMENT,
263
- memory_id TEXT NOT NULL,
264
- archived_at TEXT NOT NULL,
265
- reason TEXT NOT NULL,
266
- archived_by TEXT,
267
- payload_json TEXT NOT NULL
268
- )
260
+ db.exec(`
261
+ CREATE TABLE IF NOT EXISTS raw_archive (
262
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
263
+ memory_id TEXT NOT NULL,
264
+ archived_at TEXT NOT NULL,
265
+ reason TEXT NOT NULL,
266
+ archived_by TEXT,
267
+ payload_json TEXT NOT NULL
268
+ )
269
269
  `);
270
270
  db.exec(`CREATE INDEX IF NOT EXISTS idx_raw_archive_memory_id ON raw_archive(memory_id)`);
271
271
  // Append-only invariant: kind='raw' rows cannot be deleted directly.
272
272
  // Use raw_archive flow: archive-then-update-then-delete (see src/raw-archive.ts).
273
- db.exec(`
274
- CREATE TRIGGER IF NOT EXISTS trg_memories_raw_append_only
275
- BEFORE DELETE ON memories
276
- WHEN OLD.kind = 'raw'
277
- BEGIN
278
- SELECT RAISE(ABORT, 'raw is append-only');
279
- END
273
+ db.exec(`
274
+ CREATE TRIGGER IF NOT EXISTS trg_memories_raw_append_only
275
+ BEFORE DELETE ON memories
276
+ WHEN OLD.kind = 'raw'
277
+ BEGIN
278
+ SELECT RAISE(ABORT, 'raw is append-only');
279
+ END
280
280
  `);
281
281
  // CHECK substitute: ALTER TABLE cannot add CHECK, so enforce kind allowed-set
282
282
  // via INSERT/UPDATE triggers.
283
- db.exec(`
284
- CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_insert
285
- BEFORE INSERT ON memories
286
- WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
287
- BEGIN
288
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
289
- END
283
+ db.exec(`
284
+ CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_insert
285
+ BEFORE INSERT ON memories
286
+ WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
287
+ BEGIN
288
+ SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
289
+ END
290
290
  `);
291
- db.exec(`
292
- CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_update
293
- BEFORE UPDATE ON memories
294
- WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
295
- BEGIN
296
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
297
- END
291
+ db.exec(`
292
+ CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_update
293
+ BEFORE UPDATE ON memories
294
+ WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
295
+ BEGIN
296
+ SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
297
+ END
298
298
  `);
299
299
  },
300
300
  },
@@ -312,30 +312,30 @@ const MIGRATIONS = [
312
312
  // allowed (different timestamps).
313
313
  db.exec(`DROP TRIGGER IF EXISTS trg_memories_kind_check_insert`);
314
314
  db.exec(`DROP TRIGGER IF EXISTS trg_memories_kind_check_update`);
315
- db.exec(`
316
- CREATE TRIGGER trg_memories_kind_check_insert
317
- BEFORE INSERT ON memories
318
- WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
319
- BEGIN
320
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
321
- END
315
+ db.exec(`
316
+ CREATE TRIGGER trg_memories_kind_check_insert
317
+ BEFORE INSERT ON memories
318
+ WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
319
+ BEGIN
320
+ SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
321
+ END
322
322
  `);
323
- db.exec(`
324
- CREATE TRIGGER trg_memories_kind_check_update
325
- BEFORE UPDATE ON memories
326
- WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
327
- BEGIN
328
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
329
- END
323
+ db.exec(`
324
+ CREATE TRIGGER trg_memories_kind_check_update
325
+ BEFORE UPDATE ON memories
326
+ WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
327
+ BEGIN
328
+ SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
329
+ END
330
330
  `);
331
331
  // Defensive: any rows that somehow have NULL kind get fixed (shouldn't exist post-v14
332
332
  // backfill, but cheap insurance).
333
333
  db.exec(`UPDATE memories SET kind = 'distilled' WHERE kind IS NULL`);
334
334
  // raw_archive uniqueness. SQLite cannot ADD CONSTRAINT, but a partial unique index
335
335
  // on (memory_id, archived_at) is equivalent for INSERT-time enforcement.
336
- db.exec(`
337
- CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_archive_id_at
338
- ON raw_archive(memory_id, archived_at)
336
+ db.exec(`
337
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_archive_id_at
338
+ ON raw_archive(memory_id, archived_at)
339
339
  `);
340
340
  },
341
341
  },
@@ -369,31 +369,31 @@ const MIGRATIONS = [
369
369
  // A5 stub auth: api_keys (scrypt-hashed; plaintext returned to caller exactly once)
370
370
  // and audit_log (append-only mutation trail). Both carry tenant_id from day 1 so
371
371
  // future multi-tenant enforcement is a config flip, not a re-migration.
372
- db.exec(`
373
- CREATE TABLE IF NOT EXISTS api_keys (
374
- id INTEGER PRIMARY KEY AUTOINCREMENT,
375
- key_id TEXT UNIQUE NOT NULL,
376
- key_hash TEXT NOT NULL,
377
- tenant_id TEXT NOT NULL DEFAULT 'default',
378
- label TEXT,
379
- created_at TEXT NOT NULL,
380
- revoked_at TEXT
381
- )
372
+ db.exec(`
373
+ CREATE TABLE IF NOT EXISTS api_keys (
374
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
375
+ key_id TEXT UNIQUE NOT NULL,
376
+ key_hash TEXT NOT NULL,
377
+ tenant_id TEXT NOT NULL DEFAULT 'default',
378
+ label TEXT,
379
+ created_at TEXT NOT NULL,
380
+ revoked_at TEXT
381
+ )
382
382
  `);
383
- db.exec(`
384
- CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
385
- ON api_keys(tenant_id) WHERE revoked_at IS NULL
383
+ db.exec(`
384
+ CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
385
+ ON api_keys(tenant_id) WHERE revoked_at IS NULL
386
386
  `);
387
- db.exec(`
388
- CREATE TABLE IF NOT EXISTS audit_log (
389
- id INTEGER PRIMARY KEY AUTOINCREMENT,
390
- ts TEXT NOT NULL,
391
- tenant_id TEXT NOT NULL DEFAULT 'default',
392
- actor TEXT NOT NULL,
393
- op TEXT NOT NULL,
394
- target_id TEXT,
395
- metadata_json TEXT NOT NULL DEFAULT '{}'
396
- )
387
+ db.exec(`
388
+ CREATE TABLE IF NOT EXISTS audit_log (
389
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
390
+ ts TEXT NOT NULL,
391
+ tenant_id TEXT NOT NULL DEFAULT 'default',
392
+ actor TEXT NOT NULL,
393
+ op TEXT NOT NULL,
394
+ target_id TEXT,
395
+ metadata_json TEXT NOT NULL DEFAULT '{}'
396
+ )
397
397
  `);
398
398
  db.exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_ts ON audit_log(tenant_id, ts DESC)`);
399
399
  },
@@ -403,102 +403,102 @@ const MIGRATIONS = [
403
403
  up: (db) => {
404
404
  // E1.3 Slack ingestion: idempotency log, per-channel backfill cursors, DLQ.
405
405
  // See docs/plans/2026-04-29-e1.3-slack-ingestion.md.
406
- db.exec(`
407
- CREATE TABLE IF NOT EXISTS slack_event_log (
408
- event_id TEXT PRIMARY KEY,
409
- ingested_at TEXT NOT NULL,
410
- memory_id TEXT
411
- )
406
+ db.exec(`
407
+ CREATE TABLE IF NOT EXISTS slack_event_log (
408
+ event_id TEXT PRIMARY KEY,
409
+ ingested_at TEXT NOT NULL,
410
+ memory_id TEXT
411
+ )
412
412
  `);
413
413
  db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_event_log_memory ON slack_event_log(memory_id) WHERE memory_id IS NOT NULL`);
414
- db.exec(`
415
- CREATE TABLE IF NOT EXISTS slack_cursors (
416
- tenant_id TEXT NOT NULL,
417
- channel_id TEXT NOT NULL,
418
- latest_ts TEXT NOT NULL,
419
- updated_at TEXT NOT NULL,
420
- PRIMARY KEY (tenant_id, channel_id)
421
- )
414
+ db.exec(`
415
+ CREATE TABLE IF NOT EXISTS slack_cursors (
416
+ tenant_id TEXT NOT NULL,
417
+ channel_id TEXT NOT NULL,
418
+ latest_ts TEXT NOT NULL,
419
+ updated_at TEXT NOT NULL,
420
+ PRIMARY KEY (tenant_id, channel_id)
421
+ )
422
422
  `);
423
- db.exec(`
424
- CREATE TABLE IF NOT EXISTS slack_dlq (
425
- id INTEGER PRIMARY KEY AUTOINCREMENT,
426
- tenant_id TEXT NOT NULL,
427
- raw_payload TEXT NOT NULL,
428
- error TEXT NOT NULL,
429
- received_at TEXT NOT NULL,
430
- retried_at TEXT
431
- )
423
+ db.exec(`
424
+ CREATE TABLE IF NOT EXISTS slack_dlq (
425
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
426
+ tenant_id TEXT NOT NULL,
427
+ raw_payload TEXT NOT NULL,
428
+ error TEXT NOT NULL,
429
+ received_at TEXT NOT NULL,
430
+ retried_at TEXT
431
+ )
432
432
  `);
433
433
  db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_dlq_tenant_received ON slack_dlq(tenant_id, received_at)`);
434
434
  // Multi-tenant routing seam (review patch #6). Empty by default — single-
435
435
  // tenant deployments resolve via HIPPO_TENANT fallback. Multi-workspace
436
436
  // deployments populate this table to map team_id → tenant_id.
437
- db.exec(`
438
- CREATE TABLE IF NOT EXISTS slack_workspaces (
439
- team_id TEXT PRIMARY KEY,
440
- tenant_id TEXT NOT NULL,
441
- added_at TEXT NOT NULL
442
- )
437
+ db.exec(`
438
+ CREATE TABLE IF NOT EXISTS slack_workspaces (
439
+ team_id TEXT PRIMARY KEY,
440
+ tenant_id TEXT NOT NULL,
441
+ added_at TEXT NOT NULL
442
+ )
443
443
  `);
444
444
  },
445
445
  },
446
446
  {
447
447
  version: 18,
448
448
  up: (db) => {
449
- db.exec(`
450
- CREATE TABLE IF NOT EXISTS goal_stack (
451
- id TEXT PRIMARY KEY,
452
- session_id TEXT NOT NULL,
453
- tenant_id TEXT NOT NULL DEFAULT 'default',
454
- goal_name TEXT NOT NULL,
455
- level INTEGER NOT NULL DEFAULT 0
456
- CHECK (level BETWEEN 0 AND 2),
457
- parent_goal_id TEXT REFERENCES goal_stack(id) ON DELETE SET NULL,
458
- status TEXT NOT NULL CHECK (status IN ('active','suspended','completed')),
459
- success_condition TEXT,
460
- retrieval_policy_id TEXT,
461
- created_at TEXT NOT NULL,
462
- completed_at TEXT,
463
- outcome_score REAL
464
- CHECK (outcome_score IS NULL OR (outcome_score >= 0 AND outcome_score <= 1))
465
- );
466
-
467
- CREATE INDEX IF NOT EXISTS idx_goal_stack_tenant_session_status
468
- ON goal_stack(tenant_id, session_id, status, created_at);
469
-
470
- CREATE TABLE IF NOT EXISTS retrieval_policy (
471
- id TEXT PRIMARY KEY,
472
- goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
473
- policy_type TEXT NOT NULL CHECK (policy_type IN
474
- ('schema-fit-biased','error-prioritized','recency-first','hybrid')),
475
- weight_schema_fit REAL NOT NULL DEFAULT 1.0
476
- CHECK (weight_schema_fit >= 0 AND weight_schema_fit <= 100),
477
- weight_recency REAL NOT NULL DEFAULT 1.0
478
- CHECK (weight_recency >= 0 AND weight_recency <= 100),
479
- weight_outcome REAL NOT NULL DEFAULT 1.0
480
- CHECK (weight_outcome >= 0 AND weight_outcome <= 100),
481
- error_priority REAL NOT NULL DEFAULT 1.0
482
- CHECK (error_priority >= 0 AND error_priority <= 100)
483
- );
484
-
485
- CREATE INDEX IF NOT EXISTS idx_retrieval_policy_goal
486
- ON retrieval_policy(goal_id);
487
-
488
- CREATE TABLE IF NOT EXISTS goal_recall_log (
489
- id INTEGER PRIMARY KEY AUTOINCREMENT,
490
- goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
491
- memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
492
- tenant_id TEXT NOT NULL DEFAULT 'default',
493
- session_id TEXT NOT NULL,
494
- recalled_at TEXT NOT NULL,
495
- score REAL NOT NULL
496
- );
497
-
498
- CREATE INDEX IF NOT EXISTS idx_goal_recall_log_goal
499
- ON goal_recall_log(goal_id);
500
- CREATE UNIQUE INDEX IF NOT EXISTS uniq_goal_recall_log_memory_goal
501
- ON goal_recall_log(memory_id, goal_id);
449
+ db.exec(`
450
+ CREATE TABLE IF NOT EXISTS goal_stack (
451
+ id TEXT PRIMARY KEY,
452
+ session_id TEXT NOT NULL,
453
+ tenant_id TEXT NOT NULL DEFAULT 'default',
454
+ goal_name TEXT NOT NULL,
455
+ level INTEGER NOT NULL DEFAULT 0
456
+ CHECK (level BETWEEN 0 AND 2),
457
+ parent_goal_id TEXT REFERENCES goal_stack(id) ON DELETE SET NULL,
458
+ status TEXT NOT NULL CHECK (status IN ('active','suspended','completed')),
459
+ success_condition TEXT,
460
+ retrieval_policy_id TEXT,
461
+ created_at TEXT NOT NULL,
462
+ completed_at TEXT,
463
+ outcome_score REAL
464
+ CHECK (outcome_score IS NULL OR (outcome_score >= 0 AND outcome_score <= 1))
465
+ );
466
+
467
+ CREATE INDEX IF NOT EXISTS idx_goal_stack_tenant_session_status
468
+ ON goal_stack(tenant_id, session_id, status, created_at);
469
+
470
+ CREATE TABLE IF NOT EXISTS retrieval_policy (
471
+ id TEXT PRIMARY KEY,
472
+ goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
473
+ policy_type TEXT NOT NULL CHECK (policy_type IN
474
+ ('schema-fit-biased','error-prioritized','recency-first','hybrid')),
475
+ weight_schema_fit REAL NOT NULL DEFAULT 1.0
476
+ CHECK (weight_schema_fit >= 0 AND weight_schema_fit <= 100),
477
+ weight_recency REAL NOT NULL DEFAULT 1.0
478
+ CHECK (weight_recency >= 0 AND weight_recency <= 100),
479
+ weight_outcome REAL NOT NULL DEFAULT 1.0
480
+ CHECK (weight_outcome >= 0 AND weight_outcome <= 100),
481
+ error_priority REAL NOT NULL DEFAULT 1.0
482
+ CHECK (error_priority >= 0 AND error_priority <= 100)
483
+ );
484
+
485
+ CREATE INDEX IF NOT EXISTS idx_retrieval_policy_goal
486
+ ON retrieval_policy(goal_id);
487
+
488
+ CREATE TABLE IF NOT EXISTS goal_recall_log (
489
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
490
+ goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
491
+ memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
492
+ tenant_id TEXT NOT NULL DEFAULT 'default',
493
+ session_id TEXT NOT NULL,
494
+ recalled_at TEXT NOT NULL,
495
+ score REAL NOT NULL
496
+ );
497
+
498
+ CREATE INDEX IF NOT EXISTS idx_goal_recall_log_goal
499
+ ON goal_recall_log(goal_id);
500
+ CREATE UNIQUE INDEX IF NOT EXISTS uniq_goal_recall_log_memory_goal
501
+ ON goal_recall_log(memory_id, goal_id);
502
502
  `);
503
503
  },
504
504
  },
@@ -595,27 +595,27 @@ const MIGRATIONS = [
595
595
  // EXISTS bodies before ALTERing. A silent skip would otherwise stamp
596
596
  // schema_version=22 on a DB missing the underlying tables, leaving
597
597
  // them permanently absent.
598
- db.exec(`
599
- CREATE TABLE IF NOT EXISTS session_events (
600
- id INTEGER PRIMARY KEY AUTOINCREMENT,
601
- session_id TEXT NOT NULL,
602
- task TEXT,
603
- event_type TEXT NOT NULL,
604
- content TEXT NOT NULL,
605
- source TEXT NOT NULL,
606
- metadata_json TEXT NOT NULL,
607
- created_at TEXT NOT NULL
608
- );
609
- CREATE TABLE IF NOT EXISTS session_handoffs (
610
- id INTEGER PRIMARY KEY AUTOINCREMENT,
611
- session_id TEXT NOT NULL,
612
- repo_root TEXT,
613
- task_id TEXT,
614
- summary TEXT NOT NULL,
615
- next_action TEXT,
616
- artifacts_json TEXT NOT NULL DEFAULT '[]',
617
- created_at TEXT NOT NULL
618
- );
598
+ db.exec(`
599
+ CREATE TABLE IF NOT EXISTS session_events (
600
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
601
+ session_id TEXT NOT NULL,
602
+ task TEXT,
603
+ event_type TEXT NOT NULL,
604
+ content TEXT NOT NULL,
605
+ source TEXT NOT NULL,
606
+ metadata_json TEXT NOT NULL,
607
+ created_at TEXT NOT NULL
608
+ );
609
+ CREATE TABLE IF NOT EXISTS session_handoffs (
610
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
611
+ session_id TEXT NOT NULL,
612
+ repo_root TEXT,
613
+ task_id TEXT,
614
+ summary TEXT NOT NULL,
615
+ next_action TEXT,
616
+ artifacts_json TEXT NOT NULL DEFAULT '[]',
617
+ created_at TEXT NOT NULL
618
+ );
619
619
  `);
620
620
  if (!tableHasColumn(db, 'session_events', 'tenant_id')) {
621
621
  db.exec(`ALTER TABLE session_events ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
@@ -635,25 +635,25 @@ const MIGRATIONS = [
635
635
  // tenant boundaries on guesses. The COUNT(DISTINCT) gate is the load-
636
636
  // bearing check; without it, rows with multiple tenants under the same
637
637
  // session_id would silently pick whichever group came first.
638
- db.exec(`
639
- UPDATE session_events
640
- SET tenant_id = (
641
- SELECT MAX(t.tenant_id) FROM task_snapshots t
642
- WHERE t.session_id = session_events.session_id
643
- )
644
- WHERE tenant_id = 'default'
645
- AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
646
- WHERE t.session_id = session_events.session_id) = 1
638
+ db.exec(`
639
+ UPDATE session_events
640
+ SET tenant_id = (
641
+ SELECT MAX(t.tenant_id) FROM task_snapshots t
642
+ WHERE t.session_id = session_events.session_id
643
+ )
644
+ WHERE tenant_id = 'default'
645
+ AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
646
+ WHERE t.session_id = session_events.session_id) = 1
647
647
  `);
648
- db.exec(`
649
- UPDATE session_handoffs
650
- SET tenant_id = (
651
- SELECT MAX(t.tenant_id) FROM task_snapshots t
652
- WHERE t.session_id = session_handoffs.session_id
653
- )
654
- WHERE tenant_id = 'default'
655
- AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
656
- WHERE t.session_id = session_handoffs.session_id) = 1
648
+ db.exec(`
649
+ UPDATE session_handoffs
650
+ SET tenant_id = (
651
+ SELECT MAX(t.tenant_id) FROM task_snapshots t
652
+ WHERE t.session_id = session_handoffs.session_id
653
+ )
654
+ WHERE tenant_id = 'default'
655
+ AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
656
+ WHERE t.session_id = session_handoffs.session_id) = 1
657
657
  `);
658
658
  db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_tenant_session ON session_events(tenant_id, session_id, created_at DESC, id DESC)`);
659
659
  db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_session ON session_handoffs(tenant_id, session_id, created_at DESC)`);
@@ -665,19 +665,19 @@ const MIGRATIONS = [
665
665
  // task_snapshots: add scope so all three continuity tables carry it.
666
666
  // Self-heal partial-init stores via CREATE TABLE IF NOT EXISTS (the v22
667
667
  // session_events / session_handoffs healing is upstream).
668
- db.exec(`
669
- CREATE TABLE IF NOT EXISTS task_snapshots (
670
- id INTEGER PRIMARY KEY AUTOINCREMENT,
671
- task TEXT NOT NULL,
672
- summary TEXT NOT NULL,
673
- next_step TEXT NOT NULL,
674
- status TEXT NOT NULL,
675
- source TEXT NOT NULL,
676
- session_id TEXT,
677
- tenant_id TEXT NOT NULL DEFAULT 'default',
678
- created_at TEXT NOT NULL,
679
- updated_at TEXT NOT NULL
680
- )
668
+ db.exec(`
669
+ CREATE TABLE IF NOT EXISTS task_snapshots (
670
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
671
+ task TEXT NOT NULL,
672
+ summary TEXT NOT NULL,
673
+ next_step TEXT NOT NULL,
674
+ status TEXT NOT NULL,
675
+ source TEXT NOT NULL,
676
+ session_id TEXT,
677
+ tenant_id TEXT NOT NULL DEFAULT 'default',
678
+ created_at TEXT NOT NULL,
679
+ updated_at TEXT NOT NULL
680
+ )
681
681
  `);
682
682
  if (!tableHasColumn(db, 'task_snapshots', 'scope')) {
683
683
  db.exec(`ALTER TABLE task_snapshots ADD COLUMN scope TEXT`);
@@ -710,64 +710,64 @@ const MIGRATIONS = [
710
710
  up: (db) => {
711
711
  // v1.3.0 GitHub connector schema (codex round 1, 2026-05-04).
712
712
  // Six tables + a min_compatible_binary meta row for rollback safety.
713
- db.exec(`
714
- CREATE TABLE IF NOT EXISTS github_event_log (
715
- idempotency_key TEXT PRIMARY KEY,
716
- delivery_id TEXT NOT NULL,
717
- event_name TEXT NOT NULL,
718
- ingested_at TEXT NOT NULL,
719
- memory_id TEXT
720
- )
713
+ db.exec(`
714
+ CREATE TABLE IF NOT EXISTS github_event_log (
715
+ idempotency_key TEXT PRIMARY KEY,
716
+ delivery_id TEXT NOT NULL,
717
+ event_name TEXT NOT NULL,
718
+ ingested_at TEXT NOT NULL,
719
+ memory_id TEXT
720
+ )
721
721
  `);
722
722
  db.exec(`CREATE INDEX IF NOT EXISTS idx_github_event_log_memory ON github_event_log(memory_id) WHERE memory_id IS NOT NULL`);
723
723
  db.exec(`CREATE INDEX IF NOT EXISTS idx_github_event_log_delivery ON github_event_log(delivery_id)`);
724
- db.exec(`
725
- CREATE TABLE IF NOT EXISTS github_cursors (
726
- tenant_id TEXT NOT NULL,
727
- repo_full_name TEXT NOT NULL,
728
- issues_hwm TEXT,
729
- issue_comments_hwm TEXT,
730
- pr_review_comments_hwm TEXT,
731
- updated_at TEXT NOT NULL,
732
- PRIMARY KEY (tenant_id, repo_full_name)
733
- )
724
+ db.exec(`
725
+ CREATE TABLE IF NOT EXISTS github_cursors (
726
+ tenant_id TEXT NOT NULL,
727
+ repo_full_name TEXT NOT NULL,
728
+ issues_hwm TEXT,
729
+ issue_comments_hwm TEXT,
730
+ pr_review_comments_hwm TEXT,
731
+ updated_at TEXT NOT NULL,
732
+ PRIMARY KEY (tenant_id, repo_full_name)
733
+ )
734
734
  `);
735
- db.exec(`
736
- CREATE TABLE IF NOT EXISTS github_dlq (
737
- id INTEGER PRIMARY KEY AUTOINCREMENT,
738
- tenant_id TEXT NOT NULL,
739
- raw_payload TEXT NOT NULL,
740
- error TEXT NOT NULL,
741
- event_name TEXT,
742
- delivery_id TEXT,
743
- signature TEXT,
744
- installation_id TEXT,
745
- repo_full_name TEXT,
746
- retry_count INTEGER NOT NULL DEFAULT 0,
747
- received_at TEXT NOT NULL,
748
- retried_at TEXT,
749
- bucket TEXT NOT NULL DEFAULT 'parse_error'
750
- )
735
+ db.exec(`
736
+ CREATE TABLE IF NOT EXISTS github_dlq (
737
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
738
+ tenant_id TEXT NOT NULL,
739
+ raw_payload TEXT NOT NULL,
740
+ error TEXT NOT NULL,
741
+ event_name TEXT,
742
+ delivery_id TEXT,
743
+ signature TEXT,
744
+ installation_id TEXT,
745
+ repo_full_name TEXT,
746
+ retry_count INTEGER NOT NULL DEFAULT 0,
747
+ received_at TEXT NOT NULL,
748
+ retried_at TEXT,
749
+ bucket TEXT NOT NULL DEFAULT 'parse_error'
750
+ )
751
751
  `);
752
752
  db.exec(`CREATE INDEX IF NOT EXISTS idx_github_dlq_tenant_received ON github_dlq(tenant_id, received_at)`);
753
- db.exec(`
754
- CREATE TABLE IF NOT EXISTS github_installations (
755
- installation_id TEXT PRIMARY KEY,
756
- tenant_id TEXT NOT NULL,
757
- added_at TEXT NOT NULL
758
- )
753
+ db.exec(`
754
+ CREATE TABLE IF NOT EXISTS github_installations (
755
+ installation_id TEXT PRIMARY KEY,
756
+ tenant_id TEXT NOT NULL,
757
+ added_at TEXT NOT NULL
758
+ )
759
759
  `);
760
760
  // PAT-mode multi-tenant routing (codex P0 #4). Maps repo_full_name to
761
761
  // tenant when the webhook envelope has no `installation` field. Composite
762
762
  // PK so the same repo can intentionally be visible to multiple tenants
763
763
  // (e.g., shared tooling accounts) — collision is on (repo, tenant) pair.
764
- db.exec(`
765
- CREATE TABLE IF NOT EXISTS github_repositories (
766
- repo_full_name TEXT NOT NULL,
767
- tenant_id TEXT NOT NULL,
768
- added_at TEXT NOT NULL,
769
- PRIMARY KEY (repo_full_name, tenant_id)
770
- )
764
+ db.exec(`
765
+ CREATE TABLE IF NOT EXISTS github_repositories (
766
+ repo_full_name TEXT NOT NULL,
767
+ tenant_id TEXT NOT NULL,
768
+ added_at TEXT NOT NULL,
769
+ PRIMARY KEY (repo_full_name, tenant_id)
770
+ )
771
771
  `);
772
772
  // Rollback-safety guard (codex P0 #2). Any binary < 1.2.1 lacks the
773
773
  // generic *:private:* default-deny and would leak github:private:* rows
@@ -797,26 +797,26 @@ const MIGRATIONS = [
797
797
  // Backfill descendant_count for existing level-2 summary rows. Use
798
798
  // dag_parent_id pointing at the summary id. Level-3 (entity profiles)
799
799
  // not built today; their descendant_count stays at default 0.
800
- db.exec(`
801
- UPDATE memories
802
- SET descendant_count = (
803
- SELECT COUNT(*) FROM memories AS c WHERE c.dag_parent_id = memories.id
804
- )
805
- WHERE dag_level >= 2
806
- AND descendant_count = 0
800
+ db.exec(`
801
+ UPDATE memories
802
+ SET descendant_count = (
803
+ SELECT COUNT(*) FROM memories AS c WHERE c.dag_parent_id = memories.id
804
+ )
805
+ WHERE dag_level >= 2
806
+ AND descendant_count = 0
807
807
  `);
808
808
  // Backfill earliest_at / latest_at from child created timestamps for
809
809
  // existing summaries. Children of a level-2 summary are level-1 facts.
810
- db.exec(`
811
- UPDATE memories
812
- SET earliest_at = (
813
- SELECT MIN(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
814
- ),
815
- latest_at = (
816
- SELECT MAX(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
817
- )
818
- WHERE dag_level >= 2
819
- AND earliest_at IS NULL
810
+ db.exec(`
811
+ UPDATE memories
812
+ SET earliest_at = (
813
+ SELECT MIN(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
814
+ ),
815
+ latest_at = (
816
+ SELECT MAX(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
817
+ )
818
+ WHERE dag_level >= 2
819
+ AND earliest_at IS NULL
820
820
  `);
821
821
  },
822
822
  },
@@ -859,32 +859,32 @@ const MIGRATIONS = [
859
859
  // bug, fixes anyone who has it. Includes the role column from the
860
860
  // start so it matches v26's intent without needing v26 to ALTER on
861
861
  // this heal path.
862
- db.exec(`
863
- CREATE TABLE IF NOT EXISTS api_keys (
864
- id INTEGER PRIMARY KEY AUTOINCREMENT,
865
- key_id TEXT UNIQUE NOT NULL,
866
- key_hash TEXT NOT NULL,
867
- tenant_id TEXT NOT NULL DEFAULT 'default',
868
- label TEXT,
869
- created_at TEXT NOT NULL,
870
- revoked_at TEXT,
871
- role TEXT NOT NULL DEFAULT 'admin'
872
- )
862
+ db.exec(`
863
+ CREATE TABLE IF NOT EXISTS api_keys (
864
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
865
+ key_id TEXT UNIQUE NOT NULL,
866
+ key_hash TEXT NOT NULL,
867
+ tenant_id TEXT NOT NULL DEFAULT 'default',
868
+ label TEXT,
869
+ created_at TEXT NOT NULL,
870
+ revoked_at TEXT,
871
+ role TEXT NOT NULL DEFAULT 'admin'
872
+ )
873
873
  `);
874
- db.exec(`
875
- CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
876
- ON api_keys(tenant_id) WHERE revoked_at IS NULL
874
+ db.exec(`
875
+ CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
876
+ ON api_keys(tenant_id) WHERE revoked_at IS NULL
877
877
  `);
878
- db.exec(`
879
- CREATE TABLE IF NOT EXISTS audit_log (
880
- id INTEGER PRIMARY KEY AUTOINCREMENT,
881
- ts TEXT NOT NULL,
882
- tenant_id TEXT NOT NULL DEFAULT 'default',
883
- actor TEXT NOT NULL,
884
- op TEXT NOT NULL,
885
- target_id TEXT,
886
- metadata_json TEXT NOT NULL DEFAULT '{}'
887
- )
878
+ db.exec(`
879
+ CREATE TABLE IF NOT EXISTS audit_log (
880
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
881
+ ts TEXT NOT NULL,
882
+ tenant_id TEXT NOT NULL DEFAULT 'default',
883
+ actor TEXT NOT NULL,
884
+ op TEXT NOT NULL,
885
+ target_id TEXT,
886
+ metadata_json TEXT NOT NULL DEFAULT '{}'
887
+ )
888
888
  `);
889
889
  db.exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_ts ON audit_log(tenant_id, ts DESC)`);
890
890
  // Belt-and-braces: if api_keys existed before this migration WITHOUT
@@ -952,58 +952,872 @@ const MIGRATIONS = [
952
952
  // J3 computes accuracy (clean vs regressed) from (estimate_value,
953
953
  // actual_value) at query time.
954
954
  if (!tableExists(db, 'predictions')) {
955
- db.exec(`
956
- CREATE TABLE predictions (
957
- id INTEGER PRIMARY KEY AUTOINCREMENT,
958
- memory_id TEXT,
959
- tenant_id TEXT NOT NULL,
960
- class_tag TEXT NOT NULL,
961
- claim_text TEXT NOT NULL,
962
- estimate_value REAL,
963
- estimate_unit TEXT,
964
- target_date TEXT,
965
- actual_value REAL,
966
- closure_state TEXT NOT NULL DEFAULT 'open'
967
- CHECK (closure_state IN ('open', 'closed', 'closed-unknown')),
968
- closed_at TEXT,
969
- closure_note TEXT,
970
- created_at TEXT NOT NULL,
971
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
972
- )
955
+ db.exec(`
956
+ CREATE TABLE predictions (
957
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
958
+ memory_id TEXT,
959
+ tenant_id TEXT NOT NULL,
960
+ class_tag TEXT NOT NULL,
961
+ claim_text TEXT NOT NULL,
962
+ estimate_value REAL,
963
+ estimate_unit TEXT,
964
+ target_date TEXT,
965
+ actual_value REAL,
966
+ closure_state TEXT NOT NULL DEFAULT 'open'
967
+ CHECK (closure_state IN ('open', 'closed', 'closed-unknown')),
968
+ closed_at TEXT,
969
+ closure_note TEXT,
970
+ created_at TEXT NOT NULL,
971
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
972
+ )
973
973
  `);
974
- db.exec(`
975
- CREATE INDEX IF NOT EXISTS idx_predictions_tenant_class
976
- ON predictions(tenant_id, class_tag, closure_state)
974
+ db.exec(`
975
+ CREATE INDEX IF NOT EXISTS idx_predictions_tenant_class
976
+ ON predictions(tenant_id, class_tag, closure_state)
977
977
  `);
978
- db.exec(`
979
- CREATE INDEX IF NOT EXISTS idx_predictions_memory
980
- ON predictions(memory_id) WHERE memory_id IS NOT NULL
978
+ db.exec(`
979
+ CREATE INDEX IF NOT EXISTS idx_predictions_memory
980
+ ON predictions(memory_id) WHERE memory_id IS NOT NULL
981
981
  `);
982
982
  // Cross-tenant safety: tenant_id must match the referenced memory's
983
983
  // tenant_id when memory_id IS NOT NULL. INSERT + UPDATE pair, mirroring
984
984
  // v14 memories.kind enforcement (db.ts:298-322).
985
- db.exec(`
986
- CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_insert
987
- BEFORE INSERT ON predictions
988
- WHEN NEW.memory_id IS NOT NULL
989
- BEGIN
990
- SELECT CASE
991
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
992
- THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
993
- END;
994
- END
985
+ db.exec(`
986
+ CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_insert
987
+ BEFORE INSERT ON predictions
988
+ WHEN NEW.memory_id IS NOT NULL
989
+ BEGIN
990
+ SELECT CASE
991
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
992
+ THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
993
+ END;
994
+ END
995
995
  `);
996
- db.exec(`
997
- CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_update
998
- BEFORE UPDATE ON predictions
999
- WHEN NEW.memory_id IS NOT NULL
1000
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1001
- BEGIN
1002
- SELECT CASE
1003
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1004
- THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
1005
- END;
1006
- END
996
+ db.exec(`
997
+ CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_update
998
+ BEFORE UPDATE ON predictions
999
+ WHEN NEW.memory_id IS NOT NULL
1000
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1001
+ BEGIN
1002
+ SELECT CASE
1003
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1004
+ THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
1005
+ END;
1006
+ END
1007
+ `);
1008
+ }
1009
+ },
1010
+ },
1011
+ {
1012
+ version: 30,
1013
+ up: (db) => {
1014
+ // E2 decision first-class object (docs/plans/2026-05-28-e2-decision-object.md).
1015
+ // Promotes `hippo decide` from a tagged memory (which decayed on a 90-day
1016
+ // half-life even while the decision was still in force) to a canonical
1017
+ // decisions table that is the source of truth. The memory mirror is kept
1018
+ // for recall but is no longer authoritative; memory_id is NULLABLE with
1019
+ // ON DELETE SET NULL so forget/consolidate/archive does not lose a
1020
+ // decision. Mirrors the v29 predictions tenant-match trigger pattern.
1021
+ //
1022
+ // status (active|superseded|closed): superseded carries a self-FK
1023
+ // superseded_by to the successor decision; closed is a terminal
1024
+ // retire-without-successor. A superseded_by same-tenant trigger makes
1025
+ // cross-tenant supersession unrepresentable at the schema level.
1026
+ if (!tableExists(db, 'decisions')) {
1027
+ db.exec(`
1028
+ CREATE TABLE decisions (
1029
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1030
+ memory_id TEXT,
1031
+ tenant_id TEXT NOT NULL,
1032
+ decision_text TEXT NOT NULL,
1033
+ context TEXT,
1034
+ status TEXT NOT NULL DEFAULT 'active'
1035
+ CHECK (status IN ('active', 'superseded', 'closed')),
1036
+ superseded_by INTEGER,
1037
+ superseded_at TEXT,
1038
+ closed_at TEXT,
1039
+ created_at TEXT NOT NULL,
1040
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1041
+ FOREIGN KEY (superseded_by) REFERENCES decisions(id) ON DELETE SET NULL
1042
+ )
1043
+ `);
1044
+ db.exec(`
1045
+ CREATE INDEX IF NOT EXISTS idx_decisions_tenant_status
1046
+ ON decisions(tenant_id, status)
1047
+ `);
1048
+ db.exec(`
1049
+ CREATE INDEX IF NOT EXISTS idx_decisions_memory
1050
+ ON decisions(memory_id) WHERE memory_id IS NOT NULL
1051
+ `);
1052
+ // Cross-tenant safety vs the referenced memory (mirrors predictions v29).
1053
+ db.exec(`
1054
+ CREATE TRIGGER IF NOT EXISTS trg_decisions_tenant_match_insert
1055
+ BEFORE INSERT ON decisions
1056
+ WHEN NEW.memory_id IS NOT NULL
1057
+ BEGIN
1058
+ SELECT CASE
1059
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1060
+ THEN RAISE(ABORT, 'decisions.tenant_id must match memories.tenant_id for the referenced memory')
1061
+ END;
1062
+ END
1063
+ `);
1064
+ db.exec(`
1065
+ CREATE TRIGGER IF NOT EXISTS trg_decisions_tenant_match_update
1066
+ BEFORE UPDATE ON decisions
1067
+ WHEN NEW.memory_id IS NOT NULL
1068
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1069
+ BEGIN
1070
+ SELECT CASE
1071
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1072
+ THEN RAISE(ABORT, 'decisions.tenant_id must match memories.tenant_id for the referenced memory')
1073
+ END;
1074
+ END
1075
+ `);
1076
+ // Cross-tenant safety vs the successor decision (self-FK). superseded_by
1077
+ // is set only via UPDATE (the supersede path); the successor must share
1078
+ // the tenant. The successor row already exists in the same transaction
1079
+ // when this fires, so the subquery resolves.
1080
+ db.exec(`
1081
+ CREATE TRIGGER IF NOT EXISTS trg_decisions_supersede_tenant_match_update
1082
+ BEFORE UPDATE ON decisions
1083
+ WHEN NEW.superseded_by IS NOT NULL
1084
+ AND NEW.superseded_by IS NOT OLD.superseded_by
1085
+ BEGIN
1086
+ SELECT CASE
1087
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM decisions WHERE id = NEW.superseded_by)
1088
+ THEN RAISE(ABORT, 'decisions.superseded_by must reference a decision in the same tenant')
1089
+ END;
1090
+ END
1091
+ `);
1092
+ }
1093
+ },
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
1007
1821
  `);
1008
1822
  }
1009
1823
  },
@@ -1101,11 +1915,11 @@ function runMigrations(db) {
1101
1915
  ensureOptionalFts(db);
1102
1916
  }
1103
1917
  function ensureMetaTable(db) {
1104
- db.exec(`
1105
- CREATE TABLE IF NOT EXISTS meta (
1106
- key TEXT PRIMARY KEY,
1107
- value TEXT NOT NULL
1108
- );
1918
+ db.exec(`
1919
+ CREATE TABLE IF NOT EXISTS meta (
1920
+ key TEXT PRIMARY KEY,
1921
+ value TEXT NOT NULL
1922
+ );
1109
1923
  `);
1110
1924
  }
1111
1925
  export function getSchemaVersion(db) {
@@ -1148,17 +1962,17 @@ function backfillFtsIndex(db) {
1148
1962
  const ftsCount = db.prepare(`SELECT COUNT(*) AS c FROM memories_fts`).get()?.c ?? 0;
1149
1963
  if (memCount === ftsCount)
1150
1964
  return;
1151
- db.exec(`
1152
- INSERT INTO memories_fts(id, content, tags)
1153
- SELECT m.id, m.content, m.tags_json
1154
- FROM memories m
1155
- WHERE NOT EXISTS (
1156
- SELECT 1 FROM memories_fts f WHERE f.id = m.id
1157
- )
1965
+ db.exec(`
1966
+ INSERT INTO memories_fts(id, content, tags)
1967
+ SELECT m.id, m.content, m.tags_json
1968
+ FROM memories m
1969
+ WHERE NOT EXISTS (
1970
+ SELECT 1 FROM memories_fts f WHERE f.id = m.id
1971
+ )
1158
1972
  `);
1159
- db.exec(`
1160
- DELETE FROM memories_fts
1161
- WHERE id NOT IN (SELECT id FROM memories)
1973
+ db.exec(`
1974
+ DELETE FROM memories_fts
1975
+ WHERE id NOT IN (SELECT id FROM memories)
1162
1976
  `);
1163
1977
  }
1164
1978
  export function closeHippoDb(db) {
@@ -1175,13 +1989,13 @@ export function isFtsAvailable(db) {
1175
1989
  return getMeta(db, 'fts5_available', '0') === '1';
1176
1990
  }
1177
1991
  export function pruneConsolidationRuns(db, keep = 50) {
1178
- db.prepare(`
1179
- DELETE FROM consolidation_runs
1180
- WHERE id NOT IN (
1181
- SELECT id FROM consolidation_runs
1182
- ORDER BY timestamp DESC, id DESC
1183
- LIMIT ?
1184
- )
1992
+ db.prepare(`
1993
+ DELETE FROM consolidation_runs
1994
+ WHERE id NOT IN (
1995
+ SELECT id FROM consolidation_runs
1996
+ ORDER BY timestamp DESC, id DESC
1997
+ LIMIT ?
1998
+ )
1185
1999
  `).run(keep);
1186
2000
  }
1187
2001
  //# sourceMappingURL=db.js.map