@jhizzard/termdeck 1.0.13 → 1.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "1.0.13",
3
+ "version": "1.1.0",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -332,23 +332,63 @@ async function promptSecretWithValidation(validator) {
332
332
  throw new Error('Too many invalid attempts — cancelling.');
333
333
  }
334
334
 
335
+ // Sprint 61 T2 — collapsed fresh-install / upgrade paths. Pre-Sprint-61, the
336
+ // wizard re-applied every bundled mnestra migration on every invocation,
337
+ // relying on per-file IF NOT EXISTS / CREATE OR REPLACE idempotency. That
338
+ // works for fresh installs but doesn't tell the wizard which migrations the
339
+ // live database has actually received — so a user running
340
+ // `npm install -g @latest` against an existing project lands in Class A
341
+ // (schema drift on package upgrade): the npm package files upgrade, the
342
+ // database stays at first-kickstart state. Brad reported this 2026-05-02.
343
+ //
344
+ // applyPendingMigrations (migrations.js) replaces the loop with a tracker-
345
+ // aware diff: SELECT applied filenames from public.mnestra_migrations, run
346
+ // only the bundled-but-unapplied ones, INSERT a tracker row per apply.
347
+ // Pre-020 installs trigger a one-time backfill probe pass that seeds the
348
+ // tracker for migrations whose schema artifacts are already present.
335
349
  async function applyMigrations(client, dryRun) {
336
350
  const files = migrations.listMnestraMigrations();
337
351
  if (files.length === 0) {
338
352
  throw new Error('No Mnestra migrations found. TermDeck install looks corrupted.');
339
353
  }
340
354
 
341
- for (const file of files) {
342
- const base = path.basename(file);
343
- step(`Applying migration ${base}...`);
344
- if (dryRun) { ok('(dry-run)'); continue; }
345
- const result = await pgRunner.applyFile(client, file);
346
- if (result.ok) {
347
- ok(`(${result.elapsedMs}ms)`);
348
- } else {
349
- fail(result.error);
350
- throw new Error(`Migration failed: ${base}`);
355
+ if (dryRun) {
356
+ // Preserve the per-file dry-run banner so the user sees the planned
357
+ // sequence without touching the database.
358
+ for (const file of files) {
359
+ const base = path.basename(file);
360
+ step(`Applying migration ${base}...`);
361
+ ok('(dry-run)');
351
362
  }
363
+ return;
364
+ }
365
+
366
+ step('Running tracker-aware diff-and-apply (skips already-applied migrations)...');
367
+ const summary = await migrations.applyPendingMigrations(client);
368
+
369
+ if (summary.errored) {
370
+ fail(`${summary.errored.file}: ${summary.errored.error}`);
371
+ throw new Error(`Migration failed: ${summary.errored.file}`);
372
+ }
373
+
374
+ ok(
375
+ `(applied ${summary.applied.length}, backfilled ${summary.backfilled.length}, ` +
376
+ `skipped ${summary.skipped.length})`
377
+ );
378
+
379
+ for (const f of summary.applied) {
380
+ process.stdout.write(` ✓ applied ${f}\n`);
381
+ }
382
+ for (const f of summary.backfilled) {
383
+ process.stdout.write(` ◇ backfilled ${f} (schema already present, recorded in tracker)\n`);
384
+ }
385
+ for (const w of summary.warnings) {
386
+ const tracked = (w.trackedChecksum || '').slice(0, 12) || '<empty>';
387
+ const bundled = (w.bundledChecksum || '').slice(0, 12) || '<empty>';
388
+ process.stdout.write(
389
+ ` ! checksum drift on ${w.file}: tracked=${tracked}, bundled=${bundled} ` +
390
+ `(no auto-overwrite — investigate before re-running)\n`
391
+ );
352
392
  }
353
393
  }
354
394
 
@@ -69,6 +69,17 @@ const TOOL = /^(?:\$\s|→\s|exec(?:_command\b|\b)|Running\b|Calling\b)/m;
69
69
  // label when it's done reasoning and waiting on the user.
70
70
  const IDLE = /^codex\s*$/m;
71
71
 
72
+ // End-of-turn terminator (Sprint 60 v1.0.14 fix). After Codex finishes a
73
+ // reply the TUI renders a separator with the elapsed time, e.g.
74
+ // "─ Worked for 2m 50s ──────────" using box-drawing dashes (U+2500). This
75
+ // pattern is unambiguous: it only ever appears when the turn closes and the
76
+ // panel parks waiting for next input. Placed FIRST in the statusFor cascade
77
+ // because the same chunk may also contain a final "Working" spinner update
78
+ // that would otherwise stick `status: 'thinking'` indefinitely. Bit Sprint 59
79
+ // twice — orchestrator's `meta.status` reported "Codex is reasoning..." for
80
+ // 22+ minutes after Codex actually parked at end-of-turn.
81
+ const END_OF_TURN = /─\s*Worked for\s+(?:\d+m\s*)?\d+s\s*─/;
82
+
72
83
  // Error patterns — line-anchored to avoid mid-line "error" mentions in tool
73
84
  // output (grep results, test logs, file dumps) flagging false positives.
74
85
  // Same shape as Claude with codex-specific OpenAI-API failure modes added
@@ -82,6 +93,12 @@ const ERROR = /^\s*(?:(?:error|Error|ERROR|exception|Exception|Traceback|fatal|F
82
93
  // ──────────────────────────────────────────────────────────────────────────
83
94
 
84
95
  function statusFor(data) {
96
+ // Sprint 60 v1.0.14: end-of-turn terminator wins over THINKING. Without
97
+ // this branch, a chunk that contains both a final "Working Xs" spinner
98
+ // line AND the closing "Worked for X" separator would stick on 'thinking'.
99
+ if (END_OF_TURN.test(data)) {
100
+ return { status: 'idle', statusDetail: '' };
101
+ }
85
102
  if (THINKING.test(data)) {
86
103
  return { status: 'thinking', statusDetail: 'Codex is reasoning...' };
87
104
  }
@@ -261,6 +278,7 @@ const codexAdapter = {
261
278
  patterns: {
262
279
  prompt: PROMPT,
263
280
  thinking: THINKING,
281
+ endOfTurn: END_OF_TURN,
264
282
  editing: EDITING,
265
283
  tool: TOOL,
266
284
  idle: IDLE,
@@ -267,12 +267,92 @@ function _termdeckVersion() {
267
267
  catch { return '0.0.0'; }
268
268
  }
269
269
 
270
+ // Sprint 60 v1.0.14 (Item 3) — safe PTY resize. Brad's 2026-05-07 r730 crash
271
+ // forensic surfaced 25× `[ws] message handler error: Error: ioctl(2) failed,
272
+ // EBADF/ENOTTY` per 13h uptime. Race: WS `resize` message arrives for a PTY
273
+ // that pty-reaper has already closed (or the child has exited), and
274
+ // `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
275
+ // but the noisy console.error trace pollutes diagnostics and obscures real
276
+ // errors. This helper guards against the race and downgrades the known
277
+ // race-class errors (EBADF, ENOTTY, generic "ioctl failed" message shape) to
278
+ // a silent return. Set TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug
279
+ // for diagnostics.
280
+ function safelyResizePty(session, cols, rows) {
281
+ if (!session || !session.pty) return false;
282
+ if (session.meta && session.meta.status === 'exited') return false;
283
+ try {
284
+ session.pty.resize(cols || 120, rows || 30);
285
+ return true;
286
+ } catch (err) {
287
+ const msg = (err && err.message) || '';
288
+ const code = err && err.code;
289
+ // Sprint 60 v1.0.14 + T4-CODEX AUDIT-CONCERN narrowing: race classifier
290
+ // requires explicit EBADF or ENOTTY (in code OR message). The earlier
291
+ // shape — any "ioctl(N) failed" message — was too broad: it would have
292
+ // silently dropped a non-race ioctl failure (e.g. EINTR, EFAULT) that
293
+ // might indicate a real bug. Now: only the specific race-class signals
294
+ // get suppressed; everything else rethrows so it surfaces in logs.
295
+ const isRace =
296
+ code === 'EBADF' ||
297
+ code === 'ENOTTY' ||
298
+ /\b(?:EBADF|ENOTTY)\b/.test(msg);
299
+ if (isRace) {
300
+ if (process.env.TERMDECK_DEBUG_PTY_RACES) {
301
+ console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${code || msg}`);
302
+ }
303
+ return false;
304
+ }
305
+ throw err;
306
+ }
307
+ }
308
+
270
309
  function createServer(config) {
271
310
  const app = express();
272
311
  const server = http.createServer(app);
273
312
  const wss = new WebSocketServer({ server, path: '/ws' });
274
313
 
275
- app.use(express.json());
314
+ // Sprint 60 v1.0.14 (Item 2) — pre-screen incoming JSON bodies for unescaped
315
+ // control characters in string contexts. Brad's 2026-05-07 r730 crash
316
+ // forensic logged 9x `SyntaxError: Bad control character in string literal
317
+ // in JSON at position 9` per 13h uptime. The post-Sprint-56 error-handler
318
+ // already returns a structured 400, but body-parser's internal
319
+ // `JSON.parse(body)` throws a verbose SyntaxError whose 10-line stack trace
320
+ // dumps to stderr (Express dev-mode default error logger). The verify
321
+ // callback below fails earlier with a tight ControlCharBodyError that our
322
+ // handler logs as a single-line warning instead of a stack trace.
323
+ //
324
+ // Most likely source of these bodies: agent-to-agent inject through
325
+ // /api/sessions/:id/input where the `text` field contains raw PTY escape
326
+ // sequences (e.g. one panel forwarding terminal output to another). The
327
+ // 400 response is the correct user-facing semantic; this just quiets the
328
+ // logs so real errors aren't drowned in noise.
329
+ app.use(express.json({
330
+ verify: (req, res, buf) => {
331
+ // O(N) single-pass scan. Only checks bytes inside double-quoted string
332
+ // regions so structural whitespace doesn't trigger false positives.
333
+ let inString = false;
334
+ let escape = false;
335
+ for (let i = 0; i < buf.length; i++) {
336
+ const b = buf[i];
337
+ if (!inString) {
338
+ if (b === 0x22) inString = true; // "
339
+ continue;
340
+ }
341
+ if (escape) { escape = false; continue; }
342
+ if (b === 0x5c) { escape = true; continue; } // backslash
343
+ if (b === 0x22) { inString = false; continue; } // closing quote
344
+ // JSON forbids unescaped control chars (0x00-0x1F and 0x7F) inside
345
+ // string literals. Reject with a structured error.
346
+ if (b < 0x20 || b === 0x7f) {
347
+ const err = new Error(`Body contains illegal control character 0x${b.toString(16).padStart(2, '0')} at byte ${i}`);
348
+ err.type = 'entity.verify.failed';
349
+ err.statusCode = 400;
350
+ err.code = 'CONTROL_CHAR_IN_STRING';
351
+ throw err;
352
+ }
353
+ }
354
+ },
355
+ }));
276
356
 
277
357
  // Sprint 56 (T2 F-T2-1) — malformed-JSON body returns JSON 400, not
278
358
  // express's default HTML error page. Pre-Sprint-56 every POST/PATCH
@@ -281,9 +361,23 @@ function createServer(config) {
281
361
  // smoke tests). The status code (400) was correct; only the body
282
362
  // shape regressed. Mounted IMMEDIATELY after express.json() so it
283
363
  // catches body-parse errors before any route handler runs.
364
+ //
365
+ // Sprint 60 v1.0.14 — extended to also catch `entity.verify.failed` from
366
+ // the control-char pre-screen above, AND to log via console.warn (single
367
+ // line) instead of letting Express's default error logger dump a 10-line
368
+ // stack trace to stderr.
284
369
  app.use((err, req, res, next) => {
285
- if (err && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
286
- return res.status(400).json({ error: 'Malformed JSON body', detail: err.message });
370
+ if (err && (
371
+ err.type === 'entity.parse.failed' ||
372
+ err.type === 'entity.verify.failed' ||
373
+ err instanceof SyntaxError
374
+ )) {
375
+ console.warn(`[body-parser] ${err.code || err.type || 'parse-error'}: ${err.message} (${req.method} ${req.path})`);
376
+ return res.status(400).json({
377
+ error: 'Malformed JSON body',
378
+ detail: err.message,
379
+ code: err.code,
380
+ });
287
381
  }
288
382
  return next(err);
289
383
  });
@@ -1489,7 +1583,10 @@ function createServer(config) {
1489
1583
 
1490
1584
  const { cols, rows } = req.body;
1491
1585
  try {
1492
- session.pty.resize(cols || 120, rows || 30);
1586
+ const resized = safelyResizePty(session, cols, rows);
1587
+ if (!resized) {
1588
+ return res.status(409).json({ error: 'Session is exited or its PTY is no longer alive' });
1589
+ }
1493
1590
  res.json({ ok: true, cols, rows });
1494
1591
  } catch (err) {
1495
1592
  res.status(500).json({ error: err.message });
@@ -2160,9 +2257,10 @@ function createServer(config) {
2160
2257
  break;
2161
2258
 
2162
2259
  case 'resize':
2163
- if (session.pty) {
2164
- session.pty.resize(parsed.cols || 120, parsed.rows || 30);
2165
- }
2260
+ // Sprint 60 v1.0.14 — safelyResizePty guards against the
2261
+ // pty-reaper-closed-the-fd race that surfaced 25x in Brad's
2262
+ // 13h uptime as ioctl EBADF/ENOTTY noise.
2263
+ safelyResizePty(session, parsed.cols, parsed.rows);
2166
2264
  break;
2167
2265
 
2168
2266
  case 'meta':
@@ -2454,7 +2552,16 @@ if (require.main === module) {
2454
2552
  process.on('SIGTERM', () => handleShutdown('SIGTERM'));
2455
2553
 
2456
2554
  server.listen(port, host, () => {
2457
- console.log(`\n TermDeck running at http://${host}:${port}\n`);
2555
+ // Sprint 60 v1.0.14 (Item 5) per-boot banner with ISO timestamp + PID.
2556
+ // Brad's 2026-05-07 forensic: a single 260KB termdeck.log spanned Apr 25
2557
+ // through May 7 with only ONE boot banner at the top. Crash → restart
2558
+ // dropped its own banner somewhere we couldn't find, making post-mortem
2559
+ // diagnosis harder. Per-boot timestamps make crash boundaries trivially
2560
+ // greppable and let `journalctl`/`tail` users scan a single log to find
2561
+ // the most recent restart instantly.
2562
+ const bootIso = new Date().toISOString();
2563
+ console.log(`\n ════ TermDeck server boot · ${bootIso} · pid ${process.pid} ════`);
2564
+ console.log(` TermDeck running at http://${host}:${port}\n`);
2458
2565
  console.log(` Terminals: 0 active`);
2459
2566
  console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
2460
2567
  console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
@@ -2470,6 +2577,10 @@ if (require.main === module) {
2470
2577
  module.exports = {
2471
2578
  createServer,
2472
2579
  loadConfig,
2580
+ // Sprint 60 v1.0.14 (Item 3) — exported so tests can import the production
2581
+ // helper instead of re-implementing it. T4-CODEX AUDIT-CONCERN flagged that
2582
+ // the prior re-implementation pattern in the test could drift silently.
2583
+ safelyResizePty,
2473
2584
  // Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
2474
2585
  readTermdeckSecretsForPty,
2475
2586
  _resetTermdeckSecretsCache,
@@ -516,10 +516,29 @@ class Session {
516
516
  }
517
517
 
518
518
  toJSON() {
519
+ const meta = { ...this.meta };
520
+ // Sprint 60 v1.0.14 — stale-status guard. If a panel's status is in the
521
+ // sticky set ('thinking', 'editing') but no PTY output has arrived for
522
+ // STALE_STATUS_THRESHOLD_MS, treat it as parked at end-of-turn and report
523
+ // 'idle' instead. Lazy: only evaluated on serialization (zero timer cost).
524
+ // Backstops adapter-specific end-of-turn detection — Codex's "Worked for"
525
+ // terminator catches the precise case; this catches the general one
526
+ // (Claude's stuck-on-thinking, future adapters that forget end-of-turn,
527
+ // any adapter where the terminator chunk is split across reads). Bit
528
+ // Sprint 59 twice — orchestrator's GET /api/sessions reported sticky
529
+ // 'thinking' for 22 minutes after the panel actually parked.
530
+ const STICKY_STATUSES = Session.STICKY_STATUSES;
531
+ if (STICKY_STATUSES.has(meta.status)) {
532
+ const ageMs = Date.now() - new Date(meta.lastActivity).getTime();
533
+ if (ageMs > Session.STALE_STATUS_THRESHOLD_MS) {
534
+ meta.status = 'idle';
535
+ meta.statusDetail = '';
536
+ }
537
+ }
519
538
  return {
520
539
  id: this.id,
521
540
  pid: this.pid,
522
- meta: { ...this.meta }
541
+ meta
523
542
  };
524
543
  }
525
544
 
@@ -530,6 +549,13 @@ class Session {
530
549
  }
531
550
  }
532
551
 
552
+ // Sprint 60 v1.0.14 — class statics for the stale-status guard. Exposed on
553
+ // the class (not const-locked inside toJSON) so tests can stub them and so
554
+ // the threshold can be tuned in one place if signal/noise needs adjustment.
555
+ Session.STICKY_STATUSES = new Set(['thinking', 'editing']);
556
+ Session.STALE_STATUS_THRESHOLD_MS = 30000;
557
+
558
+
533
559
  class SessionManager {
534
560
  constructor(db) {
535
561
  this.sessions = new Map();
@@ -26,9 +26,120 @@
26
26
 
27
27
  const fs = require('fs');
28
28
  const path = require('path');
29
+ const crypto = require('crypto');
29
30
 
30
31
  const SETUP_DIR = __dirname;
31
32
 
33
+ // Sprint 61 T2 — durable migration tracking table + filename + table name.
34
+ // `mnestra_migrations` is created by bundled migration 020 (RLS-on,
35
+ // service_role-only, no policies). The applyPendingMigrations diff loop and
36
+ // the backfill probe both target this table.
37
+ const TRACKER_TABLE = 'public.mnestra_migrations';
38
+ const TRACKER_FILE = '020_migration_tracking.sql';
39
+
40
+ // Sprint 61 T2 — declarative probe set.
41
+ //
42
+ // One row per bundled mnestra migration 001-019 (020 itself is the tracker
43
+ // and is bootstrap-special-cased; not probed). Each probe is a single
44
+ // presence-style SQL statement: returns ≥1 row when the migration's schema
45
+ // artifact is in place, 0 rows otherwise.
46
+ //
47
+ // Used by applyPendingMigrations() during the backfill pass: when an install
48
+ // is pre-020 (no tracker table yet) and a bundled migration is not in the
49
+ // applied-set, the probe decides whether the migration's effects are already
50
+ // present (→ INSERT a backfill tracker row, skip apply) or genuinely missing
51
+ // (→ run the migration via the normal apply path, INSERT a real tracker row).
52
+ //
53
+ // Probe values:
54
+ // - string: SQL fragment to run via client.query(). Probe is "present"
55
+ // when the result has ≥1 row.
56
+ // - null: no schema artifact to introspect (DML migrations, comments-only
57
+ // placeholders). The first apply runs the migration; the tracker
58
+ // row prevents re-application on subsequent passes. The brief
59
+ // notes 003 (event_webhook placeholder), 011 (project_tag_backfill
60
+ // DML), and 012 (project_tag_re_taxonomy DML) fall here.
61
+ const MIGRATION_PROBES = Object.freeze({
62
+ '001_mnestra_tables.sql':
63
+ "select 1 from information_schema.tables where table_schema='public' and table_name='memory_items'",
64
+ '002_mnestra_search_function.sql':
65
+ "select 1 from pg_proc where proname='memory_hybrid_search'",
66
+ // 003 is a comments-only placeholder migration with no DDL/DML body. The
67
+ // apply path is a no-op on every install. Always-present probe is the
68
+ // honest schema fingerprint — every install for which 001 has run is
69
+ // also "compatible with 003." Post-Sprint-61-T2-audit refinement.
70
+ '003_mnestra_event_webhook.sql':
71
+ "select 1",
72
+ '004_mnestra_match_count_cap_and_explain.sql':
73
+ "select 1 from pg_proc where proname='memory_hybrid_search_explain'",
74
+ '005_v0_1_to_v0_2_upgrade.sql':
75
+ "select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='archived'",
76
+ '006_memory_status_rpc.sql':
77
+ "select 1 from pg_proc where proname='memory_status_aggregation'",
78
+ '007_add_source_session_id.sql':
79
+ "select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='source_session_id'",
80
+ '008_legacy_rag_tables.sql':
81
+ "select 1 from information_schema.tables where table_schema='public' and table_name='mnestra_session_memory'",
82
+ '009_memory_relationship_metadata.sql':
83
+ "select 1 from information_schema.columns where table_schema='public' and table_name='memory_relationships' and column_name='weight'",
84
+ '010_memory_recall_graph.sql':
85
+ "select 1 from pg_proc where proname='memory_recall_graph'",
86
+ // 011 retags chopin-nashville rows into post-Sprint-41 buckets (termdeck,
87
+ // rumen, podium, pvb, dor). Probe present iff any row carries one of those
88
+ // tags — meaning either 011 has run, or the install legitimately has rows
89
+ // tagged that way through other means. Either way the apply is a no-op
90
+ // (the UPDATEs are gated on `project = 'chopin-nashville'`), so a false-
91
+ // positive backfill costs nothing. Post-Sprint-61-T2-audit refinement.
92
+ '011_project_tag_backfill.sql':
93
+ "select 1 from memory_items where project in ('termdeck', 'rumen', 'podium', 'pvb', 'dor') limit 1",
94
+ // 012 expands 011's taxonomy with chopin-in-bohemia, chopin-scheduler, and
95
+ // claimguard buckets. Probe present iff any row is in those expanded
96
+ // buckets. Same false-positive-is-harmless reasoning as 011.
97
+ // Post-Sprint-61-T2-audit refinement.
98
+ '012_project_tag_re_taxonomy.sql':
99
+ "select 1 from memory_items where project in ('chopin-in-bohemia', 'chopin-scheduler', 'claimguard') limit 1",
100
+ '013_reclassify_uncertain.sql':
101
+ "select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='reclassified_by'",
102
+ '014_explicit_grants.sql':
103
+ "select 1 where has_table_privilege('service_role', 'public.memory_items', 'INSERT')",
104
+ '015_source_agent.sql':
105
+ "select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='source_agent'",
106
+ '016_mnestra_doctor_probes.sql':
107
+ "select 1 from pg_proc where proname='mnestra_doctor_vault_secret_exists'",
108
+ '017_memory_sessions_session_metadata.sql':
109
+ "select 1 from information_schema.columns where table_schema='public' and table_name='memory_sessions' and column_name='session_id'",
110
+ '018_rumen_processed_at.sql':
111
+ "select 1 from information_schema.columns where table_schema='public' and table_name='memory_sessions' and column_name='rumen_processed_at'",
112
+ '019_security_hardening.sql':
113
+ "select 1 from pg_proc p, unnest(coalesce(p.proconfig,'{}'::text[])) c where p.proname='memory_hybrid_search' and c like 'search_path=%' and c like '%extensions%'"
114
+ });
115
+
116
+ // Sprint 61 T2 — self-transactional detection.
117
+ //
118
+ // Bundled migrations 011 + 012 contain top-level `BEGIN;` and `COMMIT;`
119
+ // statements (011:75/217, 012:76/353). When the diff-apply loop wrapped
120
+ // these in its own outer BEGIN/COMMIT, the inner COMMIT closed the outer
121
+ // transaction prematurely and the subsequent `recordApplied` INSERT ran
122
+ // auto-committed — defeating the per-file atomicity contract. T4-CODEX
123
+ // audit-concern 2026-05-07 18:51 ET surfaced this.
124
+ //
125
+ // Detection: case-sensitive match for a line that is exactly `BEGIN;` or
126
+ // `COMMIT;` (top-level). PL/pgSQL anonymous block delimiters (`begin ... end`
127
+ // inside `do $$ ... $$`) use lowercase without trailing semicolon-on-its-
128
+ // own-line, so they don't match.
129
+ //
130
+ // Behavior for self-transactional migrations: SKIP the outer wrapper.
131
+ // Apply via pgRunner.applyFile (which sends the file as a single batched
132
+ // query, inner BEGIN/COMMIT handled by Postgres). Then INSERT the tracker
133
+ // row in a separate auto-commit. The tracker INSERT is recoverable on
134
+ // failure: 011/012 are idempotent (`WHERE project = 'chopin-nashville'`
135
+ // gates every UPDATE; re-running on an already-retagged install is a no-op),
136
+ // so a missing tracker row from a failed INSERT will be re-applied on the
137
+ // next pass and the INSERT retried. The brief explicitly notes this
138
+ // recovery shape under "out-of-T2 scope: rumen migration tracker."
139
+ function isSelfTransactional(sql) {
140
+ return /^[ \t]*(BEGIN|COMMIT)[ \t]*;[ \t]*$/m.test(sql);
141
+ }
142
+
32
143
  function listBundled(subdir) {
33
144
  const dir = path.join(SETUP_DIR, subdir);
34
145
  if (!fs.existsSync(dir)) return [];
@@ -127,11 +238,387 @@ function readFile(filepath) {
127
238
  return fs.readFileSync(filepath, 'utf-8');
128
239
  }
129
240
 
241
+ // ── Sprint 61 T2 — durable migration tracker + diff-and-apply ──────────────
242
+ //
243
+ // applyPendingMigrations(client, opts) replaces the per-wizard
244
+ // "apply every bundled migration" loop with a tracker-aware diff loop:
245
+ //
246
+ // 1. Try `SELECT filename, checksum FROM public.mnestra_migrations`.
247
+ // On 42P01 (relation does not exist), the project is pre-020 — bootstrap
248
+ // by applying 020 directly, then INSERT 020's own tracker row, then
249
+ // re-query.
250
+ // 2. Iterate bundled migrations 001..N in lex-filename order. For each
251
+ // bundled file:
252
+ // - Already in tracker: skip; if tracked checksum != bundled checksum,
253
+ // push to warnings[] (do NOT auto-overwrite).
254
+ // - Not in tracker AND probe says present: INSERT backfill row
255
+ // (applied_at = '1970-01-01T00:00:00Z', schema_version = 'backfill'),
256
+ // skip apply. (As of Sprint 61 T2 audit refinement, every bundled
257
+ // migration 001-019 has a non-null probe in MIGRATION_PROBES; the
258
+ // null-probe branch is preserved for forward-compatibility.)
259
+ // - Not in tracker AND probe absent (or null probe):
260
+ // * Self-transactional file (top-level BEGIN; / COMMIT;, currently
261
+ // 011/012): SKIP the outer wrapper. apply via pgRunner.applyFile
262
+ // (the file's own transaction control runs through Postgres).
263
+ // INSERT tracker row in a separate auto-commit. Tracker INSERT
264
+ // failure is recoverable: re-running applyPendingMigrations
265
+ // re-applies the migration (idempotent — the bundled self-tx
266
+ // files are gated on chopin-nashville rows, no-op on subsequent
267
+ // runs) and retries the tracker INSERT.
268
+ // * Non-self-transactional file: BEGIN, apply via
269
+ // pgRunner.applyFile, INSERT real tracker row, COMMIT. On
270
+ // error, ROLLBACK and halt — record errored summary, do not
271
+ // attempt subsequent migrations.
272
+ //
273
+ // Returns:
274
+ // {
275
+ // applied: string[] // filenames applied this pass
276
+ // skipped: string[] // filenames already in tracker
277
+ // backfilled: string[] // filenames probe-seeded this pass
278
+ // warnings: Array<{ file, trackedChecksum, bundledChecksum }>
279
+ // errored: null | { file, error }
280
+ // }
281
+ //
282
+ // Idempotent: a second invocation against an up-to-date project reports
283
+ // applied=[], backfilled=[], errored=null, and skipped=[...all bundled].
284
+ //
285
+ // Test injection (every dependency is replaceable):
286
+ // opts._migrations — module override for listMnestraMigrations / readFile
287
+ // (defaults to this module's own exports).
288
+ // opts._readFile — file-read shim (defaults to fs.readFileSync utf-8).
289
+ // opts._applyFile — pgRunner.applyFile shim (defaults to lazy-required
290
+ // ./pg-runner.applyFile to avoid pulling node-postgres
291
+ // at module-load time).
292
+ // opts._probes — MIGRATION_PROBES override (defaults to the constant).
293
+
294
+ function computeChecksum(content) {
295
+ return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
296
+ }
297
+
298
+ // Lazy-resolve pgRunner.applyFile so callers in test environments can avoid
299
+ // the `require('pg')` cost. Tests override via opts._applyFile.
300
+ function defaultApplyFile() {
301
+ const pgRunner = require('./pg-runner');
302
+ return (client, file) => pgRunner.applyFile(client, file);
303
+ }
304
+
305
+ // Returns Map<filename, checksum> when the tracker exists, null when the
306
+ // table is missing (PG error code 42P01 — relation does not exist). Any
307
+ // other error propagates.
308
+ async function loadAppliedSet(client) {
309
+ let res;
310
+ try {
311
+ res = await client.query(
312
+ `SELECT filename, checksum FROM ${TRACKER_TABLE}`
313
+ );
314
+ } catch (err) {
315
+ if (err && err.code === '42P01') return null;
316
+ throw err;
317
+ }
318
+ const map = new Map();
319
+ for (const row of (res && res.rows) || []) {
320
+ map.set(row.filename, row.checksum);
321
+ }
322
+ return map;
323
+ }
324
+
325
+ // Pre-020 bootstrap: the tracker doesn't exist yet, so apply 020 to create
326
+ // it, then INSERT 020's own tracker row. Caller re-queries the tracker
327
+ // afterwards to pick up the seeded row.
328
+ async function bootstrapTracker(client, files, applyFile, readFileImpl) {
329
+ const trackerPath = files.find(
330
+ (f) => path.basename(f) === TRACKER_FILE
331
+ );
332
+ if (!trackerPath) {
333
+ throw new Error(
334
+ `applyPendingMigrations: bundled ${TRACKER_FILE} not found in migration set — ` +
335
+ `the migration tracker cannot bootstrap. Re-publish the package or sync ` +
336
+ `the engram migrations directory into packages/server/src/setup/mnestra-migrations/.`
337
+ );
338
+ }
339
+ const result = await applyFile(client, trackerPath);
340
+ if (!result || result.ok !== true) {
341
+ const detail = (result && result.error) || 'apply returned not-ok with no error message';
342
+ throw new Error(
343
+ `applyPendingMigrations: failed to bootstrap tracker via ${TRACKER_FILE}: ${detail}`
344
+ );
345
+ }
346
+ const sql = readFileImpl(trackerPath);
347
+ const checksum = computeChecksum(sql);
348
+ await client.query(
349
+ `INSERT INTO ${TRACKER_TABLE} (filename, applied_at, checksum, schema_version) ` +
350
+ `VALUES ($1, now(), $2, $3) ` +
351
+ `ON CONFLICT (filename) DO UPDATE SET applied_at = EXCLUDED.applied_at, ` +
352
+ `checksum = EXCLUDED.checksum, schema_version = EXCLUDED.schema_version`,
353
+ [TRACKER_FILE, checksum, null]
354
+ );
355
+ }
356
+
357
+ // Run a probe SQL string and return whether the schema artifact is present.
358
+ // Null probes always return false. Probe-side errors (e.g. relation does
359
+ // not exist when probing into the live schema) are swallowed and degrade
360
+ // to "absent" — same posture as audit-upgrade.js::probeOne. The artifact's
361
+ // real apply path will surface any underlying error with full context.
362
+ async function probePresent(client, probeSql) {
363
+ if (!probeSql) return false;
364
+ try {
365
+ const res = await client.query(probeSql);
366
+ return Array.isArray(res && res.rows) && res.rows.length > 0;
367
+ } catch (_err) {
368
+ return false;
369
+ }
370
+ }
371
+
372
+ // Insert a backfill row for a migration whose probe came back present on
373
+ // a pre-020 install. applied_at is the epoch sentinel so audit queries
374
+ // can distinguish "applied at install" from "tracked from day one." The
375
+ // checksum is recorded too so future bundle drift can still be detected
376
+ // against backfilled rows.
377
+ async function recordBackfill(client, filename, checksum) {
378
+ await client.query(
379
+ `INSERT INTO ${TRACKER_TABLE} (filename, applied_at, checksum, schema_version) ` +
380
+ `VALUES ($1, $2::timestamptz, $3, $4) ` +
381
+ `ON CONFLICT (filename) DO NOTHING`,
382
+ [filename, '1970-01-01T00:00:00Z', checksum, 'backfill']
383
+ );
384
+ }
385
+
386
+ // Insert a real tracker row after a successful apply.
387
+ async function recordApplied(client, filename, checksum) {
388
+ await client.query(
389
+ `INSERT INTO ${TRACKER_TABLE} (filename, applied_at, checksum, schema_version) ` +
390
+ `VALUES ($1, now(), $2, $3) ` +
391
+ `ON CONFLICT (filename) DO UPDATE SET applied_at = EXCLUDED.applied_at, ` +
392
+ `checksum = EXCLUDED.checksum, schema_version = EXCLUDED.schema_version`,
393
+ [filename, checksum, null]
394
+ );
395
+ }
396
+
397
+ async function applyPendingMigrations(client, opts = {}) {
398
+ if (!client || typeof client.query !== 'function') {
399
+ throw new Error('applyPendingMigrations: client with .query() is required');
400
+ }
401
+
402
+ const _migrations = opts._migrations || module.exports;
403
+ const _readFile = opts._readFile || ((p) => fs.readFileSync(p, 'utf-8'));
404
+ const _applyFile = opts._applyFile || defaultApplyFile();
405
+ const _probes = opts._probes || MIGRATION_PROBES;
406
+
407
+ const files = _migrations.listMnestraMigrations();
408
+ if (!files || files.length === 0) {
409
+ throw new Error(
410
+ 'applyPendingMigrations: no Mnestra migrations bundled — TermDeck install looks corrupted.'
411
+ );
412
+ }
413
+
414
+ const summary = {
415
+ applied: [],
416
+ skipped: [],
417
+ backfilled: [],
418
+ warnings: [],
419
+ errored: null
420
+ };
421
+
422
+ // Step 1: load applied-set (or bootstrap if missing).
423
+ let applied = await loadAppliedSet(client);
424
+ let bootstrapped = false;
425
+ if (applied === null) {
426
+ // Pre-020 — apply 020 + INSERT row.
427
+ await bootstrapTracker(client, files, _applyFile, _readFile);
428
+ bootstrapped = true;
429
+ summary.applied.push(TRACKER_FILE);
430
+ applied = await loadAppliedSet(client);
431
+ if (applied === null) {
432
+ throw new Error(
433
+ 'applyPendingMigrations: tracker still missing after bootstrap — ' +
434
+ 'check that 020_migration_tracking.sql actually created public.mnestra_migrations.'
435
+ );
436
+ }
437
+ }
438
+
439
+ // Step 2: iterate bundled files in lex order.
440
+ for (const file of files) {
441
+ const base = path.basename(file);
442
+
443
+ // Tracker file: bootstrap already accounted for it in summary.applied.
444
+ // On a non-bootstrap pass (post-020 install where 020 is in the tracker),
445
+ // record as skipped. On any other state (tracker present but somehow
446
+ // missing 020), fall through to the normal apply path so the diff loop
447
+ // can re-record it.
448
+ if (base === TRACKER_FILE) {
449
+ if (bootstrapped) {
450
+ // Already in summary.applied via bootstrap; do not duplicate.
451
+ continue;
452
+ }
453
+ if (applied.has(TRACKER_FILE)) {
454
+ summary.skipped.push(base);
455
+ continue;
456
+ }
457
+ // Defensive fall-through: tracker exists (loadAppliedSet succeeded) but
458
+ // doesn't have 020's row. Apply path below will re-record it.
459
+ }
460
+
461
+ let sql;
462
+ try {
463
+ sql = _readFile(file);
464
+ } catch (err) {
465
+ summary.errored = {
466
+ file: base,
467
+ error: err && err.message ? err.message : String(err)
468
+ };
469
+ return summary;
470
+ }
471
+ const checksum = computeChecksum(sql);
472
+
473
+ if (applied.has(base)) {
474
+ // Already applied — checksum-drift guard.
475
+ const trackedChecksum = applied.get(base);
476
+ if (trackedChecksum && trackedChecksum !== checksum) {
477
+ summary.warnings.push({
478
+ file: base,
479
+ trackedChecksum,
480
+ bundledChecksum: checksum
481
+ });
482
+ }
483
+ summary.skipped.push(base);
484
+ continue;
485
+ }
486
+
487
+ // Not applied. Try probe-backfill (only meaningful on pre-020 installs
488
+ // that just bootstrapped, but cheap to evaluate on every pass; an
489
+ // already-tracked migration short-circuits above before reaching here).
490
+ const probeSql = Object.prototype.hasOwnProperty.call(_probes, base)
491
+ ? _probes[base]
492
+ : null;
493
+ if (probeSql) {
494
+ const present = await probePresent(client, probeSql);
495
+ if (present) {
496
+ try {
497
+ await recordBackfill(client, base, checksum);
498
+ } catch (err) {
499
+ summary.errored = {
500
+ file: base,
501
+ error: `backfill INSERT failed: ${err && err.message ? err.message : String(err)}`
502
+ };
503
+ return summary;
504
+ }
505
+ summary.backfilled.push(base);
506
+ applied.set(base, checksum);
507
+ continue;
508
+ }
509
+ }
510
+
511
+ // Genuinely needs apply. Self-transactional migrations (011, 012) ship
512
+ // top-level BEGIN/COMMIT in their bodies — see isSelfTransactional + the
513
+ // header comment near its definition. For those, skip the outer wrapper
514
+ // and rely on the file's own transaction control + idempotency for
515
+ // recovery on tracker INSERT failure. For everything else, wrap in
516
+ // outer BEGIN/COMMIT for per-file atomicity (apply + tracker row commit
517
+ // or roll back together).
518
+ const selfTx = isSelfTransactional(sql);
519
+
520
+ if (selfTx) {
521
+ let applyResult;
522
+ try {
523
+ applyResult = await _applyFile(client, file);
524
+ } catch (err) {
525
+ summary.errored = {
526
+ file: base,
527
+ error: err && err.message ? err.message : String(err)
528
+ };
529
+ return summary;
530
+ }
531
+ if (!applyResult || applyResult.ok !== true) {
532
+ summary.errored = {
533
+ file: base,
534
+ error: (applyResult && applyResult.error) || 'apply returned not-ok with no error message'
535
+ };
536
+ return summary;
537
+ }
538
+ try {
539
+ await recordApplied(client, base, checksum);
540
+ } catch (err) {
541
+ summary.errored = {
542
+ file: base,
543
+ error: `tracker INSERT failed (self-transactional ${base} applied; re-run will replay it idempotently and retry the tracker insert): ${err && err.message ? err.message : String(err)}`
544
+ };
545
+ return summary;
546
+ }
547
+ summary.applied.push(base);
548
+ applied.set(base, checksum);
549
+ continue;
550
+ }
551
+
552
+ // Non-self-transactional path: outer BEGIN/COMMIT wrapper.
553
+ try {
554
+ await client.query('BEGIN');
555
+ } catch (err) {
556
+ summary.errored = {
557
+ file: base,
558
+ error: `BEGIN failed: ${err && err.message ? err.message : String(err)}`
559
+ };
560
+ return summary;
561
+ }
562
+
563
+ let applyResult;
564
+ try {
565
+ applyResult = await _applyFile(client, file);
566
+ } catch (err) {
567
+ try { await client.query('ROLLBACK'); } catch (_e) { /* best-effort */ }
568
+ summary.errored = {
569
+ file: base,
570
+ error: err && err.message ? err.message : String(err)
571
+ };
572
+ return summary;
573
+ }
574
+
575
+ if (!applyResult || applyResult.ok !== true) {
576
+ try { await client.query('ROLLBACK'); } catch (_e) { /* best-effort */ }
577
+ summary.errored = {
578
+ file: base,
579
+ error: (applyResult && applyResult.error) || 'apply returned not-ok with no error message'
580
+ };
581
+ return summary;
582
+ }
583
+
584
+ try {
585
+ await recordApplied(client, base, checksum);
586
+ await client.query('COMMIT');
587
+ } catch (err) {
588
+ try { await client.query('ROLLBACK'); } catch (_e) { /* best-effort */ }
589
+ summary.errored = {
590
+ file: base,
591
+ error: `tracker INSERT failed: ${err && err.message ? err.message : String(err)}`
592
+ };
593
+ return summary;
594
+ }
595
+
596
+ summary.applied.push(base);
597
+ applied.set(base, checksum);
598
+ }
599
+
600
+ return summary;
601
+ }
602
+
130
603
  module.exports = {
131
604
  listMnestraMigrations,
132
605
  listRumenMigrations,
133
606
  rumenFunctionsRoot,
134
607
  listRumenFunctions,
135
608
  rumenFunctionDir,
136
- readFile
609
+ readFile,
610
+ // Sprint 61 T2 — migration tracker.
611
+ applyPendingMigrations,
612
+ MIGRATION_PROBES,
613
+ TRACKER_TABLE,
614
+ TRACKER_FILE,
615
+ // Test surface — kept exported so tests/migration-tracker.test.js can pin
616
+ // each helper without a live pg client.
617
+ _computeChecksum: computeChecksum,
618
+ _loadAppliedSet: loadAppliedSet,
619
+ _bootstrapTracker: bootstrapTracker,
620
+ _probePresent: probePresent,
621
+ _recordBackfill: recordBackfill,
622
+ _recordApplied: recordApplied,
623
+ _isSelfTransactional: isSelfTransactional
137
624
  };
@@ -79,8 +79,8 @@ create index if not exists memory_relationships_target_idx on memory_relationshi
79
79
  -- ── match_memories helper RPC ─────────────────────────────────────────────
80
80
  -- Used by remember.ts (dedup) and consolidate.ts (cluster seeding).
81
81
  --
82
- -- Sprint 52.1 — signature-drift guard. On long-lived v0.6.x-era installs
83
- -- (Joshua's petvetbid, Brad's jizzard-brain), match_memories was created by
82
+ -- Sprint 52.1 — signature-drift guard. On long-lived v0.6.x-era installs,
83
+ -- match_memories was created by
84
84
  -- a prior Mnestra version with a different RETURN-table column shape:
85
85
  -- (id, content, metadata, source_type, category, project, created_at, similarity)
86
86
  -- vs the canonical:
@@ -16,7 +16,7 @@
16
16
  -- Sprint 51.9 — signature-drift guard. Same Class A pattern Sprint 52.1
17
17
  -- closed for `match_memories` (mig 001:81-95). Codex T4 surfaced the cousin
18
18
  -- 2026-05-04 14:42 ET during Sprint 51.5b dogfood: long-lived v0.6.x-era
19
- -- installs (Joshua's petvetbid, likely Brad's jizzard-brain) ALSO have a
19
+ -- installs ALSO have a
20
20
  -- 10-arg drift overload of `memory_hybrid_search` coexisting with the
21
21
  -- canonical 8-arg signature. The drift overload carries the never-shipped
22
22
  -- `recency_weight`/`decay_days` parameters from a pre-canonical Mnestra
@@ -8,7 +8,7 @@
8
8
  --
9
9
  -- Idempotent: safe to re-run.
10
10
  --
11
- -- Pre-existing state (verified against petvetbid 2026-04-27 17:25 ET):
11
+ -- Pre-existing state (verified against the reference Mnestra project 2026-04-27 17:25 ET):
12
12
  -- memory_relationships has 749 live edges. The migration adds nullable
13
13
  -- columns and a wider CHECK; no existing row violates the new constraint.
14
14
  --
@@ -36,7 +36,10 @@
36
36
  -- 1. termdeck / mnestra — keywords: termdeck, mnestra, "4+1 sprint"
37
37
  -- 2. rumen — keyword: rumen
38
38
  -- 3. podium — keyword: podium
39
- -- 4. pvb — keywords: PVB, petvetbid, pet vet bid
39
+ -- 4. pvb — keywords: PVB, pet vet bid (and the
40
+ -- legacy single-word identifier matched
41
+ -- by the load-bearing classifier on
42
+ -- line 156)
40
43
  -- 5. dor / openclaw — TIGHTENED:
41
44
  -- word-boundary uppercase DOR (rules out
42
45
  -- "dormant", "vendored", "indoor", etc.),
@@ -49,7 +52,8 @@
49
52
  -- rumen: 92 rows, all 6 sampled were true positives.
50
53
  -- podium: 58 rows, all 6 sampled were true positives.
51
54
  -- pvb: 7 rows, 1 of those overlaps with mnestra ("Mnestra
52
- -- repo … petvetbid project") and gets claimed by bucket 1.
55
+ -- repo … legacy single-word project name") and gets
56
+ -- claimed by bucket 1.
53
57
  -- dor (tightened): 3 rows after tightening from 6 — the original
54
58
  -- `%dor%` ILIKE pattern caught false positives like
55
59
  -- "dormant", "vendored". Final 3 rows are all true
@@ -143,7 +147,7 @@ BEGIN
143
147
  END $$;
144
148
 
145
149
  -- ============================================================
146
- -- BUCKET 4 — PVB (case-insensitive PVB / petvetbid markers)
150
+ -- BUCKET 4 — PVB (case-insensitive content markers see code below)
147
151
  -- ============================================================
148
152
  DO $$
149
153
  DECLARE
@@ -49,7 +49,7 @@
49
49
  -- by Joshua; case-sensitive word-boundary token
50
50
  -- avoids matching unrelated "[maestro]" log
51
51
  -- prefixes)
52
- -- 6. pvb — PVB, petvetbid, "pet vet bid"
52
+ -- 6. pvb — PVB, "pet vet bid"
53
53
  -- 7. claimguard — claimguard, gorgias-ticket-monitor,
54
54
  -- "gorgias ticket monitor"
55
55
  -- 8. dor — \mDOR\M, /DOR/, ~/Documents/DOR, dor.config,
@@ -234,7 +234,7 @@ BEGIN
234
234
  END $$;
235
235
 
236
236
  -- ============================================================
237
- -- BUCKET 6 — pvb (case-insensitive PVB / petvetbid markers)
237
+ -- BUCKET 6 — pvb (case-insensitive content markers see code below)
238
238
  --
239
239
  -- Same pattern as 011 bucket 4. PVB is small in the chopin-nashville bucket
240
240
  -- (Sprint 39 dry-run found 7 rows; live apply landed 3 because bucket 1
@@ -17,9 +17,9 @@
17
17
  -- PostgREST checks table-level privileges before evaluating RLS, so
18
18
  -- service_role's bypassrls attribute does not help.
19
19
  --
20
- -- Reported and root-caused by Brad Heath 2026-04-28 against project
21
- -- ref rrzkceirgciiqgeefvbe; fix verified end-to-end on his install
22
- -- before being upstreamed here.
20
+ -- Reported and root-caused by Brad Heath 2026-04-28 against his Mnestra
21
+ -- project; fix verified end-to-end on his install before being upstreamed
22
+ -- here.
23
23
  --
24
24
  -- This migration is idempotent and safe on greenfield projects where
25
25
  -- the auto-grant default already fired (the GRANTs become no-ops).
@@ -27,8 +27,8 @@
27
27
  -- their grants are now wrapped in a do$$ guard that only emits them
28
28
  -- when `pg_cron` is enabled. The doctor's cron-related probes return
29
29
  -- the existing `unknown` band (Sprint 51.5 T2 already established it)
30
- -- when the wrappers don't exist — graceful degradation. Petvetbid +
31
- -- jizzard-brain are unaffected because both have rumen installed,
30
+ -- when the wrappers don't exist — graceful degradation. Existing
31
+ -- rumen-bearing installs are unaffected because both have rumen installed,
32
32
  -- which enables pg_cron via rumen's mig 002. Closes the
33
33
  -- mnestra-only-no-rumen fresh-install path.
34
34
 
@@ -103,7 +103,7 @@ grant execute on function mnestra_doctor_vault_secret_exists(text) to ser
103
103
  --
104
104
  -- Idempotent: do$$ runs every replay; CREATE OR REPLACE keeps the
105
105
  -- function definitions in sync if pg_cron later gets enabled and the
106
- -- migration re-runs. Existing installs (petvetbid, jizzard-brain) have
106
+ -- migration re-runs. Existing installs typically have
107
107
  -- pg_cron from Rumen's install path and emit these unconditionally.
108
108
 
109
109
  do $cron_guard$
@@ -3,7 +3,7 @@
3
3
  -- Sprint 51.6 T3 (TermDeck v1.0.2 hotfix wave). Brings the canonical engram
4
4
  -- memory_sessions schema in line with the rag-system writer's column set so
5
5
  -- TermDeck's bundled session-end hook can write a uniform shape on both
6
- -- fresh-canonical installs and Joshua's daily-driver petvetbid (where the
6
+ -- fresh-canonical installs and Joshua's daily-driver the reference Mnestra project (where the
7
7
  -- columns were already added by hand when rag-system bootstrap ran).
8
8
  --
9
9
  -- Why: until v1.0.2 the bundled hook only wrote memory_items. The actual
@@ -17,7 +17,7 @@
17
17
  -- the schema it expects exists everywhere.
18
18
  --
19
19
  -- Idempotent — safe on:
20
- -- 1. petvetbid (where these columns are already present from hand-applied
20
+ -- 1. the reference Mnestra project (where these columns are already present from hand-applied
21
21
  -- DDL Joshua ran when setting up rag-system; the IF NOT EXISTS guards
22
22
  -- no-op on every column).
23
23
  -- 2. Fresh canonical installs that ran migrations 001-016 only (the canonical
@@ -26,11 +26,11 @@
26
26
  --
27
27
  -- The unique constraint on session_id is wrapped in a do-block because
28
28
  -- ADD CONSTRAINT does not support IF NOT EXISTS in PostgreSQL. Joshua's
29
- -- petvetbid already has the constraint as memory_sessions_session_id_key
29
+ -- the reference Mnestra project already has the constraint as memory_sessions_session_id_key
30
30
  -- (auto-named by the rag-system bootstrap); this block detects that name
31
31
  -- and skips re-adding.
32
32
  --
33
- -- session_id is added NULLABLE on canonical installs even though petvetbid's
33
+ -- session_id is added NULLABLE on canonical installs even though the reference Mnestra project's
34
34
  -- existing constraint is NOT NULL. Adding NOT NULL via ALTER TABLE on a
35
35
  -- table with existing rows would fail; the bundled hook always supplies
36
36
  -- session_id at write time, so nullability is non-blocking. A future sprint
@@ -56,7 +56,7 @@ alter table public.memory_sessions
56
56
  -- Unique constraint on session_id. Skip if any unique constraint on
57
57
  -- (session_id) is already in place — covers both the canonical name
58
58
  -- memory_sessions_session_id_key and any alternate name from a manual
59
- -- ALTER TABLE Joshua may have run on petvetbid.
59
+ -- ALTER TABLE Joshua may have run on the reference Mnestra project.
60
60
  do $$
61
61
  declare
62
62
  has_unique boolean;
@@ -19,7 +19,7 @@
19
19
  -- 1. Joshua's daily-driver (pre-Sprint-53; column will be added with
20
20
  -- every existing memory_sessions row at NULL → all become candidates
21
21
  -- on the first post-deploy tick, which is the desired bootstrap).
22
- -- 2. Brad's jizzard-brain (Linux SSH; same shape, same null-bootstrap).
22
+ -- 2. Linux SSH installs (same shape, same null-bootstrap).
23
23
  -- 3. Fresh canonical installs (post-mig-017 schema; column added on
24
24
  -- first run, no rows to backfill).
25
25
  -- 4. Re-runs (ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS).
@@ -0,0 +1,190 @@
1
+ -- Mnestra v0.4.6 — security hardening (revised from 0.4.4 / 0.4.5).
2
+ --
3
+ -- Source: external Supabase-advisor sweep by Brad Heath / Nacho Money LLC,
4
+ -- 2026-05-06. See docs/SECURITY-HARDENING-2026-05-06.md for the full flag
5
+ -- and root-cause analysis. The standing rule lives in the global Claude
6
+ -- Code instructions: "MANDATORY: Supabase RLS + privilege hygiene".
7
+ --
8
+ -- Two corrections folded into this revision:
9
+ --
10
+ -- A. **search_path must include `extensions`.** The 0.4.4/0.4.5 version of
11
+ -- this migration set search_path = public, pg_catalog on the memory_*
12
+ -- RPCs. Supabase >= 2024 installs pgvector in the `extensions` schema,
13
+ -- so the `<=>` cosine-distance operator becomes unreachable from those
14
+ -- RPCs after the alter — semantic recall fails with "operator does not
15
+ -- exist: extensions.vector <=> extensions.vector". Confirmed live
16
+ -- against the reference Mnestra project on 2026-05-06; fixed by
17
+ -- including `extensions` in search_path.
18
+ --
19
+ -- B. **Schema-generation-aware.** Some Mnestra installs are on the older
20
+ -- "memory_items-only" generation — they have memory_items /
21
+ -- memory_relationships / memory_sessions + the 6 memory_* RPCs, but
22
+ -- NOT the layered-memory tables (mnestra_session_memory,
23
+ -- mnestra_developer_memory, mnestra_project_memory, mnestra_commands)
24
+ -- and NOT the mnestra_doctor_* SECURITY DEFINER probes. The 0.4.4 / 0.4.5
25
+ -- migration body assumed the layered shape and threw "relation does
26
+ -- not exist" / "function does not exist" mid-migration on older
27
+ -- installs. Brad caught this on three of his projects (Structural,
28
+ -- aetheria-payroll, aetheria-phase1) and worked around with a
29
+ -- signature-agnostic DO-block subset.
30
+ --
31
+ -- This revision restructures every section as defensive lookups
32
+ -- against pg_class / pg_proc / pg_views, so each statement only fires
33
+ -- when its target exists. The migration runs cleanly on:
34
+ -- - layered-memory generation (Josh's reference project): full fix
35
+ -- - memory_items-only generation (Brad's three projects): function
36
+ -- hardening only; mnestra_*-targeting statements are skipped
37
+ -- - mixed generation: each statement applies to whatever exists
38
+ --
39
+ -- Closes four hole classes (where applicable to the install's schema
40
+ -- generation):
41
+ --
42
+ -- 1. Permissive PUBLIC INSERT RLS on mnestra_{commands,developer_memory,
43
+ -- project_memory,session_memory}. Created by Supabase Studio's
44
+ -- "Allow insert for all" default-policy template at table-creation
45
+ -- time. Anyone with the project's anon key could write directly to
46
+ -- memory tables, poisoning the corpus or session-id-squatting.
47
+ --
48
+ -- 2. PUBLIC EXECUTE on every Mnestra function. Postgres defaults
49
+ -- function EXECUTE to PUBLIC; the explicit `grant ... to service_role`
50
+ -- in earlier migrations is additive, not exclusive.
51
+ --
52
+ -- 3. Mutable search_path on memory_* and mnestra_doctor_* functions
53
+ -- (Supabase lint 0011).
54
+ --
55
+ -- 4. mnestra_recent_activity SECURITY DEFINER view (Supabase lint 0010)
56
+ -- with anon+authenticated SELECT.
57
+ --
58
+ -- Backward-compat: zero behavior change for any Mnestra installation that
59
+ -- follows the documented architecture (service-role writes via MCP server).
60
+ -- service_role keeps EXECUTE on every function and SELECT on the view.
61
+ --
62
+ -- Idempotent: every section guards on object existence and uses
63
+ -- IF EXISTS / signature-agnostic patterns. Re-running this migration is
64
+ -- safe and is in fact the recommended way to upgrade a 0.4.4/0.4.5 install
65
+ -- to pick up the search_path fix.
66
+
67
+ -- ====================================================================
68
+ -- 1. Drop permissive PUBLIC INSERT policies on mnestra_* tables, when
69
+ -- those tables exist on this install. Skipped silently on older
70
+ -- memory_items-only schema generation.
71
+ -- ====================================================================
72
+
73
+ do $$
74
+ declare
75
+ tbl text;
76
+ tables text[] := array[
77
+ 'mnestra_commands',
78
+ 'mnestra_developer_memory',
79
+ 'mnestra_project_memory',
80
+ 'mnestra_session_memory'
81
+ ];
82
+ begin
83
+ foreach tbl in array tables loop
84
+ if to_regclass(format('public.%I', tbl)) is not null then
85
+ execute format('drop policy if exists "Allow insert for all" on public.%I', tbl);
86
+ end if;
87
+ end loop;
88
+ end $$;
89
+
90
+ -- ====================================================================
91
+ -- 2 + 3. Revoke EXECUTE from public + anon + authenticated AND pin
92
+ -- search_path on every Mnestra function. Signature-agnostic — iterates
93
+ -- pg_proc to apply to whatever functions exist on this install. Covers
94
+ -- memory_*, match_memories, expand_memory_neighborhood, and
95
+ -- mnestra_doctor_*.
96
+ --
97
+ -- search_path includes `extensions` for the pgvector operator and
98
+ -- pg_catalog for built-ins; doctor functions don't use vectors but the
99
+ -- inclusion is harmless and keeps every Mnestra function uniform.
100
+ -- ====================================================================
101
+
102
+ do $$
103
+ declare
104
+ fn record;
105
+ sig text;
106
+ begin
107
+ for fn in
108
+ select n.nspname,
109
+ p.proname,
110
+ pg_get_function_identity_arguments(p.oid) as ident_args
111
+ from pg_proc p
112
+ join pg_namespace n on n.oid = p.pronamespace
113
+ where n.nspname = 'public'
114
+ and p.prokind = 'f'
115
+ and (
116
+ p.proname like 'memory_%'
117
+ or p.proname in ('match_memories', 'expand_memory_neighborhood')
118
+ or p.proname like 'mnestra_doctor_%'
119
+ )
120
+ loop
121
+ sig := format('%I.%I(%s)', fn.nspname, fn.proname, fn.ident_args);
122
+ execute format('revoke execute on function %s from public, anon, authenticated', sig);
123
+ execute format('alter function %s set search_path = public, extensions, pg_catalog', sig);
124
+ -- service_role keeps EXECUTE; the revoke above only targets public/anon/authenticated.
125
+ end loop;
126
+ end $$;
127
+
128
+ -- ====================================================================
129
+ -- 4. Recreate mnestra_recent_activity view without SECURITY DEFINER and
130
+ -- restrict SELECT to service_role. Skipped silently if the view doesn't
131
+ -- exist or any of the three underlying tables are missing.
132
+ -- ====================================================================
133
+
134
+ do $$
135
+ begin
136
+ if to_regclass('public.mnestra_session_memory') is not null
137
+ and to_regclass('public.mnestra_project_memory') is not null
138
+ and to_regclass('public.mnestra_developer_memory') is not null
139
+ then
140
+ drop view if exists public.mnestra_recent_activity;
141
+
142
+ execute $view$
143
+ create view public.mnestra_recent_activity as
144
+ select 'session'::text as layer, id, session_id, event_type, payload, project, developer_id, "timestamp", created_at from public.mnestra_session_memory
145
+ union all
146
+ select 'project'::text as layer, id, session_id, event_type, payload, project, developer_id, "timestamp", created_at from public.mnestra_project_memory
147
+ union all
148
+ select 'developer'::text as layer, id, session_id, event_type, payload, project, developer_id, "timestamp", created_at from public.mnestra_developer_memory
149
+ order by 8 desc
150
+ limit 100
151
+ $view$;
152
+
153
+ revoke all on public.mnestra_recent_activity from public, anon, authenticated;
154
+ grant select on public.mnestra_recent_activity to service_role;
155
+ end if;
156
+ end $$;
157
+
158
+ -- ====================================================================
159
+ -- Post-apply verification (run separately in Studio SQL editor):
160
+ --
161
+ -- -- Should return zero rows:
162
+ -- with bad_policies as (
163
+ -- select policyname from pg_policies
164
+ -- where schemaname='public' and tablename like 'mnestra_%'
165
+ -- and ('public' = any(roles) or roles = '{}')
166
+ -- and (with_check='true' or qual='true')
167
+ -- ),
168
+ -- public_exec as (
169
+ -- select p.proname from pg_proc p join pg_namespace n on n.oid=p.pronamespace
170
+ -- where n.nspname='public'
171
+ -- and (p.proname like 'mnestra_doctor_%' or p.proname like 'memory_%'
172
+ -- or p.proname in ('match_memories','expand_memory_neighborhood'))
173
+ -- and has_function_privilege('public', p.oid, 'EXECUTE')
174
+ -- ),
175
+ -- mutable_path as (
176
+ -- select p.proname from pg_proc p join pg_namespace n on n.oid=p.pronamespace
177
+ -- where n.nspname='public' and p.prokind='f'
178
+ -- and (p.proname like 'memory_%' or p.proname like 'mnestra_doctor_%')
179
+ -- and not exists (
180
+ -- select 1 from unnest(coalesce(p.proconfig,'{}'::text[])) c
181
+ -- where c like 'search_path=%'
182
+ -- )
183
+ -- )
184
+ -- select 'BAD_POLICY' as kind, policyname as detail from bad_policies
185
+ -- union all select 'PUBLIC_EXEC', proname from public_exec
186
+ -- union all select 'MUTABLE_SEARCH_PATH', proname from mutable_path;
187
+ --
188
+ -- Verified zero rows on the reference Mnestra project on 2026-05-06.
189
+ -- Smoke test: select count(*) from memory_hybrid_search('smoke', array_fill(0::real, ARRAY[1536])::vector, 1) → 1 row, no operator-resolution error.
190
+ -- ====================================================================
@@ -0,0 +1,57 @@
1
+ -- 020_migration_tracking.sql
2
+ -- Adds durable tracking of which Mnestra migrations have been applied to a project,
3
+ -- so upgrade paths can compute (bundled - applied) and apply only the diff.
4
+ -- Sprint 61 (TermDeck Convergence Keystone), Mnestra 0.4.7.
5
+ --
6
+ -- Why this exists: prior to 020, the mnestra/rumen wizards re-applied every
7
+ -- bundled migration on every invocation, relying on per-migration
8
+ -- `IF NOT EXISTS` / `CREATE OR REPLACE` idempotency to avoid duplicate work.
9
+ -- That works for a fresh install but doesn't tell the wizard which migrations
10
+ -- the live database is missing — so a user running `npm install -g @latest`
11
+ -- against an existing project gets the new package files without any way to
12
+ -- detect schema drift. Class A (schema drift on package upgrade) per
13
+ -- termdeck/docs/INSTALLER-PITFALLS.md.
14
+ --
15
+ -- Shape:
16
+ -- - `filename` text PK — the bundled migration filename, e.g.
17
+ -- `015_source_agent.sql`. PK because each
18
+ -- bundled file applies at most once.
19
+ -- - `applied_at` timestamptz — wall-clock time of apply. Backfilled
20
+ -- rows (rows seeded by the post-020 backfill
21
+ -- probe for migrations applied pre-020) use
22
+ -- epoch (1970-01-01T00:00:00Z) as a sentinel.
23
+ -- - `checksum` text — SHA-256 of the bundled file content at apply
24
+ -- time. Lets future runs detect bundle drift
25
+ -- without auto-overwriting the live schema.
26
+ -- - `schema_version` text — optional free-text marker. Backfill rows use
27
+ -- the literal `'backfill'` so audit queries
28
+ -- can distinguish them.
29
+ --
30
+ -- RLS posture: ENABLE ROW LEVEL SECURITY + REVOKE ALL FROM PUBLIC. No
31
+ -- policies are intentional — anon and authenticated have NO access, full
32
+ -- stop. service_role bypasses RLS in Postgres by default, which is the only
33
+ -- caller that should ever touch this table (the migration runner connects
34
+ -- via DATABASE_URL using service-role credentials).
35
+ --
36
+ -- Idempotent: re-applying this migration on a project that already has the
37
+ -- table is a no-op (CREATE TABLE IF NOT EXISTS, ALTER TABLE ... ENABLE RLS
38
+ -- is a no-op when already enabled, REVOKE/GRANT are idempotent).
39
+
40
+ CREATE TABLE IF NOT EXISTS public.mnestra_migrations (
41
+ filename text PRIMARY KEY,
42
+ applied_at timestamptz NOT NULL DEFAULT now(),
43
+ checksum text NOT NULL,
44
+ schema_version text
45
+ );
46
+
47
+ ALTER TABLE public.mnestra_migrations ENABLE ROW LEVEL SECURITY;
48
+
49
+ -- Service-role-only. anon and authenticated have NO access (no policies = denied by RLS).
50
+ -- Service role bypasses RLS by default; the table is queried only by the migration runner
51
+ -- which uses the service-role key.
52
+
53
+ REVOKE ALL ON public.mnestra_migrations FROM PUBLIC;
54
+ GRANT ALL ON public.mnestra_migrations TO service_role;
55
+
56
+ COMMENT ON TABLE public.mnestra_migrations IS
57
+ 'Tracking table for applied Mnestra migrations. service_role-only; RLS-on; no policies.';
@@ -52,40 +52,10 @@ serve(async (_req: Request) => {
52
52
 
53
53
  const pool = createPoolFromUrl(url);
54
54
 
55
- // Sprint 56 (T3 Cell #1 backlog catch-up) — env-var overrides for one-off
56
- // historic processing. Set via `supabase secrets set`:
57
- // RUMEN_LOOKBACK_HOURS_OVERRIDE=2880 (120 days; bypasses default 72h)
58
- // RUMEN_MAX_SESSIONS_OVERRIDE=300 (processes whole 289-session
59
- // backlog in one tick rather than
60
- // 28 ticks at default 10 each)
61
- // After the catch-up settles, unset both with
62
- // `supabase secrets unset RUMEN_LOOKBACK_HOURS_OVERRIDE
63
- // RUMEN_MAX_SESSIONS_OVERRIDE`
64
- // and the function reverts to the rumen-package defaults (72h / 10 sessions).
65
- // Both gates fail closed: invalid integer string → ignored, default used.
66
- const lookbackOverrideRaw = Deno.env.get('RUMEN_LOOKBACK_HOURS_OVERRIDE');
67
- const maxSessionsOverrideRaw = Deno.env.get('RUMEN_MAX_SESSIONS_OVERRIDE');
68
- const lookbackOverride = lookbackOverrideRaw && /^\d+$/.test(lookbackOverrideRaw)
69
- ? parseInt(lookbackOverrideRaw, 10)
70
- : undefined;
71
- const maxSessionsOverride = maxSessionsOverrideRaw && /^\d+$/.test(maxSessionsOverrideRaw)
72
- ? parseInt(maxSessionsOverrideRaw, 10)
73
- : undefined;
74
- if (lookbackOverride !== undefined || maxSessionsOverride !== undefined) {
75
- console.log(
76
- '[rumen] override active: lookbackHours=' +
77
- (lookbackOverride ?? 'default') +
78
- ' maxSessions=' +
79
- (maxSessionsOverride ?? 'default'),
80
- );
81
- }
82
-
83
55
  try {
84
56
  console.log('[rumen] edge function tick starting');
85
57
  const summary = await runRumenJob(pool, {
86
58
  triggeredBy: 'schedule',
87
- ...(lookbackOverride !== undefined ? { lookbackHours: lookbackOverride } : {}),
88
- ...(maxSessionsOverride !== undefined ? { maxSessions: maxSessionsOverride } : {}),
89
59
  });
90
60
  console.log(
91
61
  '[rumen] edge function tick complete job_id=' +