@opengeni/db 0.13.1 → 0.14.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.
@@ -0,0 +1,100 @@
1
+ -- deployment-mode: maintenance
2
+ -- Make personal integration credentials subject-owned at the database boundary.
3
+ -- Every old API/control/turn writer MUST be stopped before activation: old writers
4
+ -- do not establish opengeni.subject_id and can persist workspace-visible OAuth refs.
5
+
6
+ SET LOCAL lock_timeout = '5s';
7
+ SET LOCAL statement_timeout = '30min';
8
+
9
+ DO $maintenance_preflight_guard$
10
+ BEGIN
11
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
12
+ AND EXISTS (
13
+ SELECT 1
14
+ FROM pg_stat_activity
15
+ WHERE datname = current_database()
16
+ AND usename = 'opengeni_app'
17
+ AND pid <> pg_backend_pid()
18
+ )
19
+ THEN
20
+ RAISE EXCEPTION
21
+ 'connection subject-isolation activation requires all opengeni_app sessions to be stopped'
22
+ USING ERRCODE = '55000';
23
+ END IF;
24
+ END
25
+ $maintenance_preflight_guard$;
26
+
27
+ LOCK TABLE connections IN ACCESS EXCLUSIVE MODE;
28
+ LOCK TABLE capability_installations IN ACCESS EXCLUSIVE MODE;
29
+
30
+ DO $maintenance_guard$
31
+ BEGIN
32
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
33
+ AND EXISTS (
34
+ SELECT 1
35
+ FROM pg_stat_activity
36
+ WHERE datname = current_database()
37
+ AND usename = 'opengeni_app'
38
+ AND pid <> pg_backend_pid()
39
+ )
40
+ THEN
41
+ RAISE EXCEPTION
42
+ 'connection subject-isolation activation requires all opengeni_app sessions to be stopped'
43
+ USING ERRCODE = '55000';
44
+ END IF;
45
+ END
46
+ $maintenance_guard$;
47
+
48
+ -- Migration 0039 initially stored generic MCP OAuth rows as workspace-shared.
49
+ -- Backfill only Slack's exact official hosted MCP endpoint, and only where the
50
+ -- OAuth callback recorded the authenticating subject. Workspace app installs,
51
+ -- manual Slack credentials, and ambiguous legacy rows are deliberately untouched.
52
+ UPDATE connections
53
+ SET subject_id = created_by_subject_id,
54
+ updated_at = now()
55
+ WHERE subject_id IS NULL
56
+ AND kind = 'oauth2'
57
+ AND provider_domain = 'slack.com'
58
+ AND metadata ->> 'mcpUrl' = 'https://mcp.slack.com/mcp'
59
+ AND created_by_subject_id IS NOT NULL
60
+ AND btrim(created_by_subject_id) <> '';
61
+
62
+ -- Capability installation metadata is workspace-visible. Preserve only a
63
+ -- generic subject selector; the concrete personal connection UUID remains in
64
+ -- the subject-protected connection row and is selected at turn execution.
65
+ UPDATE capability_installations AS installation
66
+ SET config = jsonb_set(
67
+ installation.config #- '{connectionRef,connectionId}',
68
+ '{connectionRef,subjectScope}',
69
+ '"subject"'::jsonb,
70
+ true
71
+ )
72
+ WHERE jsonb_typeof(installation.config -> 'connectionRef') = 'object'
73
+ AND EXISTS (
74
+ SELECT 1
75
+ FROM connections AS connection
76
+ WHERE connection.id::text = installation.config #>> '{connectionRef,connectionId}'
77
+ AND connection.account_id = installation.account_id
78
+ AND connection.workspace_id = installation.workspace_id
79
+ AND connection.subject_id IS NOT NULL
80
+ AND connection.kind = 'oauth2'
81
+ AND connection.provider_domain = 'slack.com'
82
+ AND connection.metadata ->> 'mcpUrl' = 'https://mcp.slack.com/mcp'
83
+ );
84
+
85
+ DROP POLICY IF EXISTS workspace_isolation ON connections;
86
+ CREATE POLICY workspace_isolation ON connections
87
+ USING (
88
+ opengeni_private.workspace_rls_visible(account_id, workspace_id)
89
+ AND (
90
+ subject_id IS NULL
91
+ OR subject_id = nullif(current_setting('opengeni.subject_id', true), '')
92
+ )
93
+ )
94
+ WITH CHECK (
95
+ opengeni_private.workspace_rls_visible(account_id, workspace_id)
96
+ AND (
97
+ subject_id IS NULL
98
+ OR subject_id = nullif(current_setting('opengeni.subject_id', true), '')
99
+ )
100
+ );
@@ -0,0 +1,3 @@
1
+ -- deployment-mode: rolling
2
+ ALTER TABLE "sessions"
3
+ ADD COLUMN "skills" jsonb DEFAULT '[]'::jsonb NOT NULL;
@@ -0,0 +1,15 @@
1
+ -- deployment-mode: rolling
2
+ -- Per-session model-visible selection for the broad first-party MCP server.
3
+ -- NULL resolves to the fixed minimal default; [] intentionally selects none.
4
+ ALTER TABLE "sessions"
5
+ ADD COLUMN "first_party_mcp_tools" jsonb;
6
+
7
+ ALTER TABLE "sessions"
8
+ ADD CONSTRAINT "sessions_first_party_mcp_tools_array_chk"
9
+ CHECK (
10
+ "first_party_mcp_tools" IS NULL
11
+ OR jsonb_typeof("first_party_mcp_tools") = 'array'
12
+ ) NOT VALID;
13
+
14
+ ALTER TABLE "sessions"
15
+ VALIDATE CONSTRAINT "sessions_first_party_mcp_tools_array_chk";
@@ -0,0 +1,359 @@
1
+ -- deployment-mode: maintenance
2
+ -- Machine inputs become append-only model memory. This one-way cutover also
3
+ -- reconstructs every previously delivered batch and removes the old deferred
4
+ -- retry state; old workers must be drained before this migration runs.
5
+
6
+ ALTER TABLE "session_system_updates"
7
+ ADD COLUMN "delivered_history_item_id" uuid;
8
+
9
+ UPDATE "session_system_updates"
10
+ SET "state" = 'pending'
11
+ WHERE "state" = 'deferred';
12
+
13
+ WITH ranked_pending_steers AS (
14
+ SELECT
15
+ id,
16
+ row_number() OVER (
17
+ PARTITION BY workspace_id, session_id
18
+ ORDER BY created_at DESC, id DESC
19
+ ) AS rank
20
+ FROM "session_system_updates"
21
+ WHERE kind = 'agent_steer_instruction'
22
+ AND state = 'pending'
23
+ )
24
+ UPDATE "session_system_updates" updates
25
+ SET state = 'superseded'
26
+ FROM ranked_pending_steers ranked
27
+ WHERE updates.id = ranked.id
28
+ AND ranked.rank > 1;
29
+
30
+ ALTER TABLE "session_system_updates"
31
+ DROP CONSTRAINT "system_updates_state_check";
32
+
33
+ ALTER TABLE "session_system_updates"
34
+ ADD CONSTRAINT "system_updates_state_check"
35
+ CHECK ("state" IN ('pending', 'delivered', 'cancelled', 'superseded', 'failed'));
36
+
37
+ WITH delivered_batches AS (
38
+ SELECT
39
+ updates.account_id,
40
+ updates.workspace_id,
41
+ updates.session_id,
42
+ updates.delivered_turn_id AS turn_id,
43
+ gen_random_uuid() AS history_item_id,
44
+ min(updates.delivered_at) AS delivered_at,
45
+ jsonb_build_object(
46
+ 'type', 'message',
47
+ 'role', 'system',
48
+ 'content',
49
+ '[OpenGeni internal updates]' || E'\n' ||
50
+ 'These platform updates were delivered together for this inference. They are not human prompts.' || E'\n' ||
51
+ jsonb_build_object(
52
+ 'updates',
53
+ jsonb_agg(
54
+ jsonb_build_object(
55
+ 'id', updates.id,
56
+ 'kind', updates.kind,
57
+ 'classification', updates.classification,
58
+ 'sourceId', updates.source_id,
59
+ 'summary', updates.summary,
60
+ 'payload', updates.payload,
61
+ 'lineage', updates.lineage
62
+ )
63
+ ORDER BY
64
+ CASE WHEN updates.kind = 'agent_steer_instruction' THEN 1 ELSE 0 END,
65
+ updates.created_at,
66
+ updates.id
67
+ )
68
+ )::text
69
+ ) AS item
70
+ FROM "session_system_updates" updates
71
+ WHERE updates.state = 'delivered'
72
+ AND updates.delivered_turn_id IS NOT NULL
73
+ AND updates.delivered_history_item_id IS NULL
74
+ GROUP BY
75
+ updates.account_id,
76
+ updates.workspace_id,
77
+ updates.session_id,
78
+ updates.delivered_turn_id
79
+ ),
80
+ anchored AS (
81
+ SELECT
82
+ batches.*,
83
+ turns.position AS turn_position,
84
+ turns.created_at AS turn_created_at,
85
+ turns.id AS turn_order_id,
86
+ NOT EXISTS (
87
+ SELECT 1
88
+ FROM "session_events" reset_event
89
+ WHERE reset_event.workspace_id = batches.workspace_id
90
+ AND reset_event.session_id = batches.session_id
91
+ AND reset_event.type IN ('session.context.compacted', 'session.context.cleared')
92
+ AND reset_event.occurred_at >= coalesce(batches.delivered_at, turns.created_at)
93
+ ) AS preserve_active,
94
+ (
95
+ SELECT max(history.position)
96
+ FROM "session_history_items" history
97
+ WHERE history.workspace_id = batches.workspace_id
98
+ AND history.session_id = batches.session_id
99
+ AND history.turn_id = batches.turn_id
100
+ AND history.active
101
+ AND history.item ->> 'type' = 'message'
102
+ AND history.item ->> 'role' = 'user'
103
+ ) AS user_position,
104
+ (
105
+ SELECT min(history.position)
106
+ FROM "session_history_items" history
107
+ WHERE history.workspace_id = batches.workspace_id
108
+ AND history.session_id = batches.session_id
109
+ AND history.turn_id = batches.turn_id
110
+ AND history.active
111
+ ) AS turn_first_position,
112
+ (
113
+ SELECT coalesce(max(history.position), -1)
114
+ FROM "session_history_items" history
115
+ WHERE history.workspace_id = batches.workspace_id
116
+ AND history.session_id = batches.session_id
117
+ AND history.active
118
+ ) AS active_tail_position,
119
+ (
120
+ SELECT coalesce(max(history.position), -1)
121
+ FROM "session_history_items" history
122
+ WHERE history.workspace_id = batches.workspace_id
123
+ AND history.session_id = batches.session_id
124
+ ) AS audit_tail_position,
125
+ (
126
+ SELECT max(history.position)
127
+ FROM "session_history_items" history
128
+ JOIN "session_turns" history_turn
129
+ ON history_turn.workspace_id = history.workspace_id
130
+ AND history_turn.id = history.turn_id
131
+ WHERE history.workspace_id = batches.workspace_id
132
+ AND history.session_id = batches.session_id
133
+ AND history.active
134
+ AND (history_turn.position, history_turn.created_at, history_turn.id)
135
+ < (turns.position, turns.created_at, turns.id)
136
+ ) AS previous_active_position,
137
+ (
138
+ SELECT min(history.position)
139
+ FROM "session_history_items" history
140
+ JOIN "session_turns" history_turn
141
+ ON history_turn.workspace_id = history.workspace_id
142
+ AND history_turn.id = history.turn_id
143
+ WHERE history.workspace_id = batches.workspace_id
144
+ AND history.session_id = batches.session_id
145
+ AND history.active
146
+ AND (history_turn.position, history_turn.created_at, history_turn.id)
147
+ > (turns.position, turns.created_at, turns.id)
148
+ ) AS next_active_position,
149
+ row_number() OVER (
150
+ PARTITION BY batches.workspace_id, batches.session_id
151
+ ORDER BY turns.position, turns.created_at, turns.id
152
+ ) AS turn_ordinal
153
+ FROM delivered_batches batches
154
+ JOIN "session_turns" turns
155
+ ON turns.workspace_id = batches.workspace_id
156
+ AND turns.id = batches.turn_id
157
+ ),
158
+ gap_ranked AS (
159
+ SELECT
160
+ anchored.*,
161
+ row_number() OVER (
162
+ PARTITION BY
163
+ workspace_id,
164
+ session_id,
165
+ preserve_active,
166
+ user_position,
167
+ turn_first_position,
168
+ previous_active_position,
169
+ next_active_position
170
+ ORDER BY turn_position, turn_created_at, turn_order_id
171
+ ) AS gap_ordinal,
172
+ count(*) OVER (
173
+ PARTITION BY
174
+ workspace_id,
175
+ session_id,
176
+ preserve_active,
177
+ user_position,
178
+ turn_first_position,
179
+ previous_active_position,
180
+ next_active_position
181
+ ) AS gap_count
182
+ FROM anchored
183
+ ),
184
+ positioned AS (
185
+ SELECT
186
+ gap_ranked.*,
187
+ CASE
188
+ WHEN NOT preserve_active THEN audit_tail_position + turn_ordinal
189
+ WHEN user_position IS NOT NULL THEN
190
+ user_position
191
+ + (
192
+ coalesce(
193
+ (
194
+ SELECT min(history.position)
195
+ FROM "session_history_items" history
196
+ WHERE history.workspace_id = gap_ranked.workspace_id
197
+ AND history.session_id = gap_ranked.session_id
198
+ AND history.position > user_position
199
+ ),
200
+ user_position + 1
201
+ )
202
+ - user_position
203
+ ) * gap_ordinal / (gap_count + 1)
204
+ WHEN turn_first_position IS NOT NULL THEN
205
+ coalesce(
206
+ (
207
+ SELECT max(history.position)
208
+ FROM "session_history_items" history
209
+ WHERE history.workspace_id = gap_ranked.workspace_id
210
+ AND history.session_id = gap_ranked.session_id
211
+ AND history.position < turn_first_position
212
+ ),
213
+ turn_first_position - 2
214
+ )
215
+ + (
216
+ turn_first_position
217
+ - coalesce(
218
+ (
219
+ SELECT max(history.position)
220
+ FROM "session_history_items" history
221
+ WHERE history.workspace_id = gap_ranked.workspace_id
222
+ AND history.session_id = gap_ranked.session_id
223
+ AND history.position < turn_first_position
224
+ ),
225
+ turn_first_position - 2
226
+ )
227
+ ) * gap_ordinal / (gap_count + 1)
228
+ WHEN previous_active_position IS NOT NULL AND next_active_position IS NOT NULL THEN
229
+ previous_active_position
230
+ + (next_active_position - previous_active_position) * gap_ordinal / (gap_count + 1)
231
+ WHEN previous_active_position IS NOT NULL THEN
232
+ previous_active_position + gap_ordinal
233
+ WHEN next_active_position IS NOT NULL THEN
234
+ next_active_position - (gap_count - gap_ordinal + 1)
235
+ ELSE active_tail_position + gap_ordinal
236
+ END AS history_position
237
+ FROM gap_ranked
238
+ ),
239
+ existing_gaps AS (
240
+ SELECT
241
+ positioned.*,
242
+ coalesce(
243
+ (
244
+ SELECT max(history.position)
245
+ FROM "session_history_items" history
246
+ WHERE history.workspace_id = positioned.workspace_id
247
+ AND history.session_id = positioned.session_id
248
+ AND history.position < positioned.history_position
249
+ ),
250
+ positioned.history_position - 1
251
+ ) AS existing_gap_left,
252
+ coalesce(
253
+ (
254
+ SELECT min(history.position)
255
+ FROM "session_history_items" history
256
+ WHERE history.workspace_id = positioned.workspace_id
257
+ AND history.session_id = positioned.session_id
258
+ AND history.position > positioned.history_position
259
+ ),
260
+ positioned.history_position + 1
261
+ ) AS existing_gap_right
262
+ FROM positioned
263
+ ),
264
+ final_positioned AS (
265
+ SELECT
266
+ existing_gaps.*,
267
+ existing_gap_left
268
+ + (existing_gap_right - existing_gap_left)
269
+ * row_number() OVER (
270
+ PARTITION BY
271
+ workspace_id,
272
+ session_id,
273
+ existing_gap_left,
274
+ existing_gap_right
275
+ ORDER BY history_position, turn_position, turn_created_at, turn_order_id
276
+ )
277
+ / (
278
+ count(*) OVER (
279
+ PARTITION BY
280
+ workspace_id,
281
+ session_id,
282
+ existing_gap_left,
283
+ existing_gap_right
284
+ ) + 1
285
+ ) AS final_history_position
286
+ FROM existing_gaps
287
+ ),
288
+ inserted AS (
289
+ INSERT INTO "session_history_items" (
290
+ "id",
291
+ "account_id",
292
+ "workspace_id",
293
+ "session_id",
294
+ "turn_id",
295
+ "position",
296
+ "item",
297
+ "active",
298
+ "producer_codex_credential_id",
299
+ "created_at"
300
+ )
301
+ SELECT
302
+ history_item_id,
303
+ account_id,
304
+ workspace_id,
305
+ session_id,
306
+ turn_id,
307
+ final_history_position,
308
+ item,
309
+ preserve_active,
310
+ NULL,
311
+ coalesce(delivered_at, now())
312
+ FROM final_positioned
313
+ RETURNING id, workspace_id, session_id, turn_id
314
+ )
315
+ UPDATE "session_system_updates" updates
316
+ SET "delivered_history_item_id" = inserted.id
317
+ FROM inserted
318
+ WHERE updates.workspace_id = inserted.workspace_id
319
+ AND updates.session_id = inserted.session_id
320
+ AND updates.delivered_turn_id = inserted.turn_id
321
+ AND updates.state = 'delivered'
322
+ AND updates.delivered_history_item_id IS NULL;
323
+
324
+ DO $$
325
+ BEGIN
326
+ IF EXISTS (
327
+ SELECT 1
328
+ FROM "session_system_updates"
329
+ WHERE state = 'delivered'
330
+ AND delivered_history_item_id IS NULL
331
+ ) THEN
332
+ RAISE EXCEPTION
333
+ 'cannot prove model-memory position for one or more delivered machine inputs';
334
+ END IF;
335
+ END
336
+ $$;
337
+
338
+ ALTER TABLE "session_system_updates"
339
+ ADD CONSTRAINT "session_system_updates_history_item_fk"
340
+ FOREIGN KEY ("delivered_history_item_id")
341
+ REFERENCES "session_history_items"("id")
342
+ ON DELETE RESTRICT
343
+ DEFERRABLE INITIALLY DEFERRED;
344
+
345
+ ALTER TABLE "session_system_updates"
346
+ ADD CONSTRAINT "session_system_updates_delivery_history_check"
347
+ CHECK (
348
+ ("state" = 'delivered' AND "delivered_history_item_id" IS NOT NULL)
349
+ OR
350
+ ("state" <> 'delivered' AND "delivered_history_item_id" IS NULL)
351
+ );
352
+
353
+ CREATE INDEX "session_system_updates_history_item_idx"
354
+ ON "session_system_updates" ("workspace_id", "delivered_history_item_id")
355
+ WHERE "delivered_history_item_id" IS NOT NULL;
356
+
357
+ CREATE UNIQUE INDEX "session_system_updates_one_pending_steer_idx"
358
+ ON "session_system_updates" ("workspace_id", "session_id")
359
+ WHERE "kind" = 'agent_steer_instruction' AND "state" = 'pending';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "OpenGeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -51,8 +51,8 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@opengeni/codex": "^0.2.7",
54
- "@opengeni/config": "^0.7.8",
55
- "@opengeni/contracts": "^0.20.1",
54
+ "@opengeni/config": "^0.7.11",
55
+ "@opengeni/contracts": "^0.22.0",
56
56
  "@opengeni/network": "^0.1.1",
