@opengeni/db 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/chunk-ELMUCYZF.js +906 -0
  2. package/dist/chunk-ELMUCYZF.js.map +1 -0
  3. package/dist/chunk-N4WTE6SB.js +4033 -0
  4. package/dist/chunk-N4WTE6SB.js.map +1 -0
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +3594 -2059
  7. package/dist/index.js.map +1 -1
  8. package/dist/provision-roles.d.ts +472 -43
  9. package/dist/provision-roles.js +1 -1
  10. package/dist/{schema-BejThLcd.d.ts → schema-Bh1Hr7xY.d.ts} +2507 -970
  11. package/dist/schema.d.ts +1 -1
  12. package/dist/schema.js +9 -1
  13. package/drizzle/0122_codex_capacity_same_turn.sql +59 -0
  14. package/drizzle/0123_session_tool_policy_version.sql +14 -0
  15. package/drizzle/0124_session_event_duplicate_lookup.sql +4 -0
  16. package/drizzle/0125_document_drops_visibility.sql +29 -0
  17. package/drizzle/0126_document_access_constraints.sql +138 -0
  18. package/drizzle/0127_document_default_base_index.sql +5 -0
  19. package/drizzle/0128_github_installation_authority.sql +69 -0
  20. package/drizzle/0129_retained_process_reconciliation.sql +356 -0
  21. package/drizzle/0130_workspace_instruction_policies.sql +330 -0
  22. package/drizzle/0131_slack_bot_install_and_post_idempotency.sql +133 -0
  23. package/package.json +4 -3
  24. package/src/event-payload-sanitizer.ts +167 -55
  25. package/src/index.ts +1850 -264
  26. package/src/new-session-drafts.ts +127 -6
  27. package/src/provision-roles.ts +184 -2
  28. package/src/runtime-posture-cli.ts +57 -0
  29. package/src/runtime-posture.ts +770 -0
  30. package/src/schema.ts +243 -3
  31. package/src/session-control.ts +1 -0
  32. package/src/workspace-instruction-policies-schema.ts +140 -0
  33. package/src/workspace-instruction-policies.ts +624 -0
  34. package/dist/chunk-BMFDXFPA.js +0 -155
  35. package/dist/chunk-BMFDXFPA.js.map +0 -1
  36. package/dist/chunk-VUKRIBO5.js +0 -3679
  37. package/dist/chunk-VUKRIBO5.js.map +0 -1
