@inflexa-ai/harness 0.41.0 → 0.41.2

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 (72) hide show
  1. package/dist/contracts/report-blocks.d.ts +10 -10
  2. package/dist/contracts/report-reference.d.ts +4 -4
  3. package/dist/memory/message-backfill.d.ts +8 -2
  4. package/dist/prompts/briefing.d.ts +28 -1
  5. package/dist/prompts/briefing.js +44 -1
  6. package/dist/prompts/compaction.d.ts +2 -2
  7. package/dist/prompts/compaction.js +9 -5
  8. package/dist/prompts/conversation.d.ts +1 -1
  9. package/dist/prompts/conversation.js +12 -0
  10. package/dist/prompts/sandbox-standards.d.ts +1 -1
  11. package/dist/prompts/sandbox-standards.js +4 -0
  12. package/dist/report-model/authoring-grammar.d.ts +4 -4
  13. package/dist/runtime/assemble.d.ts +4 -2
  14. package/dist/runtime/assemble.js +8 -1
  15. package/dist/runtime/boot.d.ts +2 -2
  16. package/dist/runtime/boot.js +3 -3
  17. package/dist/schemas/file-metadata.js +9 -9
  18. package/dist/schemas/plan-schemas.d.ts +3 -3
  19. package/dist/state/init.d.ts +7 -6
  20. package/dist/state/init.js +51 -672
  21. package/dist/state/migrations/20260927120000_baseline.d.ts +11 -0
  22. package/dist/state/migrations/20260927120000_baseline.js +675 -0
  23. package/dist/state/migrations/index.d.ts +10 -0
  24. package/dist/state/migrations/index.js +13 -0
  25. package/dist/tools/ad-hoc-router.js +9 -4
  26. package/dist/tools/bio/chembl.js +8 -5
  27. package/dist/tools/bio/comptox.js +4 -4
  28. package/dist/tools/bio/drug-gene-interactions.js +5 -4
  29. package/dist/tools/bio/gene-disease-evidence.js +4 -4
  30. package/dist/tools/bio/lookup-annotation.js +7 -3
  31. package/dist/tools/bio/opentargets.js +8 -6
  32. package/dist/tools/bio/pubchem.js +4 -2
  33. package/dist/tools/bio/pubmed.d.ts +1 -1
  34. package/dist/tools/bio/pubmed.js +10 -6
  35. package/dist/tools/bio/search-clinical-trials.js +2 -1
  36. package/dist/tools/bio/search-gene.js +2 -1
  37. package/dist/tools/bio/search-interactions.js +8 -4
  38. package/dist/tools/bio/target-safety.js +2 -1
  39. package/dist/tools/display/show-user.js +7 -5
  40. package/dist/tools/execute-analysis.js +14 -4
  41. package/dist/tools/lib/opentargets-client.d.ts +8 -3
  42. package/dist/tools/lib/opentargets-client.js +12 -3
  43. package/dist/tools/read-tool-output.js +7 -4
  44. package/dist/tools/report-authoring/authoring-tools.js +22 -15
  45. package/dist/tools/report-session/derive-table.js +5 -1
  46. package/dist/tools/report-session/examine-page.js +5 -4
  47. package/dist/tools/report-session/preview-report.js +6 -1
  48. package/dist/tools/report-session/record-version.js +2 -1
  49. package/dist/tools/research/context7-docs.js +8 -2
  50. package/dist/tools/research/generate-analogy-report.js +15 -15
  51. package/dist/tools/research/generate-plan.js +12 -7
  52. package/dist/tools/research/inspect-data-profile.js +7 -5
  53. package/dist/tools/research/inspect-run.js +6 -2
  54. package/dist/tools/research/literature-reviewer.js +11 -6
  55. package/dist/tools/research/search-arxiv.js +2 -1
  56. package/dist/tools/research/search-github-repos.js +9 -3
  57. package/dist/tools/sandbox/list-available-refs.js +3 -2
  58. package/dist/tools/sandbox/report-blocker.js +6 -1
  59. package/dist/tools/sandbox/skills.js +4 -2
  60. package/dist/tools/start-report-session.js +4 -1
  61. package/dist/tools/workspace/edit-file.js +4 -3
  62. package/dist/tools/workspace/execute-command.js +6 -5
  63. package/dist/tools/workspace/grep.js +9 -7
  64. package/dist/tools/workspace/list-files.js +1 -1
  65. package/dist/tools/workspace/read-file.js +2 -1
  66. package/dist/tools/workspace/scan-inputs.js +3 -1
  67. package/dist/tools/workspace/show-file.js +6 -3
  68. package/dist/tools/workspace/workspace-search.js +3 -1
  69. package/dist/tools/workspace/write-file.js +5 -3
  70. package/dist/workflows/execute-analysis.d.ts +28 -5
  71. package/dist/workflows/execute-analysis.js +34 -10
  72. package/package.json +2 -1