57
57
  "drizzle-orm": "^0.45.2",
58
58
  "postgres": "^3.4.7"
@@ -369,11 +369,15 @@ export function buildConnectionTokenResolver(
369
369
  const load = async (
370
370
  input: CredentialLookupInput,
371
371
  ): Promise<ConnectionCredentialForBroker | null> => {
372
+ const subjectOwned = input.connectionRef.subjectScope === "subject";
373
+ if (subjectOwned && !input.subjectId) {
374
+ return null;
375
+ }
372
376
  const request: Parameters<typeof loadConnectionCredentialForBroker>[2] = {
373
377
  workspaceId: input.workspaceId,
374
378
  providerDomain: input.connectionRef.providerDomain,
375
- // I1 deliberately accepts workspace-shared connections only at runtime.
376
- allowSubjectOwned: false,
379
+ allowSubjectOwned: subjectOwned,
380
+ ...(subjectOwned ? { subjectId: input.subjectId! } : {}),
377
381
  };
378
382
  if (input.connectionRef.connectionId !== undefined) {
379
383
  request.connectionId = input.connectionRef.connectionId;
@@ -381,10 +385,12 @@ export function buildConnectionTokenResolver(
381
385
  if (input.connectionRef.kind !== undefined) {
382
386
  request.kind = input.connectionRef.kind;
383
387
  }
384
- if (input.subjectId !== undefined) {
385
- request.subjectId = input.subjectId;
388
+ const credential = await deps.loadCredential(db, settings, request);
389
+ if (!credential) return null;
390
+ if (subjectOwned) {
391
+ return credential.subjectId === input.subjectId ? credential : null;
386
392
  }
387
- return deps.loadCredential(db, settings, request);
393
+ return credential.subjectId === null ? credential : null;
388
394
  };
389
395
 
390
396
  const snapshot = async (
@@ -428,7 +434,7 @@ export function buildConnectionTokenResolver(
428
434
  : {}),
429
435
  };
430
436
  }
431
- await deps.recordUsed(db, cred.workspaceId, cred.id);
437
+ await deps.recordUsed(db, cred.workspaceId, cred.id, cred.subjectId);
432
438
  return {
433
439
  status: "ok",
434
440
  headers,
@@ -453,6 +459,7 @@ export function buildConnectionTokenResolver(
453
459
  credentialEncrypted: deps.encrypt(key, JSON.stringify(refreshed.credential)),
454
460
  expiresAt: refreshed.expiresAt,
455
461
  lastRefreshAt: deps.now(),
462
+ subjectId: cred.subjectId,
456
463
  };
457
464
  if (refreshed.grantedScopes !== undefined) {
458
465
  refreshRecord.grantedScopes = refreshed.grantedScopes;
@@ -462,6 +469,7 @@ export function buildConnectionTokenResolver(
462
469
  const current = await load({
463
470
  workspaceId: cred.workspaceId,
464
471
  connectionRef: { ...ref, connectionId: cred.id },
472
+ ...(cred.subjectId ? { subjectId: cred.subjectId } : {}),
465
473
  });
466
474
  if (current) {
467
475
  return current;
@@ -470,6 +478,7 @@ export function buildConnectionTokenResolver(
470
478
  const winner = await load({
471
479
  workspaceId: cred.workspaceId,
472
480
  connectionRef: { ...ref, connectionId: cred.id },
481
+ ...(cred.subjectId ? { subjectId: cred.subjectId } : {}),
473
482
  });
474
483
  if (winner?.status === "active") {
475
484
  return winner;
@@ -481,7 +490,7 @@ export function buildConnectionTokenResolver(
481
490
  cred: ConnectionCredentialForBroker,
482
491
  ref: McpServerConnectionRef,
483
492
  ): Promise<ConnectionCredentialForBroker> => {
484
- const key = `${cred.id}:${cred.version}`;
493
+ const key = `${cred.subjectId ?? "workspace"}:${cred.id}:${cred.version}`;
485
494
  const existing = inflight.get(key);
486
495
  if (existing) {
487
496
  return existing;
@@ -538,6 +547,7 @@ export function buildConnectionTokenResolver(
538
547
  {
539
548
  id: cred.id,
540
549
  version: cred.version,
550
+ subjectId: cred.subjectId,
541
551
  },
542
552
  )
543
553
  .catch(() => undefined);