@@ -0,0 +1,356 @@
1
+ -- deployment-mode: rolling
2
+ -- Terminal owners do not prove provider quiescence. Persist only a
3
+ -- bounded/fair reconciliation claim here; the worker must still obtain an exact
4
+ -- provider exit/loss proof before invoking canonical retained-process settlement.
5
+
6
+ ALTER TABLE "sandbox_retained_processes"
7
+ ADD COLUMN IF NOT EXISTS "reconcile_after" timestamptz NOT NULL DEFAULT now(),
8
+ ADD COLUMN IF NOT EXISTS "reconcile_claim_id" uuid,
9
+ ADD COLUMN IF NOT EXISTS "reconcile_claimed_at" timestamptz,
10
+ ADD COLUMN IF NOT EXISTS "reconcile_attempts" integer NOT NULL DEFAULT 0,
11
+ ADD COLUMN IF NOT EXISTS "last_reconcile_outcome" text,
12
+ ADD COLUMN IF NOT EXISTS "reconcile_proof_outcome" text,
13
+ ADD COLUMN IF NOT EXISTS "reconcile_proof_exit_code" integer,
14
+ ADD COLUMN IF NOT EXISTS "reconcile_proof_reason" text,
15
+ ADD COLUMN IF NOT EXISTS "reconcile_proof_observed_at" timestamptz;
16
+
17
+ ALTER TABLE "sandbox_retained_processes"
18
+ ADD CONSTRAINT "sandbox_retained_processes_reconcile_claim_check"
19
+ CHECK (
20
+ ("reconcile_claim_id" IS NULL AND "reconcile_claimed_at" IS NULL)
21
+ OR ("reconcile_claim_id" IS NOT NULL AND "reconcile_claimed_at" IS NOT NULL)
22
+ ),
23
+ ADD CONSTRAINT "sandbox_retained_processes_reconcile_attempts_check"
24
+ CHECK ("reconcile_attempts" >= 0),
25
+ ADD CONSTRAINT "sandbox_retained_processes_reconcile_outcome_check"
26
+ CHECK (
27
+ "last_reconcile_outcome" IS NULL
28
+ OR octet_length("last_reconcile_outcome") BETWEEN 1 AND 64
29
+ ),
30
+ ADD CONSTRAINT "sandbox_retained_processes_reconcile_proof_check"
31
+ CHECK (
32
+ (
33
+ "reconcile_proof_outcome" IS NULL
34
+ AND "reconcile_proof_exit_code" IS NULL
35
+ AND "reconcile_proof_reason" IS NULL
36
+ AND "reconcile_proof_observed_at" IS NULL
37
+ ) OR (
38
+ "reconcile_proof_outcome" = 'exited'
39
+ AND "reconcile_proof_exit_code" IS NOT NULL
40
+ AND "reconcile_proof_reason" = 'provider_exit_banner'
41
+ AND "reconcile_proof_observed_at" IS NOT NULL
42
+ ) OR (
43
+ "reconcile_proof_outcome" = 'lost'
44
+ AND "reconcile_proof_exit_code" IS NULL
45
+ AND "reconcile_proof_reason" IN (
46
+ 'provider_session_lost_banner', 'provider_instance_not_found'
47
+ )
48
+ AND "reconcile_proof_observed_at" IS NOT NULL
49
+ )
50
+ );
51
+
52
+ CREATE INDEX "sandbox_retained_processes_reconcile_due_idx"
53
+ ON "sandbox_retained_processes" ("reconcile_after", "started_at", "id")
54
+ WHERE "state" = 'active';
55
+
56
+ CREATE INDEX "sandbox_retained_processes_active_inventory_idx"
57
+ ON "sandbox_retained_processes" (
58
+ "owner_actor_kind", "workspace_id", "owner_turn_id", "owner_attempt_id"
59
+ )
60
+ WHERE "state" = 'active';
61
+
62
+ CREATE INDEX "sandbox_leases_expired_draining_inventory_idx"
63
+ ON "sandbox_leases" ("expires_at", "backend")
64
+ WHERE "liveness" = 'draining';
65
+
66
+ -- The data schema is selected by the migration caller and may be public or a
67
+ -- dedicated embed schema. Bind it into every privileged statement at creation
68
+ -- time, then expose only pg_catalog at execution time. pg_temp and the caller's
69
+ -- search_path can therefore resolve neither relations nor helper functions.
70
+ DO $privileged_functions$
71
+ DECLARE data_schema text := current_schema();
72
+ BEGIN
73
+ -- The claim update fires this pre-existing deferred trigger while the claim
74
+ -- function's pg_catalog-only path is active. Replace its 0117 body in place so
75
+ -- the transitive identity check is equally independent of caller/pg_temp path.
76
+ EXECUTE format($create$
77
+ CREATE OR REPLACE FUNCTION opengeni_private.validate_sandbox_retained_process_v2()
78
+ RETURNS trigger
79
+ LANGUAGE plpgsql
80
+ SET search_path = pg_catalog
81
+ AS $function$
82
+ BEGIN
83
+ IF NOT EXISTS (
84
+ SELECT 1
85
+ FROM %1$I.sandbox_workspace_mutation_admissions admission
86
+ WHERE admission.id = NEW.parent_admission_id
87
+ AND admission.account_id = NEW.account_id
88
+ AND admission.workspace_id = NEW.workspace_id
89
+ AND admission.session_id = NEW.session_id
90
+ AND admission.lease_id = NEW.lease_id
91
+ AND admission.sandbox_group_id = NEW.sandbox_group_id
92
+ AND admission.actor_kind = NEW.owner_actor_kind
93
+ AND admission.actor_id = NEW.owner_actor_id
94
+ AND admission.turn_id IS NOT DISTINCT FROM NEW.owner_turn_id
95
+ AND admission.attempt_id IS NOT DISTINCT FROM NEW.owner_attempt_id
96
+ AND admission.execution_generation IS NOT DISTINCT FROM NEW.owner_execution_generation
97
+ AND admission.lease_epoch = NEW.lease_epoch
98
+ AND admission.provider_backend = NEW.provider_backend
99
+ AND admission.provider_instance_id = NEW.provider_instance_id
100
+ AND admission.route_kind = NEW.route_kind
101
+ AND admission.route_target_id IS NOT DISTINCT FROM NEW.route_target_id
102
+ AND admission.route_epoch = NEW.route_epoch
103
+ AND (
104
+ (
105
+ NEW.state = 'active'
106
+ AND admission.provider_outcome = 'retained'
107
+ AND admission.settled_at IS NULL
108
+ AND EXISTS (
109
+ SELECT 1 FROM %1$I.sandbox_lease_holders holder
110
+ WHERE holder.lease_id = NEW.lease_id
111
+ AND holder.account_id = NEW.account_id
112
+ AND holder.workspace_id = NEW.workspace_id
113
+ AND holder.kind = 'process'
114
+ AND holder.holder_id = NEW.holder_id
115
+ AND holder.subject_id = NEW.session_id
116
+ )
117
+ ) OR (
118
+ NEW.state IN ('exited', 'lost')
119
+ AND admission.provider_outcome IN ('resolved', 'rejected')
120
+ AND admission.settled_at IS NOT NULL
121
+ AND NOT EXISTS (
122
+ SELECT 1 FROM %1$I.sandbox_lease_holders holder
123
+ WHERE holder.lease_id = NEW.lease_id
124
+ AND holder.kind = 'process'
125
+ AND holder.holder_id = NEW.holder_id
126
+ )
127
+ AND NOT EXISTS (
128
+ SELECT 1 FROM %1$I.sandbox_pty_sessions pty
129
+ WHERE pty.retained_process_id = NEW.id
130
+ AND pty.status = 'open'
131
+ )
132
+ )
133
+ )
134
+ ) THEN
135
+ RAISE EXCEPTION 'retained process does not match its parent admission and holder state'
136
+ USING ERRCODE = '55000';
137
+ END IF;
138
+ RETURN NEW;
139
+ END;
140
+ $function$;
141
+ $create$, data_schema);
142
+
143
+ -- Cross-workspace claim for the sole control-worker reaper. The index-backed
144
+ -- candidate window is locked and limited before owner eligibility joins. A
145
+ -- live owner is deferred so it cannot permanently occupy the oldest due edge.
146
+ -- Claim expiry is represented by reconcile_after; it remains coordination
147
+ -- recovery only and is never physical-exit proof.
148
+ EXECUTE format($create$
149
+ CREATE OR REPLACE FUNCTION opengeni_private.claim_terminal_retained_processes(
150
+ p_claim_id uuid,
151
+ p_limit integer,
152
+ p_claim_ttl_ms bigint
153
+ )
154
+ RETURNS TABLE (
155
+ account_id uuid,
156
+ workspace_id uuid,
157
+ session_id uuid,
158
+ process_id uuid,
159
+ claim_id uuid,
160
+ owner_state text,
161
+ owner_attempt_outcome text
162
+ )
163
+ LANGUAGE plpgsql
164
+ SECURITY DEFINER
165
+ SET search_path = pg_catalog
166
+ AS $function$
167
+ BEGIN
168
+ IF p_limit < 1 OR p_limit > 100 THEN
169
+ RAISE EXCEPTION 'retained-process reconciliation limit must be between 1 and 100'
170
+ USING ERRCODE = '22023';
171
+ END IF;
172
+ IF p_claim_ttl_ms < 0 OR p_claim_ttl_ms > 3600000 THEN
173
+ RAISE EXCEPTION 'retained-process reconciliation claim TTL is invalid'
174
+ USING ERRCODE = '22023';
175
+ END IF;
176
+
177
+ PERFORM pg_catalog.set_config('opengeni.sandbox_recovery_protocol_v2', '1', true);
178
+
179
+ RETURN QUERY
180
+ WITH candidate_window AS MATERIALIZED (
181
+ SELECT process.id,
182
+ process.workspace_id,
183
+ process.owner_actor_kind,
184
+ process.owner_turn_id,
185
+ process.owner_attempt_id,
186
+ process.reconcile_after AS due_at,
187
+ process.started_at
188
+ FROM %1$I.sandbox_retained_processes process
189
+ WHERE process.state = 'active'
190
+ AND process.reconcile_after <= pg_catalog.now()
191
+ ORDER BY process.reconcile_after, process.started_at, process.id
192
+ FOR UPDATE OF process SKIP LOCKED
193
+ LIMIT p_limit
194
+ ), classified AS MATERIALIZED (
195
+ SELECT candidate.id,
196
+ candidate.due_at,
197
+ candidate.started_at,
198
+ candidate.owner_actor_kind = 'direct' OR attempt.state = 'closed' AS eligible,
199
+ CASE
200
+ WHEN candidate.owner_actor_kind = 'direct' THEN 'direct'
201
+ ELSE COALESCE(turn_row.status, 'missing')
202
+ END AS owner_state,
203
+ attempt.outcome AS owner_attempt_outcome
204
+ FROM candidate_window candidate
205
+ LEFT JOIN LATERAL (
206
+ SELECT source_turn.status
207
+ FROM %1$I.session_turns source_turn
208
+ WHERE source_turn.workspace_id = candidate.workspace_id
209
+ AND source_turn.id = candidate.owner_turn_id
210
+ LIMIT 1
211
+ ) turn_row ON true
212
+ LEFT JOIN LATERAL (
213
+ SELECT source_attempt.state, source_attempt.outcome
214
+ FROM %1$I.session_turn_attempts source_attempt
215
+ WHERE source_attempt.workspace_id = candidate.workspace_id
216
+ AND source_attempt.id = candidate.owner_attempt_id
217
+ LIMIT 1
218
+ ) attempt ON true
219
+ ), inspected AS (
220
+ UPDATE %1$I.sandbox_retained_processes process SET
221
+ reconcile_after = CASE
222
+ WHEN classified.eligible THEN pg_catalog.now()
223
+ + pg_catalog.make_interval(secs => p_claim_ttl_ms / 1000.0)
224
+ ELSE pg_catalog.now() + interval '30 seconds'
225
+ END,
226
+ reconcile_claim_id = CASE WHEN classified.eligible THEN p_claim_id ELSE NULL END,
227
+ reconcile_claimed_at = CASE
228
+ WHEN classified.eligible THEN pg_catalog.now() ELSE NULL
229
+ END,
230
+ reconcile_attempts = process.reconcile_attempts
231
+ + CASE WHEN classified.eligible THEN 1 ELSE 0 END,
232
+ last_reconcile_outcome = CASE
233
+ WHEN classified.eligible THEN 'claimed' ELSE 'owner_active'
234
+ END
235
+ FROM classified
236
+ WHERE process.id = classified.id
237
+ AND process.state = 'active'
238
+ RETURNING process.account_id,
239
+ process.workspace_id,
240
+ process.session_id,
241
+ process.id,
242
+ process.reconcile_claim_id,
243
+ classified.due_at,
244
+ classified.started_at,
245
+ classified.eligible,
246
+ classified.owner_state,
247
+ classified.owner_attempt_outcome
248
+ )
249
+ SELECT inspected.account_id,
250
+ inspected.workspace_id,
251
+ inspected.session_id,
252
+ inspected.id,
253
+ inspected.reconcile_claim_id,
254
+ inspected.owner_state,
255
+ inspected.owner_attempt_outcome
256
+ FROM inspected
257
+ WHERE inspected.eligible
258
+ ORDER BY inspected.due_at, inspected.started_at, inspected.id;
259
+
260
+ -- Fire the exact data-schema constraint trigger while the definer still
261
+ -- owns cross-workspace visibility, then restore deferred mode.
262
+ SET CONSTRAINTS %1$I.sandbox_retained_processes_identity_v2 IMMEDIATE;
263
+ SET CONSTRAINTS %1$I.sandbox_retained_processes_identity_v2 DEFERRED;
264
+ END;
265
+ $function$;
266
+ $create$, data_schema);
267
+
268
+ -- Fixed-shape global inventories use covering partial indexes over only the
269
+ -- active/draining subsets; retained history and cold/warm lease inventory are
270
+ -- not scanned by a reaper tick. The application normalizes legacy values into
271
+ -- its finite label sets.
272
+ EXECUTE format($create$
273
+ CREATE OR REPLACE FUNCTION opengeni_private.count_active_retained_processes_by_owner_state()
274
+ RETURNS TABLE (owner_state text, active_count bigint, terminal_owner_count bigint)
275
+ LANGUAGE sql
276
+ SECURITY DEFINER
277
+ SET search_path = pg_catalog
278
+ AS $function$
279
+ SELECT inventory.owner_state,
280
+ pg_catalog.count(*)::bigint AS active_count,
281
+ pg_catalog.count(*) FILTER (WHERE inventory.terminal_owner)::bigint
282
+ AS terminal_owner_count
283
+ FROM (
284
+ SELECT 'direct'::text AS owner_state, true AS terminal_owner
285
+ FROM %1$I.sandbox_retained_processes process
286
+ WHERE process.state = 'active'
287
+ AND process.owner_actor_kind = 'direct'
288
+ UNION ALL
289
+ SELECT COALESCE(turn_row.status, 'missing') AS owner_state,
290
+ attempt.state = 'closed' AS terminal_owner
291
+ FROM %1$I.sandbox_retained_processes process
292
+ LEFT JOIN LATERAL (
293
+ SELECT source_turn.status
294
+ FROM %1$I.session_turns source_turn
295
+ WHERE source_turn.workspace_id = process.workspace_id
296
+ AND source_turn.id = process.owner_turn_id
297
+ LIMIT 1
298
+ ) turn_row ON true
299
+ LEFT JOIN LATERAL (
300
+ SELECT source_attempt.state
301
+ FROM %1$I.session_turn_attempts source_attempt
302
+ WHERE source_attempt.workspace_id = process.workspace_id
303
+ AND source_attempt.id = process.owner_attempt_id
304
+ LIMIT 1
305
+ ) attempt ON true
306
+ WHERE process.state = 'active'
307
+ AND process.owner_actor_kind = 'turn'
308
+ ) inventory
309
+ GROUP BY inventory.owner_state;
310
+ $function$;
311
+ $create$, data_schema);
312
+
313
+ EXECUTE format($create$
314
+ CREATE OR REPLACE FUNCTION opengeni_private.count_expired_draining_sandbox_leases()
315
+ RETURNS TABLE (backend text, age_bucket text, count bigint)
316
+ LANGUAGE sql
317
+ SECURITY DEFINER
318
+ SET search_path = pg_catalog
319
+ AS $function$
320
+ SELECT lease.backend,
321
+ CASE
322
+ WHEN pg_catalog.now() - lease.expires_at < interval '5 minutes' THEN 'lt_5m'
323
+ WHEN pg_catalog.now() - lease.expires_at < interval '1 hour' THEN '5m_1h'
324
+ WHEN pg_catalog.now() - lease.expires_at < interval '1 day' THEN '1h_1d'
325
+ ELSE 'gte_1d'
326
+ END AS age_bucket,
327
+ pg_catalog.count(*)::bigint
328
+ FROM %1$I.sandbox_leases lease
329
+ WHERE lease.liveness = 'draining'
330
+ AND lease.expires_at < pg_catalog.now()
331
+ GROUP BY lease.backend, age_bucket;
332
+ $function$;
333
+ $create$, data_schema);
334
+ END $privileged_functions$;
335
+
336
+ -- PostgreSQL grants new functions to PUBLIC by default. These functions bypass
337
+ -- workspace RLS, so only the migration owner and explicit application role may
338
+ -- execute them.
339
+ REVOKE ALL ON FUNCTION opengeni_private.claim_terminal_retained_processes(uuid, integer, bigint)
340
+ FROM PUBLIC;
341
+ REVOKE ALL ON FUNCTION opengeni_private.count_active_retained_processes_by_owner_state()
342
+ FROM PUBLIC;
343
+ REVOKE ALL ON FUNCTION opengeni_private.count_expired_draining_sandbox_leases()
344
+ FROM PUBLIC;
345
+
346
+ DO $$
347
+ BEGIN
348
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
349
+ GRANT EXECUTE ON FUNCTION opengeni_private.claim_terminal_retained_processes(uuid, integer, bigint)
350
+ TO opengeni_app;
351
+ GRANT EXECUTE ON FUNCTION opengeni_private.count_active_retained_processes_by_owner_state()
352
+ TO opengeni_app;
353
+ GRANT EXECUTE ON FUNCTION opengeni_private.count_expired_draining_sandbox_leases()
354
+ TO opengeni_app;
355
+ END IF;
356
+ END $$;
@@ -0,0 +1,330 @@
1
+ -- deployment-mode: rolling
2
+ -- Backend-only workspace instruction-policy foundation. This migration performs no
3
+ -- backfill: workspaces without an activation head retain legacy behavior.
4
+
5
+ CREATE SEQUENCE IF NOT EXISTS "workspace_instruction_policy_revision_seq" AS bigint;
6
+
7
+ CREATE TABLE IF NOT EXISTS "workspace_instruction_policy_revisions" (
8
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
9
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
10
+ "workspace_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE CASCADE,
11
+ "revision" bigint NOT NULL DEFAULT nextval('workspace_instruction_policy_revision_seq'),
12
+ "kind" text NOT NULL,
13
+ "scope" text NOT NULL,
14
+ "role_key" text,
15
+ "content" text NOT NULL,
16
+ "content_hash" text NOT NULL,
17
+ "provenance_source" text NOT NULL,
18
+ "provenance_source_id" text,
19
+ "supersedes_revision_id" uuid REFERENCES "workspace_instruction_policy_revisions"("id") ON DELETE RESTRICT,
20
+ "created_by_subject_id" text NOT NULL,
21
+ "created_at" timestamptz NOT NULL DEFAULT now(),
22
+ CONSTRAINT "workspace_instruction_policy_revisions_revision_chk" CHECK ("revision" > 0),
23
+ CONSTRAINT "workspace_instruction_policy_revisions_target_chk" CHECK (
24
+ ("kind" = 'charter' AND "scope" = 'global' AND "role_key" IS NULL)
25
+ OR ("kind" = 'policy' AND "scope" = 'global' AND "role_key" IS NULL)
26
+ OR ("kind" = 'policy' AND "scope" = 'role' AND "role_key" IS NOT NULL)
27
+ ),
28
+ CONSTRAINT "workspace_instruction_policy_revisions_role_key_chk" CHECK (
29
+ "role_key" IS NULL
30
+ OR (
31
+ "role_key" = lower(btrim("role_key"))
32
+ AND length("role_key") BETWEEN 1 AND 64
33
+ AND "role_key" ~ '^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$'
34
+ AND "role_key" !~ '--'
35
+ )
36
+ ),
37
+ CONSTRAINT "workspace_instruction_policy_revisions_content_chk" CHECK (
38
+ length(btrim("content")) > 0 AND octet_length("content") <= 1048576
39
+ ),
40
+ CONSTRAINT "workspace_instruction_policy_revisions_hash_chk" CHECK (
41
+ "content_hash" ~ '^[0-9a-f]{64}$'
42
+ AND "content_hash" = encode(sha256(convert_to("content", 'UTF8')), 'hex')
43
+ ),
44
+ CONSTRAINT "workspace_instruction_policy_revisions_provenance_chk" CHECK (
45
+ "provenance_source" IN ('human', 'onboarding', 'knowledge_proposal', 'legacy_import')
46
+ AND ("provenance_source_id" IS NULL OR length("provenance_source_id") BETWEEN 1 AND 512)
47
+ ),
48
+ CONSTRAINT "workspace_instruction_policy_revisions_actor_chk" CHECK (
49
+ length(btrim("created_by_subject_id")) BETWEEN 1 AND 1024
50
+ ),
51
+ CONSTRAINT "workspace_instruction_policy_revisions_workspace_revision_uq"
52
+ UNIQUE ("workspace_id", "revision")
53
+ );
54
+
55
+ CREATE INDEX IF NOT EXISTS "workspace_instruction_policy_revisions_workspace_history_idx"
56
+ ON "workspace_instruction_policy_revisions"
57
+ ("workspace_id", "kind", "scope", "role_key", "revision" DESC);
58
+
59
+ CREATE TABLE IF NOT EXISTS "workspace_instruction_policy_heads" (
60
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
61
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
62
+ "workspace_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE CASCADE,
63
+ "kind" text NOT NULL,
64
+ "scope" text NOT NULL,
65
+ "role_key" text,
66
+ "revision_id" uuid NOT NULL REFERENCES "workspace_instruction_policy_revisions"("id") ON DELETE RESTRICT,
67
+ "revision" bigint NOT NULL,
68
+ "content_hash" text NOT NULL,
69
+ "activation_version" bigint NOT NULL,
70
+ "activated_at" timestamptz NOT NULL DEFAULT now(),
71
+ CONSTRAINT "workspace_instruction_policy_heads_target_chk" CHECK (
72
+ ("kind" = 'charter' AND "scope" = 'global' AND "role_key" IS NULL)
73
+ OR ("kind" = 'policy' AND "scope" = 'global' AND "role_key" IS NULL)
74
+ OR ("kind" = 'policy' AND "scope" = 'role' AND "role_key" IS NOT NULL)
75
+ ),
76
+ CONSTRAINT "workspace_instruction_policy_heads_role_key_chk" CHECK (
77
+ "role_key" IS NULL
78
+ OR (
79
+ "role_key" = lower(btrim("role_key"))
80
+ AND length("role_key") BETWEEN 1 AND 64
81
+ AND "role_key" ~ '^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$'
82
+ AND "role_key" !~ '--'
83
+ )
84
+ ),
85
+ CONSTRAINT "workspace_instruction_policy_heads_revision_chk" CHECK (
86
+ "revision" > 0 AND "activation_version" > 0 AND "content_hash" ~ '^[0-9a-f]{64}$'
87
+ )
88
+ );
89
+
90
+ CREATE UNIQUE INDEX IF NOT EXISTS "workspace_instruction_policy_heads_charter_uq"
91
+ ON "workspace_instruction_policy_heads" ("workspace_id")
92
+ WHERE "kind" = 'charter';
93
+
94
+ CREATE UNIQUE INDEX IF NOT EXISTS "workspace_instruction_policy_heads_global_policy_uq"
95
+ ON "workspace_instruction_policy_heads" ("workspace_id")
96
+ WHERE "kind" = 'policy' AND "scope" = 'global';
97
+
98
+ CREATE UNIQUE INDEX IF NOT EXISTS "workspace_instruction_policy_heads_role_policy_uq"
99
+ ON "workspace_instruction_policy_heads" ("workspace_id", "role_key")
100
+ WHERE "kind" = 'policy' AND "scope" = 'role';
101
+
102
+ CREATE TABLE IF NOT EXISTS "workspace_instruction_policy_activation_events" (
103
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
104
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
105
+ "workspace_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE CASCADE,
106
+ "kind" text NOT NULL,
107
+ "scope" text NOT NULL,
108
+ "role_key" text,
109
+ "type" text NOT NULL,
110
+ "activation_version" bigint NOT NULL,
111
+ "old_revision_id" uuid REFERENCES "workspace_instruction_policy_revisions"("id") ON DELETE RESTRICT,
112
+ "old_revision" bigint,
113
+ "old_content_hash" text,
114
+ "new_revision_id" uuid NOT NULL REFERENCES "workspace_instruction_policy_revisions"("id") ON DELETE RESTRICT,
115
+ "new_revision" bigint NOT NULL,
116
+ "new_content_hash" text NOT NULL,
117
+ "actor_subject_id" text NOT NULL,
118
+ "reason" text NOT NULL,
119
+ "created_at" timestamptz NOT NULL DEFAULT now(),
120
+ CONSTRAINT "workspace_instruction_policy_activation_events_type_chk" CHECK (
121
+ "type" IN ('activate', 'rollback')
122
+ ),
123
+ CONSTRAINT "workspace_instruction_policy_activation_events_target_chk" CHECK (
124
+ ("kind" = 'charter' AND "scope" = 'global' AND "role_key" IS NULL)
125
+ OR ("kind" = 'policy' AND "scope" = 'global' AND "role_key" IS NULL)
126
+ OR ("kind" = 'policy' AND "scope" = 'role' AND "role_key" IS NOT NULL)
127
+ ),
128
+ CONSTRAINT "workspace_instruction_policy_activation_events_role_key_chk" CHECK (
129
+ "role_key" IS NULL
130
+ OR (
131
+ "role_key" = lower(btrim("role_key"))
132
+ AND length("role_key") BETWEEN 1 AND 64
133
+ AND "role_key" ~ '^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$'
134
+ AND "role_key" !~ '--'
135
+ )
136
+ ),
137
+ CONSTRAINT "workspace_instruction_policy_activation_events_old_revision_chk" CHECK (
138
+ ("old_revision_id" IS NULL AND "old_revision" IS NULL AND "old_content_hash" IS NULL)
139
+ OR (
140
+ "old_revision_id" IS NOT NULL
141
+ AND "old_revision" > 0
142
+ AND "old_content_hash" ~ '^[0-9a-f]{64}$'
143
+ )
144
+ ),
145
+ CONSTRAINT "workspace_instruction_policy_activation_events_new_revision_chk" CHECK (
146
+ "new_revision" > 0
147
+ AND "new_content_hash" ~ '^[0-9a-f]{64}$'
148
+ AND "activation_version" > 0
149
+ ),
150
+ CONSTRAINT "workspace_instruction_policy_activation_events_audit_chk" CHECK (
151
+ length(btrim("actor_subject_id")) BETWEEN 1 AND 1024
152
+ AND length(btrim("reason")) BETWEEN 1 AND 4096
153
+ )
154
+ );
155
+
156
+ CREATE UNIQUE INDEX IF NOT EXISTS "workspace_instruction_policy_events_target_version_uq"
157
+ ON "workspace_instruction_policy_activation_events"
158
+ ("workspace_id", "kind", "scope", coalesce("role_key", ''), "activation_version");
159
+
160
+ CREATE INDEX IF NOT EXISTS "workspace_instruction_policy_events_workspace_time_idx"
161
+ ON "workspace_instruction_policy_activation_events" ("workspace_id", "created_at" DESC, "id");
162
+
163
+ CREATE OR REPLACE FUNCTION workspace_instruction_policy_validate_revision_link()
164
+ RETURNS trigger
165
+ LANGUAGE plpgsql
166
+ AS $$
167
+ DECLARE
168
+ target_workspace_id uuid;
169
+ target_kind text;
170
+ target_scope text;
171
+ target_role_key text;
172
+ BEGIN
173
+ IF NEW."supersedes_revision_id" IS NULL THEN
174
+ RETURN NEW;
175
+ END IF;
176
+ SELECT "workspace_id", "kind", "scope", "role_key"
177
+ INTO target_workspace_id, target_kind, target_scope, target_role_key
178
+ FROM "workspace_instruction_policy_revisions"
179
+ WHERE "id" = NEW."supersedes_revision_id";
180
+ IF NOT FOUND
181
+ OR target_workspace_id IS DISTINCT FROM NEW."workspace_id"
182
+ OR target_kind IS DISTINCT FROM NEW."kind"
183
+ OR target_scope IS DISTINCT FROM NEW."scope"
184
+ OR target_role_key IS DISTINCT FROM NEW."role_key"
185
+ THEN
186
+ RAISE EXCEPTION 'superseded instruction-policy revision must use the same workspace and target'
187
+ USING ERRCODE = '23514';
188
+ END IF;
189
+ RETURN NEW;
190
+ END;
191
+ $$;
192
+
193
+ DROP TRIGGER IF EXISTS workspace_instruction_policy_revisions_validate_link
194
+ ON "workspace_instruction_policy_revisions";
195
+ CREATE TRIGGER workspace_instruction_policy_revisions_validate_link
196
+ BEFORE INSERT OR UPDATE ON "workspace_instruction_policy_revisions"
197
+ FOR EACH ROW EXECUTE FUNCTION workspace_instruction_policy_validate_revision_link();
198
+
199
+ CREATE OR REPLACE FUNCTION workspace_instruction_policy_validate_head()
200
+ RETURNS trigger
201
+ LANGUAGE plpgsql
202
+ AS $$
203
+ BEGIN
204
+ IF NOT EXISTS (
205
+ SELECT 1
206
+ FROM "workspace_instruction_policy_revisions" revision
207
+ WHERE revision."id" = NEW."revision_id"
208
+ AND revision."account_id" = NEW."account_id"
209
+ AND revision."workspace_id" = NEW."workspace_id"
210
+ AND revision."kind" = NEW."kind"
211
+ AND revision."scope" = NEW."scope"
212
+ AND revision."role_key" IS NOT DISTINCT FROM NEW."role_key"
213
+ AND revision."revision" = NEW."revision"
214
+ AND revision."content_hash" = NEW."content_hash"
215
+ ) THEN
216
+ RAISE EXCEPTION 'instruction-policy head must identify an exact target revision'
217
+ USING ERRCODE = '23514';
218
+ END IF;
219
+ RETURN NEW;
220
+ END;
221
+ $$;
222
+
223
+ DROP TRIGGER IF EXISTS workspace_instruction_policy_heads_validate_revision
224
+ ON "workspace_instruction_policy_heads";
225
+ CREATE TRIGGER workspace_instruction_policy_heads_validate_revision
226
+ BEFORE INSERT OR UPDATE ON "workspace_instruction_policy_heads"
227
+ FOR EACH ROW EXECUTE FUNCTION workspace_instruction_policy_validate_head();
228
+
229
+ CREATE OR REPLACE FUNCTION workspace_instruction_policy_validate_event()
230
+ RETURNS trigger
231
+ LANGUAGE plpgsql
232
+ AS $$
233
+ BEGIN
234
+ IF NOT EXISTS (
235
+ SELECT 1
236
+ FROM "workspace_instruction_policy_revisions" revision
237
+ WHERE revision."id" = NEW."new_revision_id"
238
+ AND revision."account_id" = NEW."account_id"
239
+ AND revision."workspace_id" = NEW."workspace_id"
240
+ AND revision."kind" = NEW."kind"
241
+ AND revision."scope" = NEW."scope"
242
+ AND revision."role_key" IS NOT DISTINCT FROM NEW."role_key"
243
+ AND revision."revision" = NEW."new_revision"
244
+ AND revision."content_hash" = NEW."new_content_hash"
245
+ ) THEN
246
+ RAISE EXCEPTION 'instruction-policy activation event has an invalid new revision'
247
+ USING ERRCODE = '23514';
248
+ END IF;
249
+ IF NEW."old_revision_id" IS NOT NULL AND NOT EXISTS (
250
+ SELECT 1
251
+ FROM "workspace_instruction_policy_revisions" revision
252
+ WHERE revision."id" = NEW."old_revision_id"
253
+ AND revision."account_id" = NEW."account_id"
254
+ AND revision."workspace_id" = NEW."workspace_id"
255
+ AND revision."kind" = NEW."kind"
256
+ AND revision."scope" = NEW."scope"
257
+ AND revision."role_key" IS NOT DISTINCT FROM NEW."role_key"
258
+ AND revision."revision" = NEW."old_revision"
259
+ AND revision."content_hash" = NEW."old_content_hash"
260
+ ) THEN
261
+ RAISE EXCEPTION 'instruction-policy activation event has an invalid old revision'
262
+ USING ERRCODE = '23514';
263
+ END IF;
264
+ RETURN NEW;
265
+ END;
266
+ $$;
267
+
268
+ DROP TRIGGER IF EXISTS workspace_instruction_policy_events_validate_revisions
269
+ ON "workspace_instruction_policy_activation_events";
270
+ CREATE TRIGGER workspace_instruction_policy_events_validate_revisions
271
+ BEFORE INSERT OR UPDATE ON "workspace_instruction_policy_activation_events"
272
+ FOR EACH ROW EXECUTE FUNCTION workspace_instruction_policy_validate_event();
273
+
274
+ CREATE OR REPLACE FUNCTION workspace_instruction_policy_reject_mutation()
275
+ RETURNS trigger
276
+ LANGUAGE plpgsql
277
+ AS $$
278
+ BEGIN
279
+ -- PostgreSQL implements ON DELETE CASCADE with a parent constraint trigger.
280
+ -- By the time that trigger deletes child rows, the workspace is absent and
281
+ -- this child trigger is nested. Require both facts so direct history deletes
282
+ -- and deletes issued by unrelated triggers remain fail-closed.
283
+ IF TG_OP = 'DELETE'
284
+ AND pg_trigger_depth() > 1
285
+ AND NOT EXISTS (
286
+ SELECT 1
287
+ FROM "workspaces" workspace
288
+ WHERE workspace."id" = OLD."workspace_id"
289
+ )
290
+ THEN
291
+ RETURN OLD;
292
+ END IF;
293
+ RAISE EXCEPTION 'workspace instruction-policy history is immutable'
294
+ USING ERRCODE = '55000';
295
+ END;
296
+ $$;
297
+
298
+ DROP TRIGGER IF EXISTS workspace_instruction_policy_revisions_immutable
299
+ ON "workspace_instruction_policy_revisions";
300
+ CREATE TRIGGER workspace_instruction_policy_revisions_immutable
301
+ BEFORE UPDATE OR DELETE ON "workspace_instruction_policy_revisions"
302
+ FOR EACH ROW EXECUTE FUNCTION workspace_instruction_policy_reject_mutation();
303
+
304
+ DROP TRIGGER IF EXISTS workspace_instruction_policy_events_immutable
305
+ ON "workspace_instruction_policy_activation_events";
306
+ CREATE TRIGGER workspace_instruction_policy_events_immutable
307
+ BEFORE UPDATE OR DELETE ON "workspace_instruction_policy_activation_events"
308
+ FOR EACH ROW EXECUTE FUNCTION workspace_instruction_policy_reject_mutation();
309
+
310
+ ALTER TABLE "workspace_instruction_policy_revisions" ENABLE ROW LEVEL SECURITY;
311
+ ALTER TABLE "workspace_instruction_policy_revisions" FORCE ROW LEVEL SECURITY;
312
+ ALTER TABLE "workspace_instruction_policy_heads" ENABLE ROW LEVEL SECURITY;
313
+ ALTER TABLE "workspace_instruction_policy_heads" FORCE ROW LEVEL SECURITY;
314
+ ALTER TABLE "workspace_instruction_policy_activation_events" ENABLE ROW LEVEL SECURITY;
315
+ ALTER TABLE "workspace_instruction_policy_activation_events" FORCE ROW LEVEL SECURITY;
316
+
317
+ DROP POLICY IF EXISTS workspace_isolation ON "workspace_instruction_policy_revisions";
318
+ CREATE POLICY workspace_isolation ON "workspace_instruction_policy_revisions"
319
+ USING (opengeni_private.workspace_rls_visible("account_id", "workspace_id"))
320
+ WITH CHECK (opengeni_private.workspace_rls_visible("account_id", "workspace_id"));
321
+
322
+ DROP POLICY IF EXISTS workspace_isolation ON "workspace_instruction_policy_heads";
323
+ CREATE POLICY workspace_isolation ON "workspace_instruction_policy_heads"
324
+ USING (opengeni_private.workspace_rls_visible("account_id", "workspace_id"))
325
+ WITH CHECK (opengeni_private.workspace_rls_visible("account_id", "workspace_id"));
326
+
327
+ DROP POLICY IF EXISTS workspace_isolation ON "workspace_instruction_policy_activation_events";
328
+ CREATE POLICY workspace_isolation ON "workspace_instruction_policy_activation_events"
329
+ USING (opengeni_private.workspace_rls_visible("account_id", "workspace_id"))
330
+ WITH CHECK (opengeni_private.workspace_rls_visible("account_id", "workspace_id"));