@@ -0,0 +1,675 @@
1
+ /**
2
+ * The schema of the Cortex state tables when the harness adopted versioned
3
+ * migrations.
4
+ *
5
+ * Each statement is idempotent. Thus the baseline also applies to a database
6
+ * that an earlier harness created with its boot-time DDL: it brings that
7
+ * database to this schema, and the migrator records it.
8
+ */
9
+ import { CompiledQuery, sql } from "kysely";
10
+ import { backfillAiSdkMessageEnvelopes } from "../../memory/message-backfill.js";
11
+ // No ';' may appear anywhere in this text, comments included: it is executed by
12
+ // splitting on ';' (see `DDL.split` below), so a semicolon inside a comment cuts
13
+ // the statement it precedes in half and Postgres rejects the fragment.
14
+ const DDL = `
15
+ -- Analysis-level state (status, context, data-profile tracking).
16
+ --
17
+ -- User identity is derived from the ambient credential's JWT sub claim
18
+ -- at request time — not persisted in this table.
19
+ CREATE TABLE IF NOT EXISTS cortex_analysis_state (
20
+ analysis_id TEXT PRIMARY KEY,
21
+ status TEXT NOT NULL,
22
+ context TEXT,
23
+ -- Nullable: NULL is the honest "no profile" state, which loadDataProfileStatus
24
+ -- collapses to null so a cleared profile is indistinguishable from one that never
25
+ -- existed. The 'pending' default still applies to a row inserted without a status --
26
+ -- a freshly-seeded analysis awaiting its first profile.
27
+ data_profile_status TEXT DEFAULT 'pending',
28
+ data_profile_error TEXT,
29
+ data_profile_started_at TEXT,
30
+ data_profile_completed_at TEXT,
31
+ data_profile_result JSONB,
32
+ data_profile_workflow_id TEXT,
33
+ created_at TEXT NOT NULL,
34
+ updated_at TEXT NOT NULL
35
+ );
36
+
37
+ -- DBOS workflow id of the profile attempt that owns the row — what a consumer
38
+ -- subscribes to in order to observe that profile's activity. Written by the
39
+ -- workflow body, not the trigger: the ledger claim happens before the workflow id
40
+ -- is minted, so only the body knows the id of the attempt that actually started.
41
+ -- Nullable, and absence is a normal state rather than a defect, with two ordinary
42
+ -- causes: a row claimed whose body has not yet recorded its id, and a row written
43
+ -- before this column existed. Both mean "this profile's stream is not
44
+ -- addressable", which is true in each case — hence no backfill.
45
+ ALTER TABLE cortex_analysis_state
46
+ ADD COLUMN IF NOT EXISTS data_profile_workflow_id TEXT;
47
+
48
+ -- File registry — artifact_id (provenance) + file_id (S3), cross-run.
49
+ CREATE TABLE IF NOT EXISTS cortex_artifacts (
50
+ analysis_id TEXT NOT NULL,
51
+ path TEXT NOT NULL,
52
+ hash TEXT NOT NULL,
53
+ size BIGINT NOT NULL,
54
+ role TEXT NOT NULL,
55
+ source_step TEXT,
56
+ source_run TEXT,
57
+ artifact_id TEXT,
58
+ file_id TEXT,
59
+ created_at TEXT NOT NULL,
60
+ PRIMARY KEY (analysis_id, path)
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_cortex_artifacts_run
64
+ ON cortex_artifacts(analysis_id, source_run);
65
+ CREATE INDEX IF NOT EXISTS idx_cortex_artifacts_artifact
66
+ ON cortex_artifacts(analysis_id, artifact_id);
67
+
68
+ -- Run ledger — one row per workflow execution.
69
+ CREATE TABLE IF NOT EXISTS cortex_runs (
70
+ run_id TEXT PRIMARY KEY,
71
+ analysis_id TEXT NOT NULL,
72
+ thread_id TEXT,
73
+ workflow_name TEXT NOT NULL,
74
+ status TEXT NOT NULL,
75
+ started_at TEXT NOT NULL,
76
+ completed_at TEXT,
77
+ error TEXT,
78
+ -- Run-level synthesis outcome. NULL means "unknown": a legacy row written
79
+ -- before these columns existed, or a run whose synthesis never recorded a result.
80
+ synthesis_status TEXT,
81
+ synthesis_reason TEXT,
82
+ parts JSONB,
83
+ mandate_jti TEXT, -- oss-core-managed-ok run-mandate ledger, nullable, OSS leaves null
84
+ mandate_expires_at TEXT -- oss-core-managed-ok run-mandate ledger, nullable, OSS leaves null
85
+ );
86
+
87
+ CREATE INDEX IF NOT EXISTS idx_cortex_runs_analysis
88
+ ON cortex_runs(analysis_id);
89
+ CREATE INDEX IF NOT EXISTS idx_cortex_runs_thread
90
+ ON cortex_runs(thread_id);
91
+
92
+ -- Step execution ledger — one row per step execution.
93
+ CREATE TABLE IF NOT EXISTS cortex_step_executions (
94
+ run_id TEXT NOT NULL,
95
+ step_id TEXT NOT NULL,
96
+ analysis_id TEXT NOT NULL,
97
+ wave INTEGER NOT NULL,
98
+ agent_id TEXT NOT NULL,
99
+ status TEXT NOT NULL,
100
+ started_at TEXT,
101
+ completed_at TEXT,
102
+ duration_ms BIGINT,
103
+ error TEXT,
104
+ attempts INTEGER NOT NULL DEFAULT 1,
105
+ last_error_class TEXT,
106
+ finish_reason TEXT,
107
+ hit_max_steps INTEGER NOT NULL DEFAULT 0,
108
+ blocked_reason TEXT,
109
+ PRIMARY KEY (run_id, step_id)
110
+ );
111
+
112
+ CREATE INDEX IF NOT EXISTS idx_cortex_step_exec_analysis
113
+ ON cortex_step_executions(analysis_id);
114
+
115
+ -- Analysis plans — persisted by generatePlan, consumed by showUser and executePlan.
116
+ -- Immutable, append-only. Iteration produces a new row linked via parent_plan_id.
117
+ CREATE TABLE IF NOT EXISTS cortex_plans (
118
+ plan_id TEXT PRIMARY KEY,
119
+ analysis_id TEXT NOT NULL
120
+ REFERENCES cortex_analysis_state(analysis_id)
121
+ ON DELETE CASCADE,
122
+ plan JSONB NOT NULL,
123
+ parent_plan_id TEXT
124
+ REFERENCES cortex_plans(plan_id)
125
+ ON DELETE SET NULL,
126
+ created_at TEXT NOT NULL
127
+ );
128
+
129
+ CREATE INDEX IF NOT EXISTS idx_cortex_plans_analysis
130
+ ON cortex_plans(analysis_id, created_at DESC);
131
+
132
+ -- Report versions — one row for each recorded report version. A row holds the
133
+ -- block document, the pinned snapshot, and the anchor. A thread holds at most one
134
+ -- version. The named unique constraint on thread_id enforces the rule, because
135
+ -- the report policy binds one version to each report session. A record on a
136
+ -- thread that holds a version replaces that row whole, under the same version_id
137
+ -- and the same created_at. The row outlives its thread. Thus the table has no
138
+ -- foreign key to cortex_analysis_threads, and the row carries the anchor columns
139
+ -- (thread_id, parent_thread_id, parent_seq) directly. A purge of the analysis
140
+ -- removes the row through the analysis_id cascade.
141
+ CREATE TABLE IF NOT EXISTS cortex_report_versions (
142
+ version_id TEXT PRIMARY KEY,
143
+ analysis_id TEXT NOT NULL
144
+ REFERENCES cortex_analysis_state(analysis_id)
145
+ ON DELETE CASCADE,
146
+ thread_id TEXT NOT NULL,
147
+ -- Nullable pair: a root conversation anchor can be absent. A version with no
148
+ -- parent thread carries no parent_seq either.
149
+ parent_thread_id TEXT,
150
+ parent_seq BIGINT,
151
+ parent_version_id TEXT
152
+ REFERENCES cortex_report_versions(version_id)
153
+ ON DELETE SET NULL,
154
+ document JSONB NOT NULL,
155
+ snapshot JSONB NOT NULL,
156
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
157
+ CONSTRAINT cortex_report_versions_one_per_thread UNIQUE (thread_id)
158
+ );
159
+
160
+ CREATE INDEX IF NOT EXISTS idx_cortex_report_versions_analysis
161
+ ON cortex_report_versions(analysis_id);
162
+
163
+ -- Report session state — one mutable row for each report session thread. The row
164
+ -- holds the in-progress draft document and the pinned snapshot of the session. The
165
+ -- thread id is the primary key, thus one session binds to one thread. The document
166
+ -- column and the snapshot column are each nullable, because the pin writes the
167
+ -- snapshot first and the document lands later. A purge of the analysis removes the
168
+ -- row through the analysis_id cascade. The explicit purge delete stays a backstop,
169
+ -- the same as the plans delete, because a database whose table predates the key
170
+ -- never acquires one. The thread id carries no foreign key, the same as the
171
+ -- messages rows, thus a purge of a thread cannot reach this row through a cascade
172
+ -- and purgeThread removes it by name.
173
+ --
174
+ -- The two hash columns are the look-before-record markers. rendered_document_hash
175
+ -- holds the hash of the draft that the last preview rendered. seen_document_hash
176
+ -- holds the hash that the last eyes capture saw, copied from the rendered hash.
177
+ -- Each is nullable, because a fresh session has run no preview and no eyes. The
178
+ -- record refuses unless the seen hash equals the hash of the current draft.
179
+ --
180
+ -- The derivations column holds the derivation list of the session. One record
181
+ -- carries the derived output path, the output hash, the source paths with their
182
+ -- hashes, the script hash, and the script text. The column is nullable, because a
183
+ -- session that derived nothing holds no list, and the read gives an empty list for
184
+ -- such a row. The served membership merges the records onto the pin, and the pin
185
+ -- itself never changes.
186
+ CREATE TABLE IF NOT EXISTS cortex_report_session_state (
187
+ thread_id TEXT PRIMARY KEY,
188
+ analysis_id TEXT NOT NULL
189
+ REFERENCES cortex_analysis_state(analysis_id)
190
+ ON DELETE CASCADE,
191
+ document JSONB,
192
+ snapshot JSONB,
193
+ derivations JSONB,
194
+ rendered_document_hash TEXT,
195
+ seen_document_hash TEXT,
196
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
197
+ );
198
+
199
+ CREATE INDEX IF NOT EXISTS idx_cortex_report_session_state_analysis
200
+ ON cortex_report_session_state(analysis_id);
201
+
202
+ -- Regulatory guidance corpus — pre-indexed FDA CDER, FDA CBER, and ICH
203
+ -- guidance documents. Written/refreshed via tasks/refresh-regulatory-corpus.ts
204
+ -- and searched (top-K cosine) via lib/regulatory-corpus.ts. No synthesis tool
205
+ -- consumes it yet — a search_regulatory_guidance tool is not wired.
206
+ CREATE TABLE IF NOT EXISTS cortex_regulatory_chunks (
207
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
208
+ source TEXT NOT NULL,
209
+ doc_id TEXT NOT NULL,
210
+ doc_title TEXT NOT NULL,
211
+ doc_url TEXT NOT NULL,
212
+ section TEXT,
213
+ chunk_index INTEGER NOT NULL,
214
+ chunk_text TEXT NOT NULL,
215
+ embedding vector(1536),
216
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
217
+ indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
218
+ );
219
+
220
+ CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_source_doc
221
+ ON cortex_regulatory_chunks(source, doc_id);
222
+
223
+ CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_metadata
224
+ ON cortex_regulatory_chunks USING gin (metadata);
225
+
226
+ -- Conversation message store — the harness ThreadHistory module's table.
227
+ -- One row per message, with seq monotonic per thread. message_envelope holds
228
+ -- AI SDK ModelMessage envelopes and tokens is counted at write time, so the
229
+ -- read path never tokenizes. Legacy role/content_jsonb columns are nullable
230
+ -- and write-frozen — they exist only for startup backfill/inspection and
231
+ -- should be dropped after the migration window. Conversation-scoped only —
232
+ -- workflow and sandbox agent loops use the DBOS step cache, never this
233
+ -- table (see the harness-thread-store spec). The (thread_id, seq) primary key already serves
234
+ -- (thread_id, seq DESC) reads, so no separate index is needed.
235
+ --
236
+ -- message_envelope is JSON, not JSONB, and must stay that way. JSONB stores a
237
+ -- re-sorted parse tree rather than the document it was given, so a tool call's
238
+ -- input — and every other free-form payload, which has no schema to be
239
+ -- normalized back against on read — comes back key-reordered. Those bytes are
240
+ -- re-sent to the provider on the next turn, where a reordered prefix misses the
241
+ -- prompt cache. JSON stores the text verbatim. Nothing indexes this column, and
242
+ -- the JSON operators retractLastTurn uses work the same on either type.
243
+ --
244
+ -- reported_usage holds a whole turn's TokenUsageRollup — what providers reported
245
+ -- for the turn this row completed — on the LAST assistant row of an older turn
246
+ -- only. cortex_thread_turns holds the figures of each later turn.
247
+ -- It is NOT the tokens column beside it, and the two must never be read for
248
+ -- each other: tokens is an offline js-tiktoken estimate stamped on EVERY row so
249
+ -- the read path can window by budget without a provider round trip, and it exists
250
+ -- for rows no provider ever saw. reported_usage is a provider's own report, and
251
+ -- covers a turn rather than a row. Both are plausible-looking integers on one row,
252
+ -- which is exactly why the column names carry the distinction.
253
+ --
254
+ -- Nullable with no default: absent means nothing was reported, and a zero would
255
+ -- claim a turn cost nothing. A row written before this column existed is absent
256
+ -- for the same reason, so the migration needs no backfill.
257
+ --
258
+ -- JSONB here, unlike message_envelope above. The verbatim-bytes requirement that
259
+ -- forces JSON on the envelope is about model content that is re-sent to the
260
+ -- provider. This record is harness-generated, has a fixed field set, and never
261
+ -- reaches a prompt, so it does not share that requirement.
262
+ --
263
+ -- turn_duration_ms holds the time that a whole turn took, in milliseconds, on
264
+ -- the same row as reported_usage. It is a column of its own, and not a member of
265
+ -- the rollup. The rollup is absent when a provider reported no quantity, and the
266
+ -- host measures the duration apart from the token counts. Thus a member of the
267
+ -- rollup would disappear with it. BIGINT, as cortex_step_executions.duration_ms
268
+ -- is, because an int4 duration overflowed there.
269
+ --
270
+ -- Nullable with no default: absent means that nobody measured the turn, and a
271
+ -- zero would claim that a turn took no time. A row written before this column
272
+ -- existed is absent for the same reason, thus the migration needs no backfill.
273
+ --
274
+ -- author names the identity that sent the user message of a turn, as the host
275
+ -- knew it at the append site. It is written on the genuine-user-start row of an
276
+ -- append only -- the row that carries display_envelope -- and never on an
277
+ -- assistant, tool, or synthetic row. Nullable with no default and no backfill:
278
+ -- absent means that no sender was recorded, which is the honest value both for a
279
+ -- row that predates the column and for a turn appended without one.
280
+ CREATE TABLE IF NOT EXISTS messages (
281
+ thread_id TEXT NOT NULL,
282
+ seq BIGINT NOT NULL,
283
+ role TEXT,
284
+ content_jsonb JSONB,
285
+ message_envelope JSON,
286
+ -- Versioned AI SDK UIMessage projection of one written group, on the first
287
+ -- row of that group. Model-history reads never select it.
288
+ display_envelope JSONB,
289
+ tokens INTEGER NOT NULL,
290
+ reported_usage JSONB,
291
+ turn_duration_ms BIGINT,
292
+ author TEXT,
293
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
294
+ PRIMARY KEY (thread_id, seq)
295
+ );
296
+
297
+ -- The state of one chat turn, and no message: a close never changes a row of
298
+ -- messages. messages.reported_usage and messages.turn_duration_ms hold the
299
+ -- figures of the older turns, which have no record here.
300
+ CREATE TABLE IF NOT EXISTS cortex_thread_turns (
301
+ thread_id TEXT NOT NULL,
302
+ -- The seq of the user row that opens the turn.
303
+ start_seq BIGINT NOT NULL,
304
+ status TEXT NOT NULL CHECK (status IN ('open', 'done', 'aborted', 'failed')),
305
+ -- A short text from the class of the error, never its message, because each later turn sends the note.
306
+ reason TEXT,
307
+ -- Absent when no provider reported a quantity, the same as messages.reported_usage.
308
+ reported_usage JSONB,
309
+ turn_duration_ms BIGINT,
310
+ opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
311
+ closed_at TIMESTAMPTZ,
312
+ PRIMARY KEY (thread_id, start_seq)
313
+ );
314
+
315
+ -- Conversation thread metadata — the harness ThreadStore module's table,
316
+ -- one row per conversation thread. Holds the analysis-scope, title, and
317
+ -- soft-delete tombstone the harness messages table deliberately lacks.
318
+ -- No free-form metadata column: working memory lives in cortex_working_memory;
319
+ -- nothing else reads thread metadata. A NULL deleted_at means the thread is live.
320
+ CREATE TABLE IF NOT EXISTS cortex_analysis_threads (
321
+ thread_id TEXT PRIMARY KEY,
322
+ analysis_id TEXT NOT NULL,
323
+ title TEXT,
324
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
325
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
326
+ deleted_at TIMESTAMPTZ,
327
+ thread_type TEXT NOT NULL DEFAULT 'conversation',
328
+ -- The cascade on this self-reference is a BACKSTOP, never the deletion path.
329
+ -- messages carries no foreign key to this table, so a cascade firing on its
330
+ -- own drops a descendant's metadata row while leaving that descendant's
331
+ -- messages rows behind with nothing naming them, beyond the reach of any
332
+ -- later thread-scoped or analysis-scoped reclamation. purgeThread therefore
333
+ -- walks the subtree and deletes its messages rows explicitly, to the same
334
+ -- depth the cascade reaches.
335
+ parent_thread_id TEXT REFERENCES cortex_analysis_threads(thread_id) ON DELETE CASCADE,
336
+ parent_seq BIGINT
337
+ );
338
+
339
+ -- These three sit here, and not in the additive block further down, because the
340
+ -- index below reads one of them. CREATE TABLE IF NOT EXISTS adds no column to a
341
+ -- table that already exists, so on every database but a brand new one the column
342
+ -- arrives from an ALTER -- and the whole additive block runs after this text. An
343
+ -- index declared here over a column added there fails with 42703 on exactly the
344
+ -- databases a migration exists to serve. Additive with no backfill either way: an
345
+ -- existing row reads back as a root conversation thread with no anchor, which is
346
+ -- what it is.
347
+ ALTER TABLE cortex_analysis_threads
348
+ ADD COLUMN IF NOT EXISTS thread_type TEXT NOT NULL DEFAULT 'conversation';
349
+
350
+ ALTER TABLE cortex_analysis_threads
351
+ ADD COLUMN IF NOT EXISTS parent_thread_id TEXT REFERENCES cortex_analysis_threads(thread_id) ON DELETE CASCADE;
352
+
353
+ ALTER TABLE cortex_analysis_threads
354
+ ADD COLUMN IF NOT EXISTS parent_seq BIGINT;
355
+
356
+ CREATE INDEX IF NOT EXISTS idx_cortex_analysis_threads_analysis
357
+ ON cortex_analysis_threads(analysis_id) WHERE deleted_at IS NULL;
358
+
359
+ -- Not partial, unlike the analysis index above. Two of the three readers of
360
+ -- parent_thread_id carry no deleted_at predicate and cannot: the recursive
361
+ -- subtree walk in thread-store.ts must reach archived descendants, and the
362
+ -- referential trigger behind ON DELETE CASCADE is the database's own query.
363
+ -- Postgres uses a partial index only where it can prove the predicate holds, so
364
+ -- a partial index here leaves both to a sequential scan of the whole table.
365
+ -- Over 150k rows that measured 28 ms per walk and 20 ms per cascade, against
366
+ -- 0.06 ms and 0.10 ms with this one. The third reader, a listing filtered by
367
+ -- parent, keeps an index scan either way.
368
+ --
369
+ -- The old partial index goes by name: CREATE INDEX IF NOT EXISTS keeps whatever
370
+ -- definition already holds the name, so a database that built the partial one
371
+ -- would never see this.
372
+ DROP INDEX IF EXISTS idx_cortex_analysis_threads_parent;
373
+
374
+ CREATE INDEX IF NOT EXISTS idx_cortex_analysis_threads_parent_fk
375
+ ON cortex_analysis_threads(parent_thread_id);
376
+
377
+ -- Working memory — the harness WorkingMemory module's table, one row per
378
+ -- analysis. The data column holds a JSONB object with four sections (goal,
379
+ -- constraints, hypotheses, findings). Section updates are a read-modify-write
380
+ -- in harness/memory/working-memory.ts, not a whole-blob rewrite.
381
+ -- Analysis-scoped and agent-maintained.
382
+ CREATE TABLE IF NOT EXISTS cortex_working_memory (
383
+ analysis_id TEXT PRIMARY KEY,
384
+ data JSONB NOT NULL,
385
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
386
+ );
387
+
388
+ -- Tool-approval ask ledger — the single source of truth for approval state, one
389
+ -- row per ctx.ask invocation. ctx.ask inserts a 'pending' row and polls it to a
390
+ -- terminal status. The outward answer() writes the decision. reply holds the
391
+ -- recorded AskReply (NULL while pending). Analysis-scoped audit of every
392
+ -- approval-gated action, including grant auto-approvals (recorded 'resolved'
393
+ -- with no prompt shown). A pending row a dead process orphans is swept to
394
+ -- 'expired' at boot.
395
+ CREATE TABLE IF NOT EXISTS cortex_asks (
396
+ id TEXT PRIMARY KEY,
397
+ analysis_id TEXT NOT NULL,
398
+ user_id TEXT,
399
+ thread_id TEXT,
400
+ title TEXT NOT NULL,
401
+ command TEXT NOT NULL,
402
+ detail TEXT,
403
+ -- The key the standing grant behind an 'always' is recorded under: the displayed
404
+ -- command unless the tool supplied a broader grant key. Written on every insert,
405
+ -- defaulting to the command when absent. Nullable only so the additive migration
406
+ -- can add it to an already-created table without a per-row backfill.
407
+ grant_key TEXT,
408
+ status TEXT NOT NULL,
409
+ reply JSONB,
410
+ created_at TEXT NOT NULL,
411
+ resolved_at TEXT
412
+ );
413
+
414
+ CREATE INDEX IF NOT EXISTS idx_cortex_asks_analysis
415
+ ON cortex_asks(analysis_id);
416
+
417
+ -- Backs the pending() enumeration and the boot sweep. Partial so it stays
418
+ -- compact against the far larger terminal-row mass.
419
+ CREATE INDEX IF NOT EXISTS idx_cortex_asks_pending
420
+ ON cortex_asks(status) WHERE status = 'pending';
421
+
422
+ -- Standing approval grants behind an 'always' reply — keyed by the analysis, the
423
+ -- user, and the ask's grant key (the displayed command when the tool supplied no
424
+ -- broader key), so a later matching ask auto-approves without pausing. Lives for
425
+ -- the analysis lifecycle, surviving process restarts, and never applies to
426
+ -- another analysis or another user.
427
+ CREATE TABLE IF NOT EXISTS cortex_ask_grants (
428
+ analysis_id TEXT NOT NULL,
429
+ user_id TEXT NOT NULL,
430
+ grant_key TEXT NOT NULL,
431
+ created_at TEXT NOT NULL,
432
+ PRIMARY KEY (analysis_id, user_id, grant_key)
433
+ );
434
+
435
+ -- The kept text of each cut tool result. thread_id is null for a text of a run, thus purgeThread removes
436
+ -- only the texts of chat turns, and purgeAnalysis removes each row by analysis_id.
437
+ CREATE TABLE IF NOT EXISTS cortex_tool_outputs (
438
+ analysis_id TEXT NOT NULL,
439
+ ref TEXT NOT NULL,
440
+ tool_name TEXT NOT NULL,
441
+ tool_call_id TEXT NOT NULL,
442
+ thread_id TEXT,
443
+ content TEXT NOT NULL,
444
+ total_length INTEGER NOT NULL,
445
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
446
+ PRIMARY KEY (analysis_id, ref)
447
+ );
448
+
449
+ CREATE INDEX IF NOT EXISTS cortex_tool_outputs_thread_idx
450
+ ON cortex_tool_outputs (thread_id) WHERE thread_id IS NOT NULL;
451
+ `;
452
+ /**
453
+ * Drop vestigial columns/tables from pre-v2 databases. Idempotent: each
454
+ * statement targets a specific column that may or may not exist;
455
+ * `DROP COLUMN IF EXISTS` is a no-op when absent.
456
+ */
457
+ const DROP_MIGRATIONS = [
458
+ "DROP TABLE IF EXISTS cortex_run_events",
459
+ "ALTER TABLE cortex_step_executions DROP COLUMN IF EXISTS thread_id",
460
+ "ALTER TABLE cortex_step_executions DROP COLUMN IF EXISTS execution_id",
461
+ "ALTER TABLE cortex_step_executions DROP COLUMN IF EXISTS resources",
462
+ "ALTER TABLE cortex_step_executions DROP COLUMN IF EXISTS summary",
463
+ "ALTER TABLE cortex_artifacts DROP COLUMN IF EXISTS description",
464
+ "ALTER TABLE cortex_artifacts DROP COLUMN IF EXISTS metadata",
465
+ "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS profile",
466
+ "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS input_files",
467
+ "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS user_id",
468
+ // Billing identity is resolved lazily at charge-open time, never persisted.
469
+ "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS billing_context",
470
+ // v2 vestiges on cortex_runs.
471
+ "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS plan",
472
+ "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS plan_version",
473
+ "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS current_wave",
474
+ "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS suspension",
475
+ // The mandate JWT lives in DBOS workflow input (never reconstructed -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
476
+ // from the DB), and `run_id` is the DBOS workflowID directly — no
477
+ // separate `workflow_id` column.
478
+ "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS mandate_token", // oss-core-managed-ok: drop-migration for removed branded column
479
+ "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS workflow_id",
480
+ "DROP INDEX IF EXISTS idx_cortex_runs_workflow",
481
+ ];
482
+ const RELAX_MIGRATIONS = [
483
+ // If an older DB had `cortex_runs.thread_id NOT NULL`, relax it now.
484
+ // Safe no-op when already nullable.
485
+ "ALTER TABLE cortex_runs ALTER COLUMN thread_id DROP NOT NULL",
486
+ // Legacy Anthropic message columns are write-frozen: runtime writes
487
+ // only message_envelope, so new rows must not require them. The
488
+ // columns should be dropped entirely after the migration window.
489
+ "ALTER TABLE messages ALTER COLUMN role DROP NOT NULL",
490
+ "ALTER TABLE messages ALTER COLUMN content_jsonb DROP NOT NULL",
491
+ ];
492
+ /** Additive migrations — columns that may not exist on older DBs. */
493
+ const ADD_MIGRATIONS = [
494
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS thread_id TEXT",
495
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS parts JSONB",
496
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS mandate_jti TEXT", // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
497
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS mandate_expires_at TEXT", // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
498
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_status TEXT",
499
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_reason TEXT",
500
+ "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS data_profile_result JSONB",
501
+ // Sandbox reliability telemetry (from sandbox-reliability change on main).
502
+ "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 1",
503
+ "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS last_error_class TEXT",
504
+ "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS finish_reason TEXT",
505
+ "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS hit_max_steps INTEGER NOT NULL DEFAULT 0",
506
+ "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS blocked_reason TEXT",
507
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS plan_id TEXT REFERENCES cortex_plans(plan_id) ON DELETE SET NULL",
508
+ // Promote overflow-prone numeric columns from int4 to int8.
509
+ // File sizes >2 GB and durations >~24.8 days previously failed INSERT
510
+ // with "value ... is out of range of type integer".
511
+ "ALTER TABLE cortex_artifacts ALTER COLUMN size TYPE BIGINT",
512
+ "ALTER TABLE cortex_step_executions ALTER COLUMN duration_ms TYPE BIGINT",
513
+ "ALTER TABLE cortex_artifacts ADD COLUMN IF NOT EXISTS unrecoverable_at TEXT",
514
+ "ALTER TABLE cortex_artifacts ADD COLUMN IF NOT EXISTS file_type TEXT",
515
+ // Active-sandbox registry — sandbox_ref carries {sandboxId, host, port,
516
+ // backend} (no callbackSecret — that lives only in the DBOS step-output
517
+ // cache). exec_id carries the currently in-flight workflowId:stepId:fnId.
518
+ // Partial index supports the watchdog's registry query
519
+ // (status='running' AND sandbox_ref IS NOT NULL); kept partial so it
520
+ // stays compact against the much larger terminal-row mass.
521
+ "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS sandbox_ref JSONB",
522
+ "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS exec_id TEXT",
523
+ "CREATE INDEX IF NOT EXISTS idx_cortex_step_exec_active_sandbox " + "ON cortex_step_executions(status) WHERE sandbox_ref IS NOT NULL",
524
+ // DBOS workflow addressing. `cortex_runs.run_id` IS the parent DBOS
525
+ // workflowID — both are the same bare UUID. `child_workflow_id` maps
526
+ // each step-execution row to its DBOS child workflow id.
527
+ // Partial-unique index — at most one active run per (analysis_id, plan_id).
528
+ // Terminal rows are excluded so a deliberate re-run after completion
529
+ // succeeds; a concurrent double-trigger raises unique-violation and the
530
+ // caller resolves to the existing active runId via queryActiveRun.
531
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_cortex_runs_active_plan " +
532
+ "ON cortex_runs(analysis_id, plan_id) " +
533
+ "WHERE status IN ('running','suspended_insufficient_funds')",
534
+ "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS child_workflow_id TEXT",
535
+ "CREATE INDEX IF NOT EXISTS idx_cortex_step_exec_child_workflow " + "ON cortex_step_executions(child_workflow_id)",
536
+ "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS seed_input_file_ids JSONB",
537
+ // The look-before-record markers of a report session. Nullable and
538
+ // unbackfilled: a session with no preview and no eyes carries neither hash,
539
+ // which is the honest state, thus an existing row reads back as never-seen.
540
+ "ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS rendered_document_hash TEXT",
541
+ "ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS seen_document_hash TEXT",
542
+ // The derivation list of a report session. Nullable and unbackfilled: a
543
+ // session that derived nothing holds no list, and the read gives an empty
544
+ // list for such a row.
545
+ "ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS derivations JSONB",
546
+ // Databases created before `data_profile_status` became nullable still
547
+ // carry the NOT NULL floor, which would reject a clear. Idempotent:
548
+ // dropping an absent NOT NULL no-ops, so this is a no-op on fresh DBs.
549
+ "ALTER TABLE cortex_analysis_state ALTER COLUMN data_profile_status DROP NOT NULL",
550
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS message_envelope JSON",
551
+ // The turn's reported usage rollup (see the CREATE TABLE comment).
552
+ // Purely additive: nullable with no default, so adding it rewrites no
553
+ // row and every pre-existing message reads back with no rollup — which
554
+ // is the honest value, the figures having never been recorded.
555
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS reported_usage JSONB",
556
+ // The turn's measured duration (see the CREATE TABLE comment). Purely
557
+ // additive: nullable with no default, thus the column rewrites no row,
558
+ // and every earlier message reads back with no duration — which is the
559
+ // honest value, because nobody measured those turns.
560
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS turn_duration_ms BIGINT",
561
+ // Who sent the user message of the turn (see the CREATE TABLE comment).
562
+ // Purely additive: nullable with no default, thus the column rewrites no row.
563
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS author TEXT",
564
+ // Demote an existing JSONB envelope column to JSON (see the CREATE
565
+ // TABLE comment). Type-guarded because ALTER ... TYPE rewrites the
566
+ // whole table; scoped to current_schema() so parallel test schemas
567
+ // never see each other.
568
+ // Rows already written as JSONB carry over as they stand — their
569
+ // original key order is not recoverable.
570
+ `DO $$ BEGIN
571
+ IF EXISTS (
572
+ SELECT 1 FROM information_schema.columns
573
+ WHERE table_schema = current_schema() AND table_name = 'messages'
574
+ AND column_name = 'message_envelope' AND data_type = 'jsonb'
575
+ ) THEN
576
+ ALTER TABLE messages ALTER COLUMN message_envelope TYPE JSON USING message_envelope::text::json;
577
+ END IF;
578
+ END $$`,
579
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS display_envelope JSONB",
580
+ // Grant key an 'always' records the standing grant under. Nullable
581
+ // and unbackfilled: every new row writes it (command when absent), and
582
+ // orphaned legacy pending rows are swept to 'expired' before any answer
583
+ // reads it, so no existing row is left needing a value.
584
+ "ALTER TABLE cortex_asks ADD COLUMN IF NOT EXISTS grant_key TEXT",
585
+ // The grants table's key column is named for what it stores. A table
586
+ // created when the column was `command` is renamed in place — its
587
+ // values carry over unchanged, because a grant recorded without a
588
+ // grant key is keyed on its exact command. Guarded via
589
+ // information_schema (Postgres has no RENAME COLUMN IF EXISTS) and
590
+ // scoped to current_schema() so parallel test schemas in the same
591
+ // database never trigger each other's rename.
592
+ `DO $$ BEGIN
593
+ IF EXISTS (
594
+ SELECT 1 FROM information_schema.columns
595
+ WHERE table_schema = current_schema() AND table_name = 'cortex_ask_grants' AND column_name = 'command'
596
+ ) THEN
597
+ ALTER TABLE cortex_ask_grants RENAME COLUMN command TO grant_key;
598
+ END IF;
599
+ END $$`,
600
+ "ALTER TABLE cortex_asks ADD COLUMN IF NOT EXISTS user_id TEXT",
601
+ // The user joins the grant key. Guarded and scoped like the rename
602
+ // above. An existing row keeps the empty default, which matches no real
603
+ // identity, so it is inert rather than deleted.
604
+ `DO $$ BEGIN
605
+ IF NOT EXISTS (
606
+ SELECT 1 FROM information_schema.columns
607
+ WHERE table_schema = current_schema() AND table_name = 'cortex_ask_grants' AND column_name = 'user_id'
608
+ ) THEN
609
+ ALTER TABLE cortex_ask_grants ADD COLUMN user_id TEXT NOT NULL DEFAULT '';
610
+ ALTER TABLE cortex_ask_grants ALTER COLUMN user_id DROP DEFAULT;
611
+ ALTER TABLE cortex_ask_grants DROP CONSTRAINT IF EXISTS cortex_ask_grants_pkey;
612
+ ALTER TABLE cortex_ask_grants ADD PRIMARY KEY (analysis_id, user_id, grant_key);
613
+ END IF;
614
+ END $$`,
615
+ ];
616
+ // HNSW ANN index on cortex_regulatory_chunks.embedding. HNSW gives
617
+ // higher recall than ivfflat at the same latency and needs no
618
+ // training step — the right pick for a write-occasionally /
619
+ // read-many corpus (quarterly refresh, multiple search calls per
620
+ // synthesis step).
621
+ //
622
+ // Requires pgvector >= 0.5.0. Tolerated gracefully on older
623
+ // versions — the table and B-tree/GIN indexes still work; the
624
+ // operator can build this index manually once they upgrade pgvector.
625
+ // m and ef_construction are conservative defaults; tune after
626
+ // measuring recall vs. latency on the production corpus size.
627
+ const HNSW_INDEX = `CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_embedding
628
+ ON cortex_regulatory_chunks USING hnsw (embedding vector_cosine_ops)
629
+ WITH (m = 16, ef_construction = 64)`;
630
+ export function baseline(logger) {
631
+ return {
632
+ async up(db) {
633
+ // pgvector must be present for the per-analysis search indexes.
634
+ try {
635
+ await sql.raw("CREATE EXTENSION IF NOT EXISTS vector").execute(db);
636
+ }
637
+ catch (err) {
638
+ const msg = err instanceof Error ? err.message : String(err);
639
+ throw new Error(`Cortex requires the pgvector extension. ` +
640
+ `CREATE EXTENSION IF NOT EXISTS vector failed: ${msg}. ` +
641
+ `Ask platform-infra to enable pgvector on the RDS parameter group, ` +
642
+ `or grant CREATE on the database to the Cortex role.`, { cause: err });
643
+ }
644
+ // Base DDL — idempotent via CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS.
645
+ const statements = DDL.split(";")
646
+ .map((s) => s.trim())
647
+ .filter((s) => s.length > 0);
648
+ for (const statement of [...statements, ...DROP_MIGRATIONS, ...RELAX_MIGRATIONS, ...ADD_MIGRATIONS]) {
649
+ await sql.raw(statement).execute(db);
650
+ }
651
+ await backfillAiSdkMessageEnvelopes({
652
+ query: (text, values) => db.executeQuery(CompiledQuery.raw(text, values)),
653
+ });
654
+ // The migration runs in one transaction, thus a failed statement must roll
655
+ // back to a savepoint before the next statement can run.
656
+ await sql `SAVEPOINT hnsw_index`.execute(db);
657
+ try {
658
+ await sql.raw(HNSW_INDEX).execute(db);
659
+ }
660
+ catch (err) {
661
+ await sql `ROLLBACK TO SAVEPOINT hnsw_index`.execute(db);
662
+ logger.warn("could not create HNSW index on cortex_regulatory_chunks.embedding (pgvector < 0.5.0 or operator class unavailable) — " +
663
+ "cosine similarity queries fall back to sequential scan; build the index by hand after you upgrade pgvector", logger.errorFields(err));
664
+ }
665
+ // No boot-time orphan sweep on cortex_runs / cortex_step_executions.
666
+ // Every running row is backed by a DBOS workflow (executeAnalysis,
667
+ // resume entry); DBOS recovery is the
668
+ // substitute — each pod has a stable executorID (StatefulSet pod name)
669
+ // and runs `recoverPendingWorkflows` for its own ID at launch (ADR
670
+ // 0012). The workflow body owns the terminal transition on cortex_runs;
671
+ // a bulk fail here would race recovery and mark healthy sibling-replica
672
+ // runs as failed.
673
+ },
674
+ };
675
+ }