@opengeni/db 1.0.1 → 3.1.0-canary.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 (265) hide show
  1. package/dist/attached-browser-devices.d.ts +7 -0
  2. package/dist/automations.d.ts +154 -0
  3. package/dist/canonical-human-identities.js +3 -3
  4. package/dist/capability-integrations.d.ts +13 -0
  5. package/dist/child-lifecycle-notices.d.ts +68 -0
  6. package/dist/{chunk-LTG3S6ZY.js → chunk-EULAN2NP.js} +2 -2
  7. package/dist/{chunk-CUPM3FGB.js → chunk-NJVZPWXY.js} +3 -3
  8. package/dist/{chunk-4SR4HKKA.js → chunk-NMUBV7Z5.js} +19 -11
  9. package/dist/chunk-NMUBV7Z5.js.map +1 -0
  10. package/dist/chunk-OF5OBB76.js +4457 -0
  11. package/dist/chunk-OF5OBB76.js.map +1 -0
  12. package/dist/{chunk-6ZYKCRFO.js → chunk-PR2WMLOQ.js} +7580 -4417
  13. package/dist/chunk-PR2WMLOQ.js.map +1 -0
  14. package/dist/{chunk-ZWREYEWA.js → chunk-SCPUJS53.js} +41 -4
  15. package/dist/chunk-SCPUJS53.js.map +1 -0
  16. package/dist/{chunk-SK7YYHBI.js → chunk-SIBYBA2D.js} +46 -1
  17. package/dist/chunk-SIBYBA2D.js.map +1 -0
  18. package/dist/chunk-TIKF3KTI.js +383 -0
  19. package/dist/chunk-TIKF3KTI.js.map +1 -0
  20. package/dist/chunk-ZZ5LJEFX.js +516 -0
  21. package/dist/chunk-ZZ5LJEFX.js.map +1 -0
  22. package/dist/codemode-operations.d.ts +1 -6
  23. package/dist/company-brain-context-selection-schema.d.ts +705 -0
  24. package/dist/company-brain-context-selection.d.ts +94 -0
  25. package/dist/company-brain-governed-writes.d.ts +24 -0
  26. package/dist/company-brain.d.ts +55 -0
  27. package/dist/company-profile-agent-admin.d.ts +15 -0
  28. package/dist/company-profile-schema.d.ts +619 -2
  29. package/dist/company-profile.d.ts +9 -4
  30. package/dist/connection-authority.d.ts +95 -0
  31. package/dist/connection-scopes.d.ts +8 -0
  32. package/dist/connection-tenancy-backfill.d.ts +41 -0
  33. package/dist/connection-token-resolver.d.ts +30 -1
  34. package/dist/database.d.ts +19 -0
  35. package/dist/editable-artifact-durable-export.js +2 -2
  36. package/dist/editable-artifacts.d.ts +2 -2
  37. package/dist/editable-artifacts.js +3 -3
  38. package/dist/governed-learning-activation-schema.d.ts +984 -0
  39. package/dist/governed-learning-activation.d.ts +98 -0
  40. package/dist/governed-learning-evaluator-schema.d.ts +633 -0
  41. package/dist/governed-learning-evaluator.d.ts +28 -0
  42. package/dist/index.d.ts +1595 -81
  43. package/dist/index.js +21571 -8126
  44. package/dist/index.js.map +1 -1
  45. package/dist/insights.d.ts +18 -0
  46. package/dist/integration-bindings.d.ts +1 -0
  47. package/dist/integration-facets.d.ts +30 -0
  48. package/dist/interaction-placement-loss.d.ts +22 -0
  49. package/dist/managed-human-provisioning.d.ts +25 -0
  50. package/dist/memory-domain.d.ts +3 -3
  51. package/dist/migrate.d.ts +11 -1
  52. package/dist/migrate.js +1 -1
  53. package/dist/new-session-drafts.d.ts +43 -5
  54. package/dist/organization-membership-backfill.d.ts +174 -0
  55. package/dist/organization-membership-lifecycle.d.ts +162 -0
  56. package/dist/pr-review.d.ts +107 -0
  57. package/dist/preference-registry-schema.d.ts +2 -0
  58. package/dist/preference-registry.d.ts +2 -0
  59. package/dist/provision-roles.js +1 -1
  60. package/dist/runtime-posture.d.ts +22 -8
  61. package/dist/schema.d.ts +24914 -13703
  62. package/dist/schema.js +99 -1
  63. package/dist/scoped-knowledge-schema.d.ts +52 -1
  64. package/dist/session-background-commands.d.ts +125 -0
  65. package/dist/session-background-commands.js +34 -0
  66. package/dist/session-background-commands.js.map +1 -0
  67. package/dist/session-control.d.ts +156 -3
  68. package/dist/session-goal-pacing.d.ts +68 -0
  69. package/dist/session-queue-commands.d.ts +41 -4
  70. package/dist/session-realtime-ledger.d.ts +2 -0
  71. package/dist/session-tenancy.d.ts +71 -1
  72. package/dist/session-tenancy.js +25 -3
  73. package/dist/session-tool-call-settlement.d.ts +9 -4
  74. package/dist/slack-routing-personal-workspace.d.ts +23 -0
  75. package/dist/slack-routing.d.ts +153 -0
  76. package/dist/slack-task-policy-schema.d.ts +34 -0
  77. package/dist/slack-task-policy.d.ts +8 -0
  78. package/dist/slack-user-link-access.d.ts +1 -0
  79. package/dist/task-notes-schema.d.ts +594 -0
  80. package/dist/task-notes.d.ts +39 -0
  81. package/dist/turn-initiator.d.ts +2 -0
  82. package/dist/user-resource-authority.d.ts +109 -0
  83. package/dist/video-generation.js +3 -3
  84. package/dist/workspace-authority.d.ts +81 -0
  85. package/dist/workspace-instruction-policies-schema.d.ts +279 -0
  86. package/dist/workspace-instruction-policies.d.ts +62 -0
  87. package/dist/workspace-learning-policy.d.ts +1 -0
  88. package/dist/workspace-membership-permissions.d.ts +2 -0
  89. package/drizzle/0240_enrollment_connection_authority.sql +39 -0
  90. package/drizzle/0241_atomic_personal_resource_delegation.sql +1000 -0
  91. package/drizzle/0241_enrollment_agent_runtime.sql +70 -0
  92. package/drizzle/0244_slack_app_home_refresh_queue.sql +120 -0
  93. package/drizzle/0245_model_context_contribution_facts.sql +88 -0
  94. package/drizzle/0246_integration_personal_instance_authority.sql +70 -0
  95. package/drizzle/0247_terraform_stacks_provenance_repair.sql +428 -0
  96. package/drizzle/0248_terraform_stacks_component_resolution_fence.sql +83 -0
  97. package/drizzle/0249_personal_resource_delegation_authority_correction.sql +662 -0
  98. package/drizzle/0250_direct_retained_process_owner_liveness.sql +199 -0
  99. package/drizzle/0251_connected_machine_operation_policy.sql +45 -0
  100. package/drizzle/0252_scheduled_personal_resource_delegation.sql +1867 -0
  101. package/drizzle/0253_common_user_resource_authority_lifecycle.sql +1555 -0
  102. package/drizzle/0254_scoped_variable_set_authority.sql +1255 -0
  103. package/drizzle/0255_company_brain_governed_write_proposals.sql +524 -0
  104. package/drizzle/0256_connection_authority_delegation.sql +703 -0
  105. package/drizzle/0257_goal_revision_decisions_and_root_constraints.sql +436 -0
  106. package/drizzle/0258_three_scope_document_knowledge_authority.sql +1273 -0
  107. package/drizzle/0259_company_brain_context_selection_receipts.sql +869 -0
  108. package/drizzle/0260_task_note_knowledge_promotion.sql +820 -0
  109. package/drizzle/0261_preference_knowledge_proposal_actor_binding.sql +471 -0
  110. package/drizzle/0262_scoped_connected_machines_and_rigs.sql +1700 -0
  111. package/drizzle/0263_organization_membership_lifecycle.sql +3284 -0
  112. package/drizzle/0264_connection_authority_runtime_activation.sql +1084 -0
  113. package/drizzle/0266_company_brain_context_receipt_inspection.sql +156 -0
  114. package/drizzle/0268_governed_learning_decision_receipts.sql +825 -0
  115. package/drizzle/0269_governed_learning_activation_controller.sql +1874 -0
  116. package/drizzle/0270_governed_learning_history_inspection.sql +170 -0
  117. package/drizzle/0271_company_brain_retrieval_only_default.sql +524 -0
  118. package/drizzle/0272_human_confirmed_learning_activation.sql +591 -0
  119. package/drizzle/0273_scheduled_variable_set_materialization.sql +268 -0
  120. package/drizzle/0274_human_confirmed_knowledge_review.sql +310 -0
  121. package/drizzle/0275_scheduled_connection_authority.sql +5527 -0
  122. package/drizzle/0276_onboarding_proposal_initiating_human_guc.sql +198 -0
  123. package/drizzle/0277_workspace_writer_authority_attribution.sql +393 -0
  124. package/drizzle/0278_workspace_membership_removal_fencing.sql +1174 -0
  125. package/drizzle/0279_workspace_connection_use_lane.sql +343 -0
  126. package/drizzle/0280_connection_and_variable_set_audit_attribution.sql +773 -0
  127. package/drizzle/0281_viewer_holder_authority_claims.sql +52 -0
  128. package/drizzle/0282_variable_set_session_attach_attribution.sql +178 -0
  129. package/drizzle/0283_editable_spreadsheet_authored_state.sql +82 -0
  130. package/drizzle/0284_truthful_human_confirmed_review_reason.sql +930 -0
  131. package/drizzle/0285_organization_tenancy_inventory.sql +329 -0
  132. package/drizzle/0286_widen_task_note_expiry_ceiling.sql +391 -0
  133. package/drizzle/0287_open_suffix_pending_tool_calls.sql +18 -0
  134. package/drizzle/0288_attached_browser_reenrollment.sql +64 -0
  135. package/drizzle/0289_session_composer_policy_authority.sql +597 -0
  136. package/drizzle/0290_organization_membership_backfill.sql +268 -0
  137. package/drizzle/0291_resource_authority_classification_assertion.sql +371 -0
  138. package/drizzle/0292_truthful_tenancy_inventory_counters.sql +291 -0
  139. package/drizzle/0293_confirm_time_rule_rebaseline.sql +615 -0
  140. package/drizzle/0294_preference_activation_authority.sql +237 -0
  141. package/drizzle/0295_retire_legacy_standing_memory_mode.sql +476 -0
  142. package/drizzle/0296_force_rls_backfill_noop_repair.sql +215 -0
  143. package/drizzle/0297_session_ownership_classification_and_backfill.sql +752 -0
  144. package/drizzle/0298_organization_tenancy_parity.sql +609 -0
  145. package/drizzle/0299_organization_membership_lock_order.sql +291 -0
  146. package/drizzle/0300_tenancy_backfill_ledger.sql +459 -0
  147. package/drizzle/0301_session_snapshot_and_pin_visibility.sql +181 -0
  148. package/drizzle/0302_personal_workspace_session_ownership.sql +303 -0
  149. package/drizzle/0303_session_tenancy_product_activation.sql +919 -0
  150. package/drizzle/0304_personal_workspace_private_session_reads.sql +97 -0
  151. package/drizzle/0305_personal_resource_grant_management.sql +584 -0
  152. package/drizzle/0306_atomic_personal_resource_attachments.sql +1374 -0
  153. package/drizzle/0307_session_attention_state.sql +51 -0
  154. package/drizzle/0308_session_archives.sql +35 -0
  155. package/drizzle/0309_channel_project_pins.sql +6 -0
  156. package/drizzle/0310_channel_project_order.sql +27 -0
  157. package/drizzle/0311_company_scope_and_private_session_create.sql +656 -0
  158. package/drizzle/0312_quiescent_session_tree_deletion.sql +39 -0
  159. package/drizzle/0313_private_child_session_authority.sql +365 -0
  160. package/drizzle/0314_unregistered_organization_invitations.sql +606 -0
  161. package/drizzle/0315_personal_github_repository_selection.sql +624 -0
  162. package/drizzle/0316_human_confirmed_activation_resumed_generation.sql +869 -0
  163. package/drizzle/0317_session_goal_continuation_hold.sql +64 -0
  164. package/drizzle/0318_session_turn_startup_milestones.sql +115 -0
  165. package/drizzle/0319_event_triggered_automations.sql +254 -0
  166. package/drizzle/0320_pr_review_pack.sql +113 -0
  167. package/drizzle/0321_slack_bot_environment_display_name.sql +15 -0
  168. package/drizzle/0322_session_turns_unclaimed_prompt_trigger_index.sql +5 -0
  169. package/drizzle/0323_organization_private_session_enablement.sql +489 -0
  170. package/drizzle/0324_human_confirmed_company_profile_agent_admin.sql +856 -0
  171. package/drizzle/0325_child_lifecycle_notices.sql +44 -0
  172. package/drizzle/0326_interaction_operation_error_codes.sql +62 -0
  173. package/drizzle/0327_slack_first_task_hint.sql +29 -0
  174. package/drizzle/0328_session_background_commands.sql +590 -0
  175. package/drizzle/0329_slack_orchestration_delivery_events.sql +91 -0
  176. package/drizzle/0330_api_key_descriptions.sql +20 -0
  177. package/drizzle/0331_managed_organization_creation.sql +163 -0
  178. package/drizzle/0332_organization_shared_workspace_control_plane.sql +539 -0
  179. package/drizzle/0333_session_turn_prompt_routing.sql +45 -0
  180. package/drizzle/0334_connected_machine_workspace_root.sql +99 -0
  181. package/drizzle/0335_slack_workspace_routing.sql +460 -0
  182. package/drizzle/0336_atomic_session_fork_visibility.sql +667 -0
  183. package/drizzle/0337_slack_routed_action_handles.sql +189 -0
  184. package/drizzle/0338_atomic_connected_machine_attachments.sql +410 -0
  185. package/drizzle/0339_document_authority_reclassification.sql +1153 -0
  186. package/drizzle/0340_tenancy_backfill_activation_evidence.sql +1347 -0
  187. package/drizzle/0341_slack_routing_probe_organization_fence.sql +109 -0
  188. package/package.json +10 -6
  189. package/src/attached-browser-devices.ts +233 -5
  190. package/src/automations.ts +911 -0
  191. package/src/browser-sessions.ts +10 -0
  192. package/src/capability-integrations.ts +175 -1
  193. package/src/child-lifecycle-notices.ts +241 -0
  194. package/src/codemode-operations.ts +1 -35
  195. package/src/company-brain-context-selection-schema.ts +100 -0
  196. package/src/company-brain-context-selection.ts +359 -0
  197. package/src/company-brain-governed-writes.ts +1170 -0
  198. package/src/company-brain.ts +309 -0
  199. package/src/company-profile-agent-admin.ts +225 -0
  200. package/src/company-profile-schema.ts +121 -1
  201. package/src/company-profile.ts +21 -0
  202. package/src/computer-sessions.ts +10 -1
  203. package/src/connection-authority.ts +290 -0
  204. package/src/connection-scopes.ts +20 -0
  205. package/src/connection-tenancy-backfill.ts +81 -0
  206. package/src/connection-token-resolver.ts +263 -31
  207. package/src/database.ts +75 -2
  208. package/src/editable-artifacts-schema.ts +10 -5
  209. package/src/editable-artifacts.ts +21 -11
  210. package/src/governed-learning-activation-schema.ts +121 -0
  211. package/src/governed-learning-activation.ts +588 -0
  212. package/src/governed-learning-evaluator-schema.ts +72 -0
  213. package/src/governed-learning-evaluator.ts +217 -0
  214. package/src/index.ts +11651 -1548
  215. package/src/insights.ts +107 -0
  216. package/src/integration-bindings.ts +88 -6
  217. package/src/integration-facets.ts +287 -84
  218. package/src/interaction-placement-loss.ts +234 -0
  219. package/src/managed-human-provisioning.ts +116 -0
  220. package/src/memory-domain.ts +27 -32
  221. package/src/migrate.ts +77 -1
  222. package/src/new-session-drafts.ts +229 -35
  223. package/src/organization-membership-backfill.ts +735 -0
  224. package/src/organization-membership-lifecycle.ts +985 -0
  225. package/src/pr-review.ts +890 -0
  226. package/src/preference-registry.ts +31 -1
  227. package/src/provision-roles.ts +877 -6
  228. package/src/runtime-posture-cli.ts +2 -0
  229. package/src/runtime-posture.ts +1291 -76
  230. package/src/schema.ts +3303 -428
  231. package/src/scoped-knowledge-schema.ts +16 -8
  232. package/src/scoped-knowledge.ts +1 -1
  233. package/src/session-background-commands.ts +759 -0
  234. package/src/session-control.ts +640 -74
  235. package/src/session-goal-pacing.ts +126 -0
  236. package/src/session-queue-commands.ts +437 -84
  237. package/src/session-realtime-context.ts +3 -3
  238. package/src/session-realtime-ledger.ts +22 -11
  239. package/src/session-realtime-mirror.ts +1 -1
  240. package/src/session-tenancy.ts +403 -49
  241. package/src/session-tool-call-settlement.ts +38 -29
  242. package/src/slack-routing-personal-workspace.ts +67 -0
  243. package/src/slack-routing.ts +469 -0
  244. package/src/slack-task-policy-schema.ts +5 -0
  245. package/src/slack-task-policy.ts +18 -0
  246. package/src/slack-user-link-access.ts +17 -1
  247. package/src/task-notes-schema.ts +74 -0
  248. package/src/task-notes.ts +124 -2
  249. package/src/turn-initiator.ts +8 -0
  250. package/src/user-resource-authority.ts +377 -0
  251. package/src/workspace-authority.ts +160 -0
  252. package/src/workspace-instruction-policies-schema.ts +53 -1
  253. package/src/workspace-instruction-policies.ts +528 -77
  254. package/src/workspace-learning-policy.ts +21 -5
  255. package/src/workspace-membership-permissions.ts +9 -0
  256. package/dist/chunk-4SR4HKKA.js.map +0 -1
  257. package/dist/chunk-6ZYKCRFO.js.map +0 -1
  258. package/dist/chunk-NXAFLCQX.js +0 -2468
  259. package/dist/chunk-NXAFLCQX.js.map +0 -1
  260. package/dist/chunk-SK7YYHBI.js.map +0 -1
  261. package/dist/chunk-WD5ROSLL.js +0 -117
  262. package/dist/chunk-WD5ROSLL.js.map +0 -1
  263. package/dist/chunk-ZWREYEWA.js.map +0 -1
  264. /package/dist/{chunk-LTG3S6ZY.js.map → chunk-EULAN2NP.js.map} +0 -0
  265. /package/dist/{chunk-CUPM3FGB.js.map → chunk-NJVZPWXY.js.map} +0 -0
@@ -0,0 +1,3284 @@
1
+ -- deployment-mode: rolling
2
+ -- Organization invitation, role, suspension, reactivation, offboarding and
3
+ -- retention-policy authority and the operator-driven destructive retention
4
+ -- lifecycle. Provider email delivery remains outside this database lifecycle.
5
+
6
+ SET LOCAL lock_timeout = '5s';
7
+ SET LOCAL statement_timeout = '10min';
8
+
9
+ ALTER TABLE "session_realtime_modes"
10
+ DROP CONSTRAINT "session_realtime_modes_end_reason_check";
11
+ ALTER TABLE "session_realtime_modes"
12
+ ADD CONSTRAINT "session_realtime_modes_end_reason_check"
13
+ CHECK (
14
+ "end_reason" IS NULL
15
+ OR "end_reason" IN ('user_stop', 'browser_unload', 'lease_expired', 'authority_revoked')
16
+ ) NOT VALID;
17
+ ALTER TABLE "session_realtime_modes"
18
+ VALIDATE CONSTRAINT "session_realtime_modes_end_reason_check";
19
+
20
+ ALTER TABLE "session_attempt_interruptions"
21
+ DROP CONSTRAINT "session_attempt_interruptions_kind_check";
22
+ ALTER TABLE "session_attempt_interruptions"
23
+ ADD CONSTRAINT "session_attempt_interruptions_kind_check"
24
+ CHECK ("kind" IN (
25
+ 'session_pause', 'workspace_pause', 'steer', 'maintenance',
26
+ 'authority_change', 'organization_membership_revoked'
27
+ )) NOT VALID;
28
+ ALTER TABLE "session_attempt_interruptions"
29
+ VALIDATE CONSTRAINT "session_attempt_interruptions_kind_check";
30
+
31
+ ALTER TABLE "organization_memberships"
32
+ ADD COLUMN "role" text NOT NULL DEFAULT 'member';
33
+
34
+ UPDATE "organization_memberships" membership
35
+ SET role = 'owner'
36
+ FROM "managed_accounts" account
37
+ WHERE account.id = membership.account_id
38
+ AND account.external_source = 'better-auth:user'
39
+ AND membership.subject_id = 'user:' || account.external_id;
40
+
41
+ ALTER TABLE "organization_memberships"
42
+ ADD CONSTRAINT "organization_memberships_role_check"
43
+ CHECK ("role" IN ('owner', 'admin', 'member')) NOT VALID;
44
+ ALTER TABLE "organization_memberships"
45
+ VALIDATE CONSTRAINT "organization_memberships_role_check";
46
+
47
+ ALTER TABLE "organization_user_retention_policies"
48
+ DROP CONSTRAINT "organization_user_retention_policies_duration_check";
49
+ ALTER TABLE "organization_user_retention_policies"
50
+ ADD CONSTRAINT "organization_user_retention_policies_duration_check" CHECK (
51
+ ("mode" = 'retain' AND "retention_days" IS NULL)
52
+ OR (
53
+ "mode" = 'delete_after'
54
+ AND "retention_days" IS NOT NULL
55
+ AND "retention_days" BETWEEN 30 AND 90
56
+ )
57
+ ) NOT VALID;
58
+ ALTER TABLE "organization_user_retention_policies"
59
+ VALIDATE CONSTRAINT "organization_user_retention_policies_duration_check";
60
+
61
+ -- The pre-existing managed-human provisioner creates the one organization
62
+ -- whose external identity is the same human but predates roles. Preserve that
63
+ -- seamless bootstrap without teaching the general invitation path to infer
64
+ -- authority from provenance: only this exact self-organization INSERT is
65
+ -- promoted, and later role changes remain explicit lifecycle commands.
66
+ CREATE OR REPLACE FUNCTION opengeni_private.assign_managed_self_organization_owner()
67
+ RETURNS trigger
68
+ LANGUAGE plpgsql
69
+ SECURITY DEFINER
70
+ SET search_path FROM CURRENT
71
+ AS $body$
72
+ BEGIN
73
+ IF EXISTS (
74
+ SELECT 1 FROM managed_accounts account
75
+ WHERE account.id = NEW.account_id
76
+ AND account.external_source = 'better-auth:user'
77
+ AND NEW.subject_id = 'user:' || account.external_id
78
+ ) THEN
79
+ NEW.role := 'owner';
80
+ END IF;
81
+ RETURN NEW;
82
+ END
83
+ $body$;
84
+ CREATE TRIGGER organization_memberships_assign_managed_self_owner
85
+ BEFORE INSERT ON organization_memberships
86
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.assign_managed_self_organization_owner();
87
+
88
+ CREATE TABLE "organization_membership_invitations" (
89
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
90
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
91
+ "target_subject_id" text NOT NULL,
92
+ "target_email" text NOT NULL,
93
+ "role" text NOT NULL DEFAULT 'member',
94
+ "status" text NOT NULL DEFAULT 'pending',
95
+ "revision" bigint NOT NULL DEFAULT 1,
96
+ "created_by_membership_id" uuid NOT NULL,
97
+ "accepted_membership_id" uuid,
98
+ "expires_at" timestamptz NOT NULL,
99
+ "created_at" timestamptz NOT NULL DEFAULT clock_timestamp(),
100
+ "updated_at" timestamptz NOT NULL DEFAULT clock_timestamp(),
101
+ CONSTRAINT "organization_membership_invitations_creator_fk"
102
+ FOREIGN KEY ("created_by_membership_id", "account_id")
103
+ REFERENCES "organization_memberships"("id", "account_id") ON DELETE RESTRICT,
104
+ CONSTRAINT "organization_membership_invitations_accepted_membership_fk"
105
+ FOREIGN KEY ("accepted_membership_id", "account_id")
106
+ REFERENCES "organization_memberships"("id", "account_id") ON DELETE RESTRICT,
107
+ CONSTRAINT "organization_membership_invitations_subject_check" CHECK (
108
+ "target_subject_id" = btrim("target_subject_id")
109
+ AND "target_subject_id" LIKE 'user:%'
110
+ AND octet_length(convert_to("target_subject_id", 'UTF8')) BETWEEN 6 AND 1024
111
+ ),
112
+ CONSTRAINT "organization_membership_invitations_email_check" CHECK (
113
+ "target_email" = lower(btrim("target_email"))
114
+ AND octet_length(convert_to("target_email", 'UTF8')) BETWEEN 3 AND 320
115
+ ),
116
+ CONSTRAINT "organization_membership_invitations_role_check"
117
+ CHECK ("role" IN ('owner', 'admin', 'member')),
118
+ CONSTRAINT "organization_membership_invitations_status_check"
119
+ CHECK ("status" IN ('pending', 'accepted', 'revoked', 'expired')),
120
+ CONSTRAINT "organization_membership_invitations_revision_check" CHECK ("revision" > 0),
121
+ CONSTRAINT "organization_membership_invitations_acceptance_check" CHECK (
122
+ ("status" = 'accepted' AND "accepted_membership_id" IS NOT NULL)
123
+ OR ("status" <> 'accepted' AND "accepted_membership_id" IS NULL)
124
+ )
125
+ );
126
+ CREATE UNIQUE INDEX "organization_membership_invitations_id_account_idx"
127
+ ON "organization_membership_invitations" ("id", "account_id");
128
+ CREATE UNIQUE INDEX "organization_membership_invitations_pending_target_uq"
129
+ ON "organization_membership_invitations" ("account_id", "target_subject_id")
130
+ WHERE "status" = 'pending';
131
+ CREATE INDEX "organization_membership_invitations_account_created_idx"
132
+ ON "organization_membership_invitations" ("account_id", "created_at" DESC, "id" DESC);
133
+ CREATE INDEX "organization_membership_invitations_subject_created_idx"
134
+ ON "organization_membership_invitations" ("target_subject_id", "created_at" DESC, "id" DESC);
135
+
136
+ CREATE TABLE "organization_membership_operation_receipts" (
137
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
138
+ "operation_id" uuid NOT NULL,
139
+ "action" text NOT NULL,
140
+ "input_hash" text NOT NULL,
141
+ "result" jsonb NOT NULL,
142
+ "created_at" timestamptz NOT NULL DEFAULT clock_timestamp(),
143
+ CONSTRAINT "organization_membership_operation_receipts_pk"
144
+ PRIMARY KEY ("account_id", "operation_id"),
145
+ CONSTRAINT "organization_membership_operation_receipts_action_check" CHECK (
146
+ "action" IN ('invite', 'accept', 'revoke_invitation', 'change_role', 'suspend', 'reactivate', 'offboard', 'retention')
147
+ ),
148
+ CONSTRAINT "organization_membership_operation_receipts_input_hash_check"
149
+ CHECK ("input_hash" ~ '^[0-9a-f]{64}$')
150
+ );
151
+
152
+ CREATE TABLE "organization_membership_lifecycle_events" (
153
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
154
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
155
+ "operation_id" uuid NOT NULL,
156
+ "actor_membership_id" uuid NOT NULL,
157
+ "target_membership_id" uuid,
158
+ "kind" text NOT NULL,
159
+ "prior_authorization_revision" bigint,
160
+ "resulting_authorization_revision" bigint,
161
+ "reason" text,
162
+ "created_at" timestamptz NOT NULL DEFAULT clock_timestamp(),
163
+ CONSTRAINT "organization_membership_lifecycle_events_actor_fk"
164
+ FOREIGN KEY ("actor_membership_id", "account_id")
165
+ REFERENCES "organization_memberships"("id", "account_id") ON DELETE RESTRICT,
166
+ CONSTRAINT "organization_membership_lifecycle_events_target_fk"
167
+ FOREIGN KEY ("target_membership_id", "account_id")
168
+ REFERENCES "organization_memberships"("id", "account_id") ON DELETE RESTRICT,
169
+ CONSTRAINT "organization_membership_lifecycle_events_kind_check" CHECK (
170
+ "kind" IN ('invite', 'accept', 'revoke_invitation', 'change_role', 'suspend', 'reactivate', 'offboard', 'retention')
171
+ ),
172
+ CONSTRAINT "organization_membership_lifecycle_events_reason_check" CHECK (
173
+ "reason" IS NULL OR octet_length(convert_to("reason", 'UTF8')) <= 512
174
+ )
175
+ );
176
+ CREATE UNIQUE INDEX "organization_membership_lifecycle_events_operation_uq"
177
+ ON "organization_membership_lifecycle_events" ("account_id", "operation_id");
178
+
179
+ CREATE TABLE "organization_user_retention_deletions" (
180
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
181
+ "membership_id" uuid NOT NULL,
182
+ "retention_until" timestamptz NOT NULL,
183
+ "state" text NOT NULL DEFAULT 'claimed',
184
+ "claim_operation_id" uuid NOT NULL,
185
+ "claim_expires_at" timestamptz NOT NULL,
186
+ "attempt_count" integer NOT NULL DEFAULT 1,
187
+ "database_result" jsonb,
188
+ "database_finalized_at" timestamptz,
189
+ "result" jsonb,
190
+ "completed_at" timestamptz,
191
+ "updated_at" timestamptz NOT NULL DEFAULT clock_timestamp(),
192
+ CONSTRAINT "organization_user_retention_deletions_pk"
193
+ PRIMARY KEY ("account_id", "membership_id"),
194
+ CONSTRAINT "organization_user_retention_deletions_membership_fk"
195
+ FOREIGN KEY ("membership_id", "account_id")
196
+ REFERENCES "organization_memberships"("id", "account_id") ON DELETE RESTRICT,
197
+ CONSTRAINT "organization_user_retention_deletions_state_check"
198
+ CHECK ("state" IN ('claimed', 'failed', 'completed')),
199
+ CONSTRAINT "organization_user_retention_deletions_attempt_check"
200
+ CHECK ("attempt_count" > 0),
201
+ CONSTRAINT "organization_user_retention_deletions_completion_check" CHECK (
202
+ ("state" = 'completed' AND "completed_at" IS NOT NULL AND "result" IS NOT NULL)
203
+ OR ("state" <> 'completed' AND "completed_at" IS NULL AND "result" IS NULL)
204
+ ),
205
+ CONSTRAINT "organization_retention_deletions_db_finalized_chk" CHECK (
206
+ ("database_finalized_at" IS NULL AND "database_result" IS NULL)
207
+ OR ("database_finalized_at" IS NOT NULL AND "database_result" IS NOT NULL)
208
+ )
209
+ );
210
+ CREATE UNIQUE INDEX "organization_user_retention_deletions_operation_uq"
211
+ ON "organization_user_retention_deletions" ("claim_operation_id");
212
+ CREATE INDEX "organization_user_retention_deletions_claim_idx"
213
+ ON "organization_user_retention_deletions" ("account_id", "state", "claim_expires_at");
214
+ CREATE INDEX "organization_memberships_retention_due_idx"
215
+ ON "organization_memberships" ("account_id", "personal_retention_until", "id")
216
+ WHERE "status" = 'revoked' AND "personal_retention_until" IS NOT NULL;
217
+
218
+ CREATE TABLE "organization_user_retention_object_obligations" (
219
+ "account_id" uuid NOT NULL,
220
+ "membership_id" uuid NOT NULL,
221
+ "object_kind" text NOT NULL,
222
+ "source_id" text NOT NULL,
223
+ "object_bucket" text NOT NULL,
224
+ "object_key" text NOT NULL,
225
+ "prepared_operation_id" uuid NOT NULL,
226
+ "object_key_hash" text NOT NULL,
227
+ "prepared_at" timestamptz NOT NULL DEFAULT clock_timestamp(),
228
+ CONSTRAINT "organization_user_retention_object_obligations_pk"
229
+ PRIMARY KEY ("account_id", "membership_id", "object_kind", "source_id", "object_bucket"),
230
+ CONSTRAINT "organization_retention_object_obligations_deletion_fk"
231
+ FOREIGN KEY ("account_id", "membership_id")
232
+ REFERENCES "organization_user_retention_deletions"("account_id", "membership_id")
233
+ ON DELETE RESTRICT,
234
+ CONSTRAINT "organization_user_retention_object_obligations_hash_check"
235
+ CHECK ("object_key_hash" ~ '^[0-9a-f]{64}$'),
236
+ CONSTRAINT "organization_user_retention_object_obligations_shape_check" CHECK (
237
+ "object_kind" IN (
238
+ 'file', 'session_recording', 'browser_state_artifact', 'browser_state_upload',
239
+ 'transcription_recording_object', 'video_staging_reference',
240
+ 'workspace_artifact_version', 'editable_artifact_blob',
241
+ 'workspace_capture_manifest', 'workspace_capture_tree_index',
242
+ 'workspace_capture_blob'
243
+ )
244
+ AND octet_length("source_id") BETWEEN 1 AND 2048
245
+ AND octet_length(convert_to("object_bucket", 'UTF8')) BETWEEN 1 AND 1024
246
+ AND octet_length(convert_to("object_key", 'UTF8')) BETWEEN 1 AND 4096
247
+ )
248
+ );
249
+
250
+ CREATE TABLE "organization_user_retention_object_deletion_receipts" (
251
+ "account_id" uuid NOT NULL,
252
+ "membership_id" uuid NOT NULL,
253
+ "object_kind" text NOT NULL,
254
+ "source_id" text NOT NULL,
255
+ "object_bucket" text NOT NULL,
256
+ "operation_id" uuid NOT NULL,
257
+ "deleted_at" timestamptz NOT NULL DEFAULT clock_timestamp(),
258
+ CONSTRAINT "organization_user_retention_object_deletion_receipts_pk"
259
+ PRIMARY KEY ("account_id", "membership_id", "object_kind", "source_id", "object_bucket"),
260
+ CONSTRAINT "organization_retention_object_deletions_obligation_fk"
261
+ FOREIGN KEY ("account_id", "membership_id", "object_kind", "source_id", "object_bucket")
262
+ REFERENCES "organization_user_retention_object_obligations"(
263
+ "account_id", "membership_id", "object_kind", "source_id", "object_bucket"
264
+ ) ON DELETE RESTRICT
265
+ );
266
+
267
+ CREATE TABLE "organization_user_retention_deletion_events" (
268
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
269
+ "account_id" uuid NOT NULL,
270
+ "membership_id" uuid NOT NULL,
271
+ "operation_id" uuid NOT NULL,
272
+ "kind" text NOT NULL,
273
+ "reason_code" text,
274
+ "result" jsonb NOT NULL,
275
+ "created_at" timestamptz NOT NULL DEFAULT clock_timestamp(),
276
+ CONSTRAINT "organization_user_retention_deletion_events_membership_fk"
277
+ FOREIGN KEY ("membership_id", "account_id")
278
+ REFERENCES "organization_memberships"("id", "account_id") ON DELETE RESTRICT,
279
+ CONSTRAINT "organization_user_retention_deletion_events_kind_check"
280
+ CHECK ("kind" IN ('claimed', 'failed', 'completed')),
281
+ CONSTRAINT "organization_user_retention_deletion_events_reason_check"
282
+ CHECK (
283
+ ("kind" = 'failed' AND "reason_code" IS NOT NULL
284
+ AND "reason_code" ~ '^[a-z0-9_]{1,64}$')
285
+ OR ("kind" IN ('claimed', 'completed') AND "reason_code" IS NULL)
286
+ )
287
+ );
288
+ CREATE UNIQUE INDEX "organization_user_retention_deletion_events_operation_kind_uq"
289
+ ON "organization_user_retention_deletion_events" ("account_id", "operation_id", "kind");
290
+ CREATE INDEX "organization_retention_events_member_created_idx"
291
+ ON "organization_user_retention_deletion_events" ("account_id", "membership_id", "created_at", "id");
292
+
293
+ ALTER TABLE "organization_membership_invitations" ENABLE ROW LEVEL SECURITY;
294
+ ALTER TABLE "organization_membership_invitations" FORCE ROW LEVEL SECURITY;
295
+ ALTER TABLE "organization_membership_operation_receipts" ENABLE ROW LEVEL SECURITY;
296
+ ALTER TABLE "organization_membership_operation_receipts" FORCE ROW LEVEL SECURITY;
297
+ ALTER TABLE "organization_membership_lifecycle_events" ENABLE ROW LEVEL SECURITY;
298
+ ALTER TABLE "organization_membership_lifecycle_events" FORCE ROW LEVEL SECURITY;
299
+ ALTER TABLE "organization_user_retention_deletions" ENABLE ROW LEVEL SECURITY;
300
+ ALTER TABLE "organization_user_retention_deletions" FORCE ROW LEVEL SECURITY;
301
+ ALTER TABLE "organization_user_retention_object_obligations" ENABLE ROW LEVEL SECURITY;
302
+ ALTER TABLE "organization_user_retention_object_obligations" FORCE ROW LEVEL SECURITY;
303
+ ALTER TABLE "organization_user_retention_object_deletion_receipts" ENABLE ROW LEVEL SECURITY;
304
+ ALTER TABLE "organization_user_retention_object_deletion_receipts" FORCE ROW LEVEL SECURITY;
305
+ ALTER TABLE "organization_user_retention_deletion_events" ENABLE ROW LEVEL SECURITY;
306
+ ALTER TABLE "organization_user_retention_deletion_events" FORCE ROW LEVEL SECURITY;
307
+
308
+ DROP POLICY IF EXISTS organization_tenancy_lifecycle ON "organization_memberships";
309
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_memberships"
310
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
311
+ IN (
312
+ 'managed_human_provisioning',
313
+ 'session_visibility_activation',
314
+ 'organization_membership_lifecycle'
315
+ ))
316
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
317
+ IN (
318
+ 'managed_human_provisioning',
319
+ 'session_visibility_activation',
320
+ 'organization_membership_lifecycle'
321
+ ));
322
+ DROP POLICY IF EXISTS organization_tenancy_lifecycle ON "organization_user_retention_policies";
323
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_user_retention_policies"
324
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
325
+ IN ('managed_human_provisioning', 'organization_membership_lifecycle'))
326
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
327
+ IN ('managed_human_provisioning', 'organization_membership_lifecycle'));
328
+ DROP POLICY IF EXISTS organization_tenancy_lifecycle ON "organization_user_resource_authorities";
329
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_user_resource_authorities"
330
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
331
+ IN ('managed_human_provisioning', 'organization_membership_lifecycle'))
332
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
333
+ IN ('managed_human_provisioning', 'organization_membership_lifecycle'));
334
+ DROP POLICY IF EXISTS organization_tenancy_lifecycle ON "organization_user_resource_grants";
335
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_user_resource_grants"
336
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
337
+ IN (
338
+ 'managed_human_provisioning',
339
+ 'session_visibility_activation',
340
+ 'organization_membership_lifecycle'
341
+ ))
342
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
343
+ IN (
344
+ 'managed_human_provisioning',
345
+ 'session_visibility_activation',
346
+ 'organization_membership_lifecycle'
347
+ ));
348
+
349
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_membership_invitations"
350
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
351
+ = 'organization_membership_lifecycle')
352
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
353
+ = 'organization_membership_lifecycle');
354
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_membership_operation_receipts"
355
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
356
+ = 'organization_membership_lifecycle')
357
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
358
+ = 'organization_membership_lifecycle');
359
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_membership_lifecycle_events"
360
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
361
+ = 'organization_membership_lifecycle')
362
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
363
+ = 'organization_membership_lifecycle');
364
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_user_retention_deletions"
365
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
366
+ = 'organization_membership_lifecycle')
367
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
368
+ = 'organization_membership_lifecycle');
369
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_user_retention_object_obligations"
370
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
371
+ = 'organization_membership_lifecycle')
372
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
373
+ = 'organization_membership_lifecycle');
374
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_user_retention_object_deletion_receipts"
375
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
376
+ = 'organization_membership_lifecycle')
377
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
378
+ = 'organization_membership_lifecycle');
379
+ CREATE POLICY organization_tenancy_lifecycle ON "organization_user_retention_deletion_events"
380
+ USING (current_setting('opengeni.organization_tenancy_lifecycle', true)
381
+ = 'organization_membership_lifecycle')
382
+ WITH CHECK (current_setting('opengeni.organization_tenancy_lifecycle', true)
383
+ = 'organization_membership_lifecycle');
384
+
385
+ CREATE OR REPLACE FUNCTION opengeni_private.organization_membership_row_json(
386
+ p_membership organization_memberships
387
+ ) RETURNS jsonb
388
+ LANGUAGE sql IMMUTABLE
389
+ SET search_path = pg_catalog
390
+ AS $body$
391
+ SELECT pg_catalog.jsonb_build_object(
392
+ 'id', p_membership.id,
393
+ 'organizationId', p_membership.account_id,
394
+ 'subjectId', p_membership.subject_id,
395
+ 'role', p_membership.role,
396
+ 'status', p_membership.status,
397
+ 'authorizationRevision', p_membership.authorization_revision,
398
+ 'personalWorkspaceId', p_membership.personal_workspace_id,
399
+ 'revokedAt', p_membership.revoked_at,
400
+ 'personalRetentionUntil', p_membership.personal_retention_until,
401
+ 'createdAt', p_membership.created_at,
402
+ 'updatedAt', p_membership.updated_at
403
+ )
404
+ $body$;
405
+
406
+ CREATE OR REPLACE FUNCTION opengeni_private.organization_invitation_row_json(
407
+ p_invitation organization_membership_invitations
408
+ ) RETURNS jsonb
409
+ LANGUAGE sql STABLE
410
+ SET search_path = pg_catalog
411
+ AS $body$
412
+ SELECT pg_catalog.jsonb_build_object(
413
+ 'id', p_invitation.id,
414
+ 'organizationId', p_invitation.account_id,
415
+ 'targetEmail', p_invitation.target_email,
416
+ 'role', p_invitation.role,
417
+ 'status', CASE
418
+ WHEN p_invitation.status = 'pending'
419
+ AND p_invitation.expires_at <= pg_catalog.clock_timestamp()
420
+ THEN 'expired'
421
+ ELSE p_invitation.status
422
+ END,
423
+ 'revision', p_invitation.revision,
424
+ 'expiresAt', p_invitation.expires_at,
425
+ 'acceptedMembershipId', p_invitation.accepted_membership_id,
426
+ 'createdAt', p_invitation.created_at,
427
+ 'updatedAt', p_invitation.updated_at
428
+ )
429
+ $body$;
430
+
431
+ CREATE OR REPLACE FUNCTION list_self_organization_memberships(p_subject_id text)
432
+ RETURNS jsonb
433
+ LANGUAGE plpgsql
434
+ SECURITY DEFINER
435
+ SET search_path FROM CURRENT
436
+ AS $body$
437
+ DECLARE result jsonb;
438
+ BEGIN
439
+ IF p_subject_id IS NULL
440
+ OR p_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
441
+ OR p_subject_id NOT LIKE 'user:%'
442
+ THEN
443
+ RAISE EXCEPTION 'managed human subject authority required' USING ERRCODE = '42501';
444
+ END IF;
445
+ PERFORM pg_catalog.set_config(
446
+ 'opengeni.organization_tenancy_lifecycle',
447
+ 'organization_membership_lifecycle', true
448
+ );
449
+ SELECT coalesce(
450
+ pg_catalog.jsonb_agg(
451
+ opengeni_private.organization_membership_row_json(membership)
452
+ ORDER BY membership.created_at, membership.id
453
+ ), '[]'::jsonb
454
+ ) INTO result
455
+ FROM organization_memberships membership
456
+ WHERE membership.subject_id = p_subject_id;
457
+ RETURN result;
458
+ END
459
+ $body$;
460
+
461
+ CREATE OR REPLACE FUNCTION list_self_organization_invitations(
462
+ p_subject_id text,
463
+ p_cursor_id uuid,
464
+ p_limit integer
465
+ )
466
+ RETURNS jsonb
467
+ LANGUAGE plpgsql
468
+ SECURITY DEFINER
469
+ SET search_path FROM CURRENT
470
+ AS $body$
471
+ DECLARE
472
+ cursor_created_at timestamptz;
473
+ result jsonb;
474
+ BEGIN
475
+ IF p_subject_id IS NULL
476
+ OR p_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
477
+ OR p_subject_id NOT LIKE 'user:%'
478
+ OR p_limit IS NULL OR p_limit < 1 OR p_limit > 100
479
+ THEN
480
+ RAISE EXCEPTION 'managed human subject authority required' USING ERRCODE = '42501';
481
+ END IF;
482
+ PERFORM pg_catalog.set_config(
483
+ 'opengeni.organization_tenancy_lifecycle',
484
+ 'organization_membership_lifecycle', true
485
+ );
486
+ IF p_cursor_id IS NOT NULL THEN
487
+ SELECT invitation.created_at INTO cursor_created_at
488
+ FROM organization_membership_invitations invitation
489
+ WHERE invitation.target_subject_id = p_subject_id
490
+ AND invitation.id = p_cursor_id;
491
+ IF NOT FOUND THEN
492
+ RAISE EXCEPTION 'organization invitation cursor not found' USING ERRCODE = 'P0002';
493
+ END IF;
494
+ END IF;
495
+ SELECT coalesce(
496
+ pg_catalog.jsonb_agg(
497
+ opengeni_private.organization_invitation_row_json(candidate)
498
+ ORDER BY candidate.created_at DESC, candidate.id DESC
499
+ ), '[]'::jsonb
500
+ ) INTO result
501
+ FROM (
502
+ SELECT invitation.*
503
+ FROM organization_membership_invitations invitation
504
+ WHERE invitation.target_subject_id = p_subject_id
505
+ AND (
506
+ p_cursor_id IS NULL
507
+ OR (invitation.created_at, invitation.id) < (cursor_created_at, p_cursor_id)
508
+ )
509
+ ORDER BY invitation.created_at DESC, invitation.id DESC
510
+ LIMIT p_limit + 1
511
+ ) candidate;
512
+ RETURN result;
513
+ END
514
+ $body$;
515
+
516
+ -- Rolling compatibility for callers that predate keyset pagination. The
517
+ -- legacy signature is deliberately capped rather than returning unbounded
518
+ -- invitation history.
519
+ CREATE OR REPLACE FUNCTION list_self_organization_invitations(p_subject_id text)
520
+ RETURNS jsonb
521
+ LANGUAGE sql
522
+ SECURITY DEFINER
523
+ SET search_path FROM CURRENT
524
+ AS $body$
525
+ SELECT list_self_organization_invitations(p_subject_id, NULL::uuid, 100)
526
+ $body$;
527
+
528
+ CREATE OR REPLACE FUNCTION get_self_organization_invitation(
529
+ p_subject_id text,
530
+ p_invitation_id uuid
531
+ )
532
+ RETURNS jsonb
533
+ LANGUAGE plpgsql
534
+ SECURITY DEFINER
535
+ SET search_path FROM CURRENT
536
+ AS $body$
537
+ DECLARE invitation organization_membership_invitations%ROWTYPE;
538
+ BEGIN
539
+ IF p_subject_id IS NULL
540
+ OR p_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
541
+ OR p_subject_id NOT LIKE 'user:%'
542
+ OR p_invitation_id IS NULL
543
+ THEN
544
+ RAISE EXCEPTION 'managed human subject authority required' USING ERRCODE = '42501';
545
+ END IF;
546
+ PERFORM pg_catalog.set_config(
547
+ 'opengeni.organization_tenancy_lifecycle',
548
+ 'organization_membership_lifecycle', true
549
+ );
550
+ SELECT candidate.* INTO invitation
551
+ FROM organization_membership_invitations candidate
552
+ WHERE candidate.id = p_invitation_id
553
+ AND candidate.target_subject_id = p_subject_id;
554
+ IF NOT FOUND THEN
555
+ RAISE EXCEPTION 'organization invitation not found' USING ERRCODE = 'P0002';
556
+ END IF;
557
+ RETURN opengeni_private.organization_invitation_row_json(invitation);
558
+ END
559
+ $body$;
560
+
561
+ CREATE OR REPLACE FUNCTION list_organization_members(
562
+ p_account_id uuid,
563
+ p_actor_subject_id text
564
+ ) RETURNS jsonb
565
+ LANGUAGE plpgsql
566
+ SECURITY DEFINER
567
+ SET search_path FROM CURRENT
568
+ AS $body$
569
+ DECLARE actor organization_memberships%ROWTYPE; result jsonb;
570
+ BEGIN
571
+ IF p_account_id IS NULL
572
+ OR p_actor_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
573
+ OR p_account_id IS DISTINCT FROM opengeni_private.current_account_id()
574
+ THEN
575
+ RAISE EXCEPTION 'organization member authority required' USING ERRCODE = '42501';
576
+ END IF;
577
+ PERFORM pg_catalog.set_config(
578
+ 'opengeni.organization_tenancy_lifecycle',
579
+ 'organization_membership_lifecycle', true
580
+ );
581
+ SELECT * INTO actor FROM organization_memberships membership
582
+ WHERE membership.account_id = p_account_id
583
+ AND membership.subject_id = p_actor_subject_id;
584
+ IF NOT FOUND OR actor.status <> 'active' OR actor.role NOT IN ('owner', 'admin') THEN
585
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
586
+ END IF;
587
+ SELECT coalesce(
588
+ pg_catalog.jsonb_agg(
589
+ opengeni_private.organization_membership_row_json(membership)
590
+ ORDER BY membership.created_at, membership.id
591
+ ), '[]'::jsonb
592
+ ) INTO result
593
+ FROM organization_memberships membership
594
+ WHERE membership.account_id = p_account_id;
595
+ RETURN result;
596
+ END
597
+ $body$;
598
+
599
+ CREATE OR REPLACE FUNCTION list_organization_invitations(
600
+ p_account_id uuid,
601
+ p_actor_subject_id text,
602
+ p_cursor_id uuid,
603
+ p_limit integer
604
+ ) RETURNS jsonb
605
+ LANGUAGE plpgsql
606
+ SECURITY DEFINER
607
+ SET search_path FROM CURRENT
608
+ AS $body$
609
+ DECLARE
610
+ actor organization_memberships%ROWTYPE;
611
+ cursor_created_at timestamptz;
612
+ result jsonb;
613
+ BEGIN
614
+ IF p_account_id IS NULL
615
+ OR p_actor_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
616
+ OR p_account_id IS DISTINCT FROM opengeni_private.current_account_id()
617
+ OR p_limit IS NULL OR p_limit < 1 OR p_limit > 100
618
+ THEN
619
+ RAISE EXCEPTION 'organization invitation page authority is invalid'
620
+ USING ERRCODE = '42501';
621
+ END IF;
622
+ PERFORM pg_catalog.set_config(
623
+ 'opengeni.organization_tenancy_lifecycle',
624
+ 'organization_membership_lifecycle', true
625
+ );
626
+ SELECT * INTO actor FROM organization_memberships membership
627
+ WHERE membership.account_id = p_account_id
628
+ AND membership.subject_id = p_actor_subject_id;
629
+ IF NOT FOUND OR actor.status <> 'active' OR actor.role NOT IN ('owner', 'admin') THEN
630
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
631
+ END IF;
632
+ IF p_cursor_id IS NOT NULL THEN
633
+ SELECT invitation.created_at INTO cursor_created_at
634
+ FROM organization_membership_invitations invitation
635
+ WHERE invitation.account_id = p_account_id AND invitation.id = p_cursor_id;
636
+ IF NOT FOUND THEN
637
+ RAISE EXCEPTION 'organization invitation cursor not found' USING ERRCODE = 'P0002';
638
+ END IF;
639
+ END IF;
640
+ SELECT coalesce(
641
+ pg_catalog.jsonb_agg(
642
+ opengeni_private.organization_invitation_row_json(candidate)
643
+ ORDER BY candidate.created_at DESC, candidate.id DESC
644
+ ), '[]'::jsonb
645
+ ) INTO result
646
+ FROM (
647
+ SELECT invitation.*
648
+ FROM organization_membership_invitations invitation
649
+ WHERE invitation.account_id = p_account_id
650
+ AND (
651
+ p_cursor_id IS NULL
652
+ OR (invitation.created_at, invitation.id) < (cursor_created_at, p_cursor_id)
653
+ )
654
+ ORDER BY invitation.created_at DESC, invitation.id DESC
655
+ LIMIT p_limit + 1
656
+ ) candidate;
657
+ RETURN result;
658
+ END
659
+ $body$;
660
+
661
+ CREATE OR REPLACE FUNCTION prepare_organization_membership_protocol_settlements(
662
+ p_command jsonb
663
+ ) RETURNS jsonb
664
+ LANGUAGE plpgsql
665
+ SECURITY DEFINER
666
+ SET search_path FROM CURRENT
667
+ AS $body$
668
+ DECLARE
669
+ action_name text := p_command ->> 'action';
670
+ account_id_value uuid := nullif(p_command ->> 'organizationId', '')::uuid;
671
+ actor_subject text := p_command ->> 'actorSubjectId';
672
+ operation_id_value uuid := nullif(p_command ->> 'operationId', '')::uuid;
673
+ target_id uuid := nullif(p_command ->> 'membershipId', '')::uuid;
674
+ expected_revision bigint := nullif(
675
+ p_command ->> 'expectedAuthorizationRevision', ''
676
+ )::bigint;
677
+ input_hash_value text;
678
+ receipt_row organization_membership_operation_receipts%ROWTYPE;
679
+ actor organization_memberships%ROWTYPE;
680
+ target organization_memberships%ROWTYPE;
681
+ workspace_id_value uuid;
682
+ result jsonb := '[]'::jsonb;
683
+ previous_workspace text := pg_catalog.current_setting('opengeni.workspace_id', true);
684
+ BEGIN
685
+ IF p_command IS NULL
686
+ OR action_name NOT IN ('suspend', 'offboard')
687
+ OR account_id_value IS NULL
688
+ OR operation_id_value IS NULL
689
+ OR target_id IS NULL
690
+ OR expected_revision IS NULL
691
+ OR actor_subject IS NULL
692
+ OR actor_subject IS DISTINCT FROM opengeni_private.current_subject_id()
693
+ OR account_id_value IS DISTINCT FROM opengeni_private.current_account_id()
694
+ THEN
695
+ RAISE EXCEPTION 'organization membership protocol preparation authority is invalid'
696
+ USING ERRCODE = '42501';
697
+ END IF;
698
+ input_hash_value := pg_catalog.encode(
699
+ pg_catalog.sha256(pg_catalog.convert_to(p_command::text, 'UTF8')), 'hex'
700
+ );
701
+ PERFORM 1 FROM managed_accounts account
702
+ WHERE account.id = account_id_value FOR UPDATE;
703
+ IF NOT FOUND THEN
704
+ RAISE EXCEPTION 'organization not found' USING ERRCODE = 'P0002';
705
+ END IF;
706
+ PERFORM pg_catalog.set_config(
707
+ 'opengeni.organization_tenancy_lifecycle',
708
+ 'organization_membership_lifecycle', true
709
+ );
710
+ SELECT * INTO receipt_row
711
+ FROM organization_membership_operation_receipts receipt
712
+ WHERE receipt.account_id = account_id_value
713
+ AND receipt.operation_id = operation_id_value
714
+ FOR UPDATE;
715
+ IF FOUND THEN
716
+ IF receipt_row.action IS DISTINCT FROM action_name
717
+ OR receipt_row.input_hash IS DISTINCT FROM input_hash_value
718
+ THEN
719
+ RAISE EXCEPTION 'organization operation id was reused with different input'
720
+ USING ERRCODE = '23505';
721
+ END IF;
722
+ RETURN result;
723
+ END IF;
724
+ FOR workspace_id_value IN
725
+ SELECT workspace.id FROM workspaces workspace
726
+ WHERE workspace.account_id = account_id_value ORDER BY workspace.id
727
+ LOOP
728
+ PERFORM pg_catalog.set_config(
729
+ 'opengeni.workspace_id', workspace_id_value::text, true
730
+ );
731
+ PERFORM 1 FROM workspace_inference_controls control
732
+ WHERE control.account_id = account_id_value
733
+ AND control.workspace_id = workspace_id_value
734
+ FOR SHARE;
735
+ PERFORM 1 FROM workspaces workspace
736
+ WHERE workspace.account_id = account_id_value
737
+ AND workspace.id = workspace_id_value
738
+ FOR KEY SHARE;
739
+ END LOOP;
740
+ PERFORM 1 FROM organization_memberships membership
741
+ WHERE membership.account_id = account_id_value
742
+ AND membership.id IN (
743
+ target_id,
744
+ (
745
+ SELECT candidate.id FROM organization_memberships candidate
746
+ WHERE candidate.account_id = account_id_value
747
+ AND candidate.subject_id = actor_subject
748
+ )
749
+ )
750
+ ORDER BY membership.id
751
+ FOR UPDATE;
752
+ SELECT * INTO actor FROM organization_memberships membership
753
+ WHERE membership.account_id = account_id_value
754
+ AND membership.subject_id = actor_subject;
755
+ SELECT * INTO target FROM organization_memberships membership
756
+ WHERE membership.account_id = account_id_value AND membership.id = target_id;
757
+ IF actor.id IS NULL OR actor.status <> 'active' OR actor.role NOT IN ('owner', 'admin') THEN
758
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
759
+ END IF;
760
+ IF target.id IS NULL THEN
761
+ RAISE EXCEPTION 'organization member not found' USING ERRCODE = 'P0002';
762
+ END IF;
763
+ IF target.authorization_revision IS DISTINCT FROM expected_revision THEN
764
+ RAISE EXCEPTION 'organization membership revision is stale' USING ERRCODE = '40001';
765
+ END IF;
766
+ IF actor.role = 'admin' AND target.role <> 'member' THEN
767
+ RAISE EXCEPTION 'administrators may manage members only' USING ERRCODE = '42501';
768
+ END IF;
769
+ IF action_name = 'suspend' AND target.status <> 'active' THEN
770
+ RAISE EXCEPTION 'only an active member can be suspended' USING ERRCODE = '55000';
771
+ ELSIF action_name = 'offboard' AND target.status NOT IN ('active', 'suspended') THEN
772
+ RAISE EXCEPTION 'only an active or suspended member can be offboarded'
773
+ USING ERRCODE = '55000';
774
+ END IF;
775
+ IF target.role = 'owner' AND NOT EXISTS (
776
+ SELECT 1 FROM organization_memberships other
777
+ WHERE other.account_id = account_id_value AND other.id <> target.id
778
+ AND other.status = 'active' AND other.role = 'owner'
779
+ ) THEN
780
+ RAISE EXCEPTION 'cannot remove the last active organization owner'
781
+ USING ERRCODE = '55000';
782
+ END IF;
783
+ FOR workspace_id_value IN
784
+ SELECT workspace.id FROM workspaces workspace
785
+ WHERE workspace.account_id = account_id_value ORDER BY workspace.id
786
+ LOOP
787
+ PERFORM pg_catalog.set_config(
788
+ 'opengeni.workspace_id', workspace_id_value::text, true
789
+ );
790
+ PERFORM 1 FROM sessions affected
791
+ WHERE affected.account_id = account_id_value
792
+ AND affected.workspace_id = workspace_id_value
793
+ AND (
794
+ affected.owner_organization_membership_id = target.id
795
+ OR EXISTS (
796
+ SELECT 1 FROM session_turns initiated
797
+ WHERE initiated.session_id = affected.id
798
+ AND initiated.initiating_human_subject_id = target.subject_id
799
+ )
800
+ )
801
+ ORDER BY affected.id
802
+ FOR NO KEY UPDATE;
803
+ PERFORM 1 FROM session_turns turn_row
804
+ WHERE turn_row.account_id = account_id_value
805
+ AND turn_row.workspace_id = workspace_id_value
806
+ AND EXISTS (
807
+ SELECT 1 FROM sessions owned
808
+ WHERE owned.id = turn_row.session_id
809
+ AND (
810
+ owned.owner_organization_membership_id = target.id
811
+ OR turn_row.initiating_human_subject_id = target.subject_id
812
+ )
813
+ )
814
+ ORDER BY turn_row.id
815
+ FOR UPDATE;
816
+ PERFORM 1 FROM session_turn_attempts attempt
817
+ WHERE attempt.account_id = account_id_value
818
+ AND attempt.workspace_id = workspace_id_value
819
+ AND EXISTS (
820
+ SELECT 1 FROM sessions owned
821
+ WHERE owned.id = attempt.session_id
822
+ AND (
823
+ owned.owner_organization_membership_id = target.id
824
+ OR EXISTS (
825
+ SELECT 1 FROM session_turns initiated
826
+ WHERE initiated.id = attempt.turn_id
827
+ AND initiated.initiating_human_subject_id = target.subject_id
828
+ )
829
+ )
830
+ )
831
+ ORDER BY attempt.id
832
+ FOR UPDATE;
833
+ SELECT result || COALESCE(
834
+ pg_catalog.jsonb_agg(
835
+ pg_catalog.jsonb_build_object(
836
+ 'accountId', turn_row.account_id,
837
+ 'workspaceId', turn_row.workspace_id,
838
+ 'sessionId', turn_row.session_id,
839
+ 'turnId', turn_row.id,
840
+ 'executionGeneration', turn_row.execution_generation,
841
+ 'turnAssociation', CASE WHEN owned.active_turn_id = turn_row.id
842
+ THEN 'current' ELSE NULL END
843
+ ) ORDER BY turn_row.session_id, turn_row.id
844
+ ), '[]'::jsonb
845
+ ) INTO result
846
+ FROM session_turns turn_row
847
+ JOIN sessions owned ON owned.id = turn_row.session_id
848
+ WHERE turn_row.account_id = account_id_value
849
+ AND turn_row.workspace_id = workspace_id_value
850
+ AND turn_row.status IN (
851
+ 'queued', 'running', 'requires_action', 'recovering', 'waiting_capacity'
852
+ )
853
+ AND (
854
+ owned.owner_organization_membership_id = target.id
855
+ OR turn_row.initiating_human_subject_id = target.subject_id
856
+ )
857
+ AND EXISTS (
858
+ SELECT 1 FROM session_pending_tool_calls pending
859
+ WHERE pending.account_id = turn_row.account_id
860
+ AND pending.workspace_id = turn_row.workspace_id
861
+ AND pending.session_id = turn_row.session_id
862
+ AND pending.turn_id = turn_row.id
863
+ )
864
+ AND NOT EXISTS (
865
+ SELECT 1 FROM session_turn_attempts live_attempt
866
+ WHERE live_attempt.account_id = turn_row.account_id
867
+ AND live_attempt.workspace_id = turn_row.workspace_id
868
+ AND live_attempt.session_id = turn_row.session_id
869
+ AND live_attempt.turn_id = turn_row.id
870
+ AND live_attempt.id = turn_row.active_attempt_id
871
+ AND live_attempt.state IN ('claimed', 'running')
872
+ );
873
+ END LOOP;
874
+ PERFORM pg_catalog.set_config(
875
+ 'opengeni.workspace_id', COALESCE(previous_workspace, ''), true
876
+ );
877
+ RETURN result;
878
+ EXCEPTION WHEN OTHERS THEN
879
+ PERFORM pg_catalog.set_config(
880
+ 'opengeni.workspace_id', COALESCE(previous_workspace, ''), true
881
+ );
882
+ RAISE;
883
+ END
884
+ $body$;
885
+
886
+ CREATE OR REPLACE FUNCTION organization_membership_command(p_command jsonb)
887
+ RETURNS jsonb
888
+ LANGUAGE plpgsql
889
+ SECURITY DEFINER
890
+ SET search_path FROM CURRENT
891
+ AS $body$
892
+ DECLARE
893
+ action_name text := p_command ->> 'action';
894
+ account_id_value uuid := nullif(p_command ->> 'organizationId', '')::uuid;
895
+ actor_subject text := p_command ->> 'actorSubjectId';
896
+ operation_id_value uuid := nullif(p_command ->> 'operationId', '')::uuid;
897
+ input_hash_value text;
898
+ receipt_row organization_membership_operation_receipts%ROWTYPE;
899
+ actor organization_memberships%ROWTYPE;
900
+ target organization_memberships%ROWTYPE;
901
+ invitation organization_membership_invitations%ROWTYPE;
902
+ policy organization_user_retention_policies%ROWTYPE;
903
+ result jsonb;
904
+ now_value timestamptz := pg_catalog.clock_timestamp();
905
+ target_subject text;
906
+ target_email text;
907
+ requested_role text;
908
+ target_id uuid;
909
+ expected_revision bigint;
910
+ workspace_id_value uuid;
911
+ reason_value text;
912
+ retention_days_value integer;
913
+ previous_workspace text := pg_catalog.current_setting('opengeni.workspace_id', true);
914
+ previous_visibility_capability text := pg_catalog.current_setting(
915
+ 'opengeni.session_visibility_write_capability', true
916
+ );
917
+ previous_activity_gate_state text := pg_catalog.current_setting(
918
+ 'opengeni.session_activity_gate_state', true
919
+ );
920
+ previous_activity_gate_workspace text := pg_catalog.current_setting(
921
+ 'opengeni.session_activity_gate_workspace_id', true
922
+ );
923
+ visibility_capability_id uuid;
924
+ interruption_operation_id uuid;
925
+ affected_session_ids uuid[];
926
+ affected_realtime_ids uuid[];
927
+ cancelled_turn_ids uuid[];
928
+ cancelled_turn record;
929
+ cancelled_request record;
930
+ cancelled_sequence bigint;
931
+ cancelled_association text;
932
+ settled_update_ids uuid[];
933
+ settled_history_item_id uuid;
934
+ activity_revision_value bigint;
935
+ BEGIN
936
+ IF p_command IS NULL
937
+ OR action_name NOT IN ('invite', 'accept', 'revoke_invitation', 'change_role', 'suspend', 'reactivate', 'offboard', 'retention')
938
+ OR account_id_value IS NULL
939
+ OR operation_id_value IS NULL
940
+ OR actor_subject IS NULL
941
+ OR actor_subject IS DISTINCT FROM opengeni_private.current_subject_id()
942
+ OR account_id_value IS DISTINCT FROM opengeni_private.current_account_id()
943
+ THEN
944
+ RAISE EXCEPTION 'organization membership command authority is invalid'
945
+ USING ERRCODE = '42501';
946
+ END IF;
947
+ input_hash_value := pg_catalog.encode(
948
+ pg_catalog.sha256(pg_catalog.convert_to(p_command::text, 'UTF8')), 'hex'
949
+ );
950
+ PERFORM 1 FROM managed_accounts account WHERE account.id = account_id_value FOR UPDATE;
951
+ IF NOT FOUND THEN RAISE EXCEPTION 'organization not found' USING ERRCODE = 'P0002'; END IF;
952
+ PERFORM pg_catalog.set_config(
953
+ 'opengeni.organization_tenancy_lifecycle',
954
+ 'organization_membership_lifecycle', true
955
+ );
956
+
957
+ SELECT * INTO receipt_row
958
+ FROM organization_membership_operation_receipts receipt
959
+ WHERE receipt.account_id = account_id_value
960
+ AND receipt.operation_id = operation_id_value
961
+ FOR UPDATE;
962
+ IF FOUND THEN
963
+ IF receipt_row.action IS DISTINCT FROM action_name
964
+ OR receipt_row.input_hash IS DISTINCT FROM input_hash_value
965
+ THEN
966
+ RAISE EXCEPTION 'organization operation id was reused with different input'
967
+ USING ERRCODE = '23505';
968
+ END IF;
969
+ RETURN receipt_row.result;
970
+ END IF;
971
+
972
+ IF action_name = 'accept' THEN
973
+ target_id := nullif(p_command ->> 'invitationId', '')::uuid;
974
+ expected_revision := nullif(p_command ->> 'expectedRevision', '')::bigint;
975
+ SELECT * INTO invitation
976
+ FROM organization_membership_invitations candidate
977
+ WHERE candidate.id = target_id AND candidate.account_id = account_id_value
978
+ FOR UPDATE;
979
+ IF NOT FOUND THEN RAISE EXCEPTION 'invitation not found' USING ERRCODE = 'P0002'; END IF;
980
+ IF invitation.target_subject_id IS DISTINCT FROM actor_subject THEN
981
+ RAISE EXCEPTION 'invitation belongs to another subject' USING ERRCODE = '42501';
982
+ END IF;
983
+ IF invitation.status <> 'pending' OR invitation.expires_at <= now_value THEN
984
+ RAISE EXCEPTION 'invitation is not active' USING ERRCODE = '55000';
985
+ END IF;
986
+ IF invitation.revision IS DISTINCT FROM expected_revision THEN
987
+ RAISE EXCEPTION 'invitation revision is stale' USING ERRCODE = '40001';
988
+ END IF;
989
+ SELECT * INTO target FROM organization_memberships membership
990
+ WHERE membership.account_id = account_id_value
991
+ AND membership.subject_id = actor_subject
992
+ FOR UPDATE;
993
+ IF NOT FOUND THEN
994
+ workspace_id_value := gen_random_uuid();
995
+ PERFORM pg_catalog.set_config('opengeni.workspace_id', workspace_id_value::text, true);
996
+ INSERT INTO workspaces (
997
+ id, account_id, name, slug, external_source, external_id
998
+ ) VALUES (
999
+ workspace_id_value, account_id_value, 'Personal workspace', NULL,
1000
+ 'opengeni:organization-membership', account_id_value::text || ':' || actor_subject
1001
+ ) ON CONFLICT (external_source, external_id) DO UPDATE
1002
+ SET updated_at = workspaces.updated_at
1003
+ RETURNING id INTO workspace_id_value;
1004
+ INSERT INTO workspace_inference_controls (workspace_id, account_id)
1005
+ VALUES (workspace_id_value, account_id_value) ON CONFLICT DO NOTHING;
1006
+ INSERT INTO organization_memberships (
1007
+ account_id, subject_id, role, status, personal_workspace_id
1008
+ ) VALUES (
1009
+ account_id_value, actor_subject, invitation.role, 'active', workspace_id_value
1010
+ ) RETURNING * INTO target;
1011
+ ELSE
1012
+ IF target.status = 'active' THEN
1013
+ RAISE EXCEPTION 'organization membership is already active' USING ERRCODE = '55000';
1014
+ END IF;
1015
+ RAISE EXCEPTION 'inactive organization membership requires explicit owner lifecycle action'
1016
+ USING ERRCODE = '55000';
1017
+ END IF;
1018
+ UPDATE organization_membership_invitations SET
1019
+ status = 'accepted', revision = revision + 1,
1020
+ accepted_membership_id = target.id, updated_at = now_value
1021
+ WHERE id = invitation.id RETURNING * INTO invitation;
1022
+ result := pg_catalog.jsonb_build_object(
1023
+ 'invitation', opengeni_private.organization_invitation_row_json(invitation),
1024
+ 'membership', opengeni_private.organization_membership_row_json(target)
1025
+ );
1026
+ actor := target;
1027
+ ELSE
1028
+ -- Existing session-visibility activation serializes workspace -> member ->
1029
+ -- session. Offboarding spans every owned workspace, so pre-lock that exact
1030
+ -- workspace prefix in UUID order before locking either actor or target
1031
+ -- membership. This prevents member/workspace lock inversion while the
1032
+ -- later per-workspace pass takes the canonical session/turn/attempt suffix.
1033
+ IF action_name IN ('suspend', 'offboard') THEN
1034
+ target_id := nullif(p_command ->> 'membershipId', '')::uuid;
1035
+ expected_revision := nullif(
1036
+ p_command ->> 'expectedAuthorizationRevision', ''
1037
+ )::bigint;
1038
+ FOR workspace_id_value IN
1039
+ SELECT workspace.id
1040
+ FROM workspaces workspace
1041
+ WHERE workspace.account_id = account_id_value
1042
+ ORDER BY workspace.id
1043
+ LOOP
1044
+ PERFORM pg_catalog.set_config(
1045
+ 'opengeni.workspace_id', workspace_id_value::text, true
1046
+ );
1047
+ PERFORM 1 FROM workspace_inference_controls control
1048
+ WHERE control.account_id = account_id_value
1049
+ AND control.workspace_id = workspace_id_value
1050
+ FOR SHARE;
1051
+ PERFORM 1 FROM workspaces workspace
1052
+ WHERE workspace.account_id = account_id_value
1053
+ AND workspace.id = workspace_id_value
1054
+ FOR KEY SHARE;
1055
+ END LOOP;
1056
+ PERFORM 1 FROM organization_memberships membership
1057
+ WHERE membership.account_id = account_id_value
1058
+ AND membership.id IN (
1059
+ target_id,
1060
+ (
1061
+ SELECT candidate.id FROM organization_memberships candidate
1062
+ WHERE candidate.account_id = account_id_value
1063
+ AND candidate.subject_id = actor_subject
1064
+ )
1065
+ )
1066
+ ORDER BY membership.id
1067
+ FOR UPDATE;
1068
+ SELECT * INTO actor FROM organization_memberships membership
1069
+ WHERE membership.account_id = account_id_value
1070
+ AND membership.subject_id = actor_subject;
1071
+ ELSE
1072
+ SELECT * INTO actor FROM organization_memberships membership
1073
+ WHERE membership.account_id = account_id_value
1074
+ AND membership.subject_id = actor_subject
1075
+ FOR UPDATE;
1076
+ END IF;
1077
+ IF NOT FOUND OR actor.status <> 'active' THEN
1078
+ RAISE EXCEPTION 'active organization membership required' USING ERRCODE = '42501';
1079
+ END IF;
1080
+
1081
+ IF action_name = 'invite' THEN
1082
+ IF actor.role NOT IN ('owner', 'admin') THEN
1083
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
1084
+ END IF;
1085
+ target_subject := p_command ->> 'targetSubjectId';
1086
+ target_email := lower(btrim(p_command ->> 'targetEmail'));
1087
+ requested_role := p_command ->> 'role';
1088
+ IF target_subject IS NULL OR target_subject NOT LIKE 'user:%'
1089
+ OR requested_role NOT IN ('owner', 'admin', 'member')
1090
+ OR (actor.role = 'admin' AND requested_role <> 'member')
1091
+ OR nullif(p_command ->> 'expiresAt', '')::timestamptz <= now_value
1092
+ OR nullif(p_command ->> 'expiresAt', '')::timestamptz > now_value + interval '30 days'
1093
+ THEN
1094
+ RAISE EXCEPTION 'invitation input is invalid' USING ERRCODE = '22023';
1095
+ END IF;
1096
+ IF EXISTS (
1097
+ SELECT 1 FROM organization_memberships membership
1098
+ WHERE membership.account_id = account_id_value
1099
+ AND membership.subject_id = target_subject
1100
+ AND membership.status = 'active'
1101
+ ) THEN
1102
+ RAISE EXCEPTION 'subject is already an active organization member' USING ERRCODE = '55000';
1103
+ END IF;
1104
+ IF EXISTS (
1105
+ SELECT 1 FROM organization_memberships membership
1106
+ WHERE membership.account_id = account_id_value
1107
+ AND membership.subject_id = target_subject
1108
+ AND membership.status IN ('suspended', 'revoked')
1109
+ ) THEN
1110
+ RAISE EXCEPTION 'inactive organization membership cannot be re-invited'
1111
+ USING ERRCODE = '55000';
1112
+ END IF;
1113
+ UPDATE organization_membership_invitations SET
1114
+ status = 'revoked', revision = revision + 1, updated_at = now_value
1115
+ WHERE account_id = account_id_value
1116
+ AND target_subject_id = target_subject AND status = 'pending';
1117
+ INSERT INTO organization_membership_invitations (
1118
+ account_id, target_subject_id, target_email, role,
1119
+ created_by_membership_id, expires_at
1120
+ ) VALUES (
1121
+ account_id_value, target_subject, target_email, requested_role,
1122
+ actor.id, nullif(p_command ->> 'expiresAt', '')::timestamptz
1123
+ ) RETURNING * INTO invitation;
1124
+ result := opengeni_private.organization_invitation_row_json(invitation);
1125
+ target := NULL;
1126
+ ELSIF action_name = 'revoke_invitation' THEN
1127
+ IF actor.role NOT IN ('owner', 'admin') THEN
1128
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
1129
+ END IF;
1130
+ target_id := nullif(p_command ->> 'invitationId', '')::uuid;
1131
+ expected_revision := nullif(p_command ->> 'expectedRevision', '')::bigint;
1132
+ SELECT * INTO invitation
1133
+ FROM organization_membership_invitations candidate
1134
+ WHERE candidate.id = target_id AND candidate.account_id = account_id_value
1135
+ FOR UPDATE;
1136
+ IF NOT FOUND THEN RAISE EXCEPTION 'invitation not found' USING ERRCODE = 'P0002'; END IF;
1137
+ IF invitation.revision IS DISTINCT FROM expected_revision THEN
1138
+ RAISE EXCEPTION 'invitation revision is stale' USING ERRCODE = '40001';
1139
+ END IF;
1140
+ IF invitation.status <> 'pending' THEN
1141
+ RAISE EXCEPTION 'only a pending invitation can be revoked' USING ERRCODE = '55000';
1142
+ END IF;
1143
+ IF actor.role = 'admin' AND invitation.role <> 'member' THEN
1144
+ RAISE EXCEPTION 'administrators may revoke member invitations only' USING ERRCODE = '42501';
1145
+ END IF;
1146
+ UPDATE organization_membership_invitations SET
1147
+ status = 'revoked', revision = revision + 1, updated_at = now_value
1148
+ WHERE id = invitation.id RETURNING * INTO invitation;
1149
+ result := opengeni_private.organization_invitation_row_json(invitation);
1150
+ target := NULL;
1151
+ ELSIF action_name = 'retention' THEN
1152
+ IF actor.role <> 'owner' THEN
1153
+ RAISE EXCEPTION 'organization owner required' USING ERRCODE = '42501';
1154
+ END IF;
1155
+ expected_revision := nullif(p_command ->> 'expectedVersion', '')::bigint;
1156
+ requested_role := p_command ->> 'mode';
1157
+ retention_days_value := nullif(p_command ->> 'retentionDays', '')::integer;
1158
+ IF requested_role NOT IN ('retain', 'delete_after')
1159
+ OR (requested_role = 'retain' AND retention_days_value IS NOT NULL)
1160
+ OR (requested_role = 'delete_after' AND retention_days_value NOT BETWEEN 30 AND 90)
1161
+ THEN
1162
+ RAISE EXCEPTION 'retention policy input is invalid' USING ERRCODE = '22023';
1163
+ END IF;
1164
+ SELECT * INTO policy FROM organization_user_retention_policies candidate
1165
+ WHERE candidate.account_id = account_id_value FOR UPDATE;
1166
+ IF NOT FOUND THEN
1167
+ INSERT INTO organization_user_retention_policies (
1168
+ account_id, mode, retention_days, version, updated_by_membership_id
1169
+ ) VALUES (account_id_value, 'retain', NULL, 1, actor.id)
1170
+ RETURNING * INTO policy;
1171
+ END IF;
1172
+ IF policy.version IS DISTINCT FROM expected_revision THEN
1173
+ RAISE EXCEPTION 'retention policy version is stale' USING ERRCODE = '40001';
1174
+ END IF;
1175
+ UPDATE organization_user_retention_policies SET
1176
+ mode = requested_role, retention_days = retention_days_value,
1177
+ version = version + 1, updated_by_membership_id = actor.id,
1178
+ updated_at = now_value
1179
+ WHERE account_id = account_id_value RETURNING * INTO policy;
1180
+ result := pg_catalog.jsonb_build_object(
1181
+ 'organizationId', policy.account_id, 'mode', policy.mode,
1182
+ 'retentionDays', policy.retention_days, 'version', policy.version,
1183
+ 'updatedAt', policy.updated_at
1184
+ );
1185
+ target := NULL;
1186
+ ELSE
1187
+ IF actor.role NOT IN ('owner', 'admin') THEN
1188
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
1189
+ END IF;
1190
+ target_id := nullif(p_command ->> 'membershipId', '')::uuid;
1191
+ expected_revision := nullif(p_command ->> 'expectedAuthorizationRevision', '')::bigint;
1192
+ requested_role := p_command ->> 'role';
1193
+ reason_value := nullif(btrim(p_command ->> 'reason'), '');
1194
+ IF reason_value IS NOT NULL
1195
+ AND octet_length(convert_to(reason_value, 'UTF8')) > 512
1196
+ THEN RAISE EXCEPTION 'reason is too large' USING ERRCODE = '22023'; END IF;
1197
+ PERFORM 1 FROM organization_memberships membership
1198
+ WHERE membership.account_id = account_id_value
1199
+ AND membership.id IN (actor.id, target_id)
1200
+ ORDER BY membership.id FOR UPDATE;
1201
+ SELECT * INTO target FROM organization_memberships membership
1202
+ WHERE membership.account_id = account_id_value AND membership.id = target_id;
1203
+ IF NOT FOUND THEN RAISE EXCEPTION 'organization member not found' USING ERRCODE = 'P0002'; END IF;
1204
+ IF target.authorization_revision IS DISTINCT FROM expected_revision THEN
1205
+ RAISE EXCEPTION 'organization membership revision is stale' USING ERRCODE = '40001';
1206
+ END IF;
1207
+ IF actor.role = 'admin' AND target.role <> 'member' THEN
1208
+ RAISE EXCEPTION 'administrators may manage members only' USING ERRCODE = '42501';
1209
+ END IF;
1210
+ IF target.role = 'owner' AND (
1211
+ action_name IN ('suspend', 'offboard')
1212
+ OR (action_name = 'change_role' AND requested_role <> 'owner')
1213
+ ) AND NOT EXISTS (
1214
+ SELECT 1 FROM organization_memberships other
1215
+ WHERE other.account_id = account_id_value AND other.id <> target.id
1216
+ AND other.status = 'active' AND other.role = 'owner'
1217
+ ) THEN
1218
+ RAISE EXCEPTION 'cannot remove the last active organization owner'
1219
+ USING ERRCODE = '55000';
1220
+ END IF;
1221
+ IF action_name = 'change_role' THEN
1222
+ IF target.status <> 'active' OR requested_role NOT IN ('owner', 'admin', 'member')
1223
+ OR (actor.role = 'admin' AND requested_role <> 'member')
1224
+ THEN RAISE EXCEPTION 'role transition is invalid' USING ERRCODE = '55000'; END IF;
1225
+ UPDATE organization_memberships SET role = requested_role,
1226
+ authorization_revision = authorization_revision + 1, updated_at = now_value
1227
+ WHERE id = target.id RETURNING * INTO target;
1228
+ ELSIF action_name = 'reactivate' THEN
1229
+ IF target.status <> 'suspended' THEN
1230
+ RAISE EXCEPTION 'only a suspended member can be reactivated' USING ERRCODE = '55000';
1231
+ END IF;
1232
+ UPDATE organization_memberships SET status = 'active',
1233
+ authorization_revision = authorization_revision + 1, updated_at = now_value
1234
+ WHERE id = target.id RETURNING * INTO target;
1235
+ ELSIF action_name IN ('suspend', 'offboard') THEN
1236
+ IF action_name = 'suspend' AND target.status <> 'active' THEN
1237
+ RAISE EXCEPTION 'only an active member can be suspended' USING ERRCODE = '55000';
1238
+ ELSIF action_name = 'offboard' AND target.status NOT IN ('active', 'suspended') THEN
1239
+ RAISE EXCEPTION 'only an active or suspended member can be offboarded'
1240
+ USING ERRCODE = '55000';
1241
+ END IF;
1242
+ SELECT * INTO policy FROM organization_user_retention_policies candidate
1243
+ WHERE candidate.account_id = account_id_value;
1244
+ UPDATE organization_user_resource_grants SET
1245
+ status = 'revoked', generation = generation + 1,
1246
+ revoked_at = now_value, updated_at = now_value
1247
+ WHERE account_id = account_id_value
1248
+ AND owner_organization_membership_id = target.id AND status = 'active';
1249
+ UPDATE organization_user_resource_authorities SET
1250
+ status = 'retained', generation = generation + 1, updated_at = now_value
1251
+ WHERE account_id = account_id_value
1252
+ AND organization_membership_id = target.id AND status = 'active'
1253
+ AND action_name = 'offboard';
1254
+ DELETE FROM workspace_memberships
1255
+ WHERE account_id = account_id_value AND subject_id = target.subject_id;
1256
+ -- Private-session attempts carry the exact authority epoch. Advance it
1257
+ -- under each workspace's FORCE-RLS context without touching updated_at
1258
+ -- (the session activity commit gate owns that separate concern).
1259
+ visibility_capability_id := pg_catalog.gen_random_uuid();
1260
+ INSERT INTO session_visibility_write_capabilities (
1261
+ backend_pid, transaction_id, capability_id
1262
+ ) VALUES (
1263
+ pg_catalog.pg_backend_pid(), pg_catalog.pg_current_xact_id(),
1264
+ visibility_capability_id
1265
+ );
1266
+ PERFORM pg_catalog.set_config(
1267
+ 'opengeni.session_visibility_write_capability',
1268
+ visibility_capability_id::text, true
1269
+ );
1270
+ FOR workspace_id_value IN
1271
+ SELECT workspace.id
1272
+ FROM workspaces workspace
1273
+ WHERE workspace.account_id = account_id_value
1274
+ ORDER BY workspace.id
1275
+ LOOP
1276
+ PERFORM pg_catalog.set_config(
1277
+ 'opengeni.workspace_id', workspace_id_value::text, true
1278
+ );
1279
+ PERFORM pg_catalog.set_config(
1280
+ 'opengeni.session_activity_gate_state', 'open', true
1281
+ );
1282
+ PERFORM pg_catalog.set_config(
1283
+ 'opengeni.session_activity_gate_workspace_id', workspace_id_value::text, true
1284
+ );
1285
+ PERFORM 1 FROM workspace_inference_controls control
1286
+ WHERE control.account_id = account_id_value
1287
+ AND control.workspace_id = workspace_id_value
1288
+ FOR SHARE;
1289
+ PERFORM 1 FROM workspaces workspace
1290
+ WHERE workspace.account_id = account_id_value
1291
+ AND workspace.id = workspace_id_value
1292
+ FOR KEY SHARE;
1293
+ PERFORM 1 FROM sessions affected
1294
+ WHERE affected.account_id = account_id_value
1295
+ AND affected.workspace_id = workspace_id_value
1296
+ AND (
1297
+ affected.owner_organization_membership_id = target.id
1298
+ OR EXISTS (
1299
+ SELECT 1 FROM session_turns initiated
1300
+ WHERE initiated.session_id = affected.id
1301
+ AND initiated.initiating_human_subject_id = target.subject_id
1302
+ )
1303
+ )
1304
+ ORDER BY affected.id
1305
+ FOR NO KEY UPDATE;
1306
+ PERFORM 1 FROM session_turns turn_row
1307
+ WHERE turn_row.account_id = account_id_value
1308
+ AND turn_row.workspace_id = workspace_id_value
1309
+ AND EXISTS (
1310
+ SELECT 1 FROM sessions owned
1311
+ WHERE owned.id = turn_row.session_id
1312
+ AND (
1313
+ owned.owner_organization_membership_id = target.id
1314
+ OR turn_row.initiating_human_subject_id = target.subject_id
1315
+ )
1316
+ )
1317
+ ORDER BY turn_row.id
1318
+ FOR UPDATE;
1319
+ PERFORM 1 FROM session_turn_attempts attempt
1320
+ WHERE attempt.account_id = account_id_value
1321
+ AND attempt.workspace_id = workspace_id_value
1322
+ AND EXISTS (
1323
+ SELECT 1 FROM sessions owned
1324
+ WHERE owned.id = attempt.session_id
1325
+ AND (
1326
+ owned.owner_organization_membership_id = target.id
1327
+ OR EXISTS (
1328
+ SELECT 1 FROM session_turns initiated
1329
+ WHERE initiated.id = attempt.turn_id
1330
+ AND initiated.initiating_human_subject_id = target.subject_id
1331
+ )
1332
+ )
1333
+ )
1334
+ ORDER BY attempt.id
1335
+ FOR UPDATE;
1336
+ PERFORM 1 FROM session_realtime_modes realtime
1337
+ WHERE realtime.account_id = account_id_value
1338
+ AND realtime.workspace_id = workspace_id_value
1339
+ AND realtime.owner_subject_id = target.subject_id
1340
+ AND realtime.state = 'active'
1341
+ ORDER BY realtime.id
1342
+ FOR UPDATE;
1343
+ SELECT COALESCE(pg_catalog.array_agg(realtime.id ORDER BY realtime.id), ARRAY[]::uuid[])
1344
+ INTO affected_realtime_ids
1345
+ FROM session_realtime_modes realtime
1346
+ WHERE realtime.account_id = account_id_value
1347
+ AND realtime.workspace_id = workspace_id_value
1348
+ AND realtime.owner_subject_id = target.subject_id
1349
+ AND realtime.state = 'active';
1350
+ PERFORM 1 FROM session_realtime_connections connection
1351
+ WHERE connection.account_id = account_id_value
1352
+ AND connection.workspace_id = workspace_id_value
1353
+ AND connection.realtime_id = ANY(affected_realtime_ids)
1354
+ AND connection.state IN ('negotiating', 'ready', 'active')
1355
+ ORDER BY connection.id
1356
+ FOR UPDATE;
1357
+ IF EXISTS (
1358
+ SELECT 1
1359
+ FROM session_turns turn_row
1360
+ JOIN sessions owned ON owned.id = turn_row.session_id
1361
+ WHERE turn_row.account_id = account_id_value
1362
+ AND turn_row.workspace_id = workspace_id_value
1363
+ AND turn_row.status IN (
1364
+ 'queued', 'running', 'requires_action', 'recovering', 'waiting_capacity'
1365
+ )
1366
+ AND (
1367
+ owned.owner_organization_membership_id = target.id
1368
+ OR turn_row.initiating_human_subject_id = target.subject_id
1369
+ )
1370
+ AND EXISTS (
1371
+ SELECT 1 FROM session_pending_tool_calls pending
1372
+ WHERE pending.account_id = turn_row.account_id
1373
+ AND pending.workspace_id = turn_row.workspace_id
1374
+ AND pending.session_id = turn_row.session_id
1375
+ AND pending.turn_id = turn_row.id
1376
+ )
1377
+ AND NOT EXISTS (
1378
+ SELECT 1 FROM session_turn_attempts live_attempt
1379
+ WHERE live_attempt.account_id = turn_row.account_id
1380
+ AND live_attempt.workspace_id = turn_row.workspace_id
1381
+ AND live_attempt.session_id = turn_row.session_id
1382
+ AND live_attempt.turn_id = turn_row.id
1383
+ AND live_attempt.id = turn_row.active_attempt_id
1384
+ AND live_attempt.state IN ('claimed', 'running')
1385
+ )
1386
+ ) THEN
1387
+ RAISE EXCEPTION 'pending tool calls require canonical protocol settlement'
1388
+ USING ERRCODE = '55000';
1389
+ END IF;
1390
+ SELECT COALESCE(pg_catalog.array_agg(DISTINCT turn_row.session_id), ARRAY[]::uuid[])
1391
+ INTO affected_session_ids
1392
+ FROM session_turns turn_row
1393
+ JOIN sessions owned
1394
+ ON owned.account_id = turn_row.account_id
1395
+ AND owned.workspace_id = turn_row.workspace_id
1396
+ AND owned.id = turn_row.session_id
1397
+ WHERE turn_row.account_id = account_id_value
1398
+ AND turn_row.workspace_id = workspace_id_value
1399
+ AND turn_row.status IN (
1400
+ 'queued', 'running', 'requires_action', 'recovering', 'waiting_capacity'
1401
+ )
1402
+ AND (
1403
+ owned.owner_organization_membership_id = target.id
1404
+ OR turn_row.initiating_human_subject_id = target.subject_id
1405
+ );
1406
+ affected_session_ids := ARRAY(
1407
+ SELECT DISTINCT candidate.session_id
1408
+ FROM (
1409
+ SELECT unnest(affected_session_ids) AS session_id
1410
+ UNION ALL
1411
+ SELECT realtime.session_id
1412
+ FROM session_realtime_modes realtime
1413
+ WHERE realtime.id = ANY(affected_realtime_ids)
1414
+ ) candidate
1415
+ ORDER BY candidate.session_id
1416
+ );
1417
+ interruption_operation_id := pg_catalog.gen_random_uuid();
1418
+ INSERT INTO session_command_receipts (
1419
+ id, account_id, workspace_id, actor_type, actor_subject_id,
1420
+ action, operation_key, canonical_request_hash, result
1421
+ ) VALUES (
1422
+ interruption_operation_id, account_id_value, workspace_id_value,
1423
+ 'human', actor_subject, 'organization.membership.' || action_name,
1424
+ 'organization-membership:' || operation_id_value::text,
1425
+ input_hash_value,
1426
+ pg_catalog.jsonb_build_object(
1427
+ 'organizationMembershipOperationId', operation_id_value,
1428
+ 'organizationMembershipId', target.id
1429
+ )
1430
+ );
1431
+ INSERT INTO session_attempt_interruptions (
1432
+ account_id, workspace_id, session_id, operation_id, attempt_id,
1433
+ kind, control_revision
1434
+ )
1435
+ SELECT attempt.account_id, attempt.workspace_id, attempt.session_id,
1436
+ interruption_operation_id, attempt.id, 'organization_membership_revoked',
1437
+ CASE WHEN owned.owner_organization_membership_id = target.id
1438
+ THEN owned.authority_epoch + 1 ELSE owned.authority_epoch END
1439
+ FROM session_turn_attempts attempt
1440
+ JOIN sessions owned ON owned.id = attempt.session_id
1441
+ JOIN session_turns initiated ON initiated.id = attempt.turn_id
1442
+ WHERE attempt.account_id = account_id_value
1443
+ AND attempt.workspace_id = workspace_id_value
1444
+ AND (
1445
+ owned.owner_organization_membership_id = target.id
1446
+ OR initiated.initiating_human_subject_id = target.subject_id
1447
+ )
1448
+ AND attempt.state IN ('claimed', 'running')
1449
+ ON CONFLICT ON CONSTRAINT session_attempt_interruptions_operation_attempt_uq
1450
+ DO NOTHING;
1451
+ WITH cancelled AS (
1452
+ UPDATE session_turns turn_row
1453
+ SET status = 'cancelled', active_attempt_id = NULL,
1454
+ cancelled_by = actor_subject, cancel_reason = 'authority_changed',
1455
+ finished_at = now_value, updated_at = now_value,
1456
+ version = turn_row.version + 1
1457
+ WHERE turn_row.account_id = account_id_value
1458
+ AND turn_row.workspace_id = workspace_id_value
1459
+ AND turn_row.status IN (
1460
+ 'queued', 'running', 'requires_action', 'recovering', 'waiting_capacity'
1461
+ )
1462
+ AND EXISTS (
1463
+ SELECT 1 FROM sessions owned
1464
+ WHERE owned.account_id = turn_row.account_id
1465
+ AND owned.workspace_id = turn_row.workspace_id
1466
+ AND owned.id = turn_row.session_id
1467
+ AND (
1468
+ owned.owner_organization_membership_id = target.id
1469
+ OR turn_row.initiating_human_subject_id = target.subject_id
1470
+ )
1471
+ )
1472
+ AND NOT EXISTS (
1473
+ SELECT 1 FROM session_turn_attempts live_attempt
1474
+ WHERE live_attempt.account_id = turn_row.account_id
1475
+ AND live_attempt.workspace_id = turn_row.workspace_id
1476
+ AND live_attempt.session_id = turn_row.session_id
1477
+ AND live_attempt.turn_id = turn_row.id
1478
+ AND live_attempt.id = turn_row.active_attempt_id
1479
+ AND live_attempt.state IN ('claimed', 'running')
1480
+ )
1481
+ RETURNING turn_row.id
1482
+ )
1483
+ SELECT COALESCE(pg_catalog.array_agg(cancelled.id ORDER BY cancelled.id), ARRAY[]::uuid[])
1484
+ INTO cancelled_turn_ids
1485
+ FROM cancelled;
1486
+ FOR cancelled_turn IN
1487
+ SELECT turn_row.id, turn_row.session_id, turn_row.execution_generation
1488
+ FROM session_turns turn_row
1489
+ WHERE turn_row.account_id = account_id_value
1490
+ AND turn_row.workspace_id = workspace_id_value
1491
+ AND turn_row.id = ANY(cancelled_turn_ids)
1492
+ ORDER BY turn_row.session_id, turn_row.id
1493
+ LOOP
1494
+ SELECT affected.last_sequence,
1495
+ CASE WHEN affected.active_turn_id = cancelled_turn.id
1496
+ THEN 'current' ELSE NULL END
1497
+ INTO cancelled_sequence, cancelled_association
1498
+ FROM sessions affected
1499
+ WHERE affected.account_id = account_id_value
1500
+ AND affected.workspace_id = workspace_id_value
1501
+ AND affected.id = cancelled_turn.session_id;
1502
+ FOR cancelled_request IN
1503
+ UPDATE session_human_input_requests request_row
1504
+ SET status = 'cancelled', response = '{"outcome":"cancelled"}'::jsonb,
1505
+ responded_by = actor_subject, responded_at = now_value,
1506
+ updated_at = now_value
1507
+ WHERE request_row.account_id = account_id_value
1508
+ AND request_row.workspace_id = workspace_id_value
1509
+ AND request_row.session_id = cancelled_turn.session_id
1510
+ AND request_row.turn_id = cancelled_turn.id
1511
+ AND request_row.status = 'pending'
1512
+ RETURNING request_row.id, request_row.creation_attempt_id
1513
+ LOOP
1514
+ cancelled_sequence := cancelled_sequence + 1;
1515
+ INSERT INTO session_events (
1516
+ account_id, workspace_id, session_id, sequence, type, payload,
1517
+ payload_codec_version, turn_id, turn_generation,
1518
+ turn_attempt_id, turn_association, occurred_at
1519
+ ) VALUES (
1520
+ account_id_value, workspace_id_value, cancelled_turn.session_id,
1521
+ cancelled_sequence, 'user.humanInputResponse',
1522
+ pg_catalog.jsonb_build_object(
1523
+ 'requestId', cancelled_request.id,
1524
+ 'response', pg_catalog.jsonb_build_object('outcome', 'cancelled')
1525
+ ), 1, cancelled_turn.id, cancelled_turn.execution_generation,
1526
+ cancelled_request.creation_attempt_id, cancelled_association, now_value
1527
+ );
1528
+ END LOOP;
1529
+ SELECT
1530
+ pg_catalog.array_agg(
1531
+ update_row.id ORDER BY update_row.created_at, update_row.id
1532
+ ),
1533
+ (pg_catalog.array_agg(
1534
+ update_row.delivered_history_item_id
1535
+ ORDER BY update_row.created_at, update_row.id
1536
+ ))[1]
1537
+ INTO settled_update_ids, settled_history_item_id
1538
+ FROM session_system_updates update_row
1539
+ WHERE update_row.account_id = account_id_value
1540
+ AND update_row.workspace_id = workspace_id_value
1541
+ AND update_row.session_id = cancelled_turn.session_id
1542
+ AND update_row.delivered_turn_id = cancelled_turn.id
1543
+ AND update_row.state = 'delivered';
1544
+ IF COALESCE(pg_catalog.array_length(settled_update_ids, 1), 0) > 0
1545
+ AND NOT EXISTS (
1546
+ SELECT 1 FROM session_events prior_settlement
1547
+ WHERE prior_settlement.account_id = account_id_value
1548
+ AND prior_settlement.workspace_id = workspace_id_value
1549
+ AND prior_settlement.session_id = cancelled_turn.session_id
1550
+ AND prior_settlement.turn_id = cancelled_turn.id
1551
+ AND prior_settlement.type = 'system.update.settled'
1552
+ )
1553
+ THEN
1554
+ cancelled_sequence := cancelled_sequence + 1;
1555
+ INSERT INTO session_events (
1556
+ account_id, workspace_id, session_id, sequence, type, payload,
1557
+ payload_codec_version, turn_id, turn_generation,
1558
+ turn_association, occurred_at
1559
+ ) VALUES (
1560
+ account_id_value, workspace_id_value, cancelled_turn.session_id,
1561
+ cancelled_sequence, 'system.update.settled',
1562
+ pg_catalog.jsonb_build_object(
1563
+ 'updateIds', pg_catalog.to_jsonb(settled_update_ids),
1564
+ 'count', pg_catalog.array_length(settled_update_ids, 1),
1565
+ 'historyItemId', settled_history_item_id,
1566
+ 'outcome', 'cancelled'
1567
+ ), 1, cancelled_turn.id, cancelled_turn.execution_generation,
1568
+ cancelled_association, now_value
1569
+ );
1570
+ END IF;
1571
+ IF NOT EXISTS (
1572
+ SELECT 1 FROM session_events prior_cancel
1573
+ WHERE prior_cancel.account_id = account_id_value
1574
+ AND prior_cancel.workspace_id = workspace_id_value
1575
+ AND prior_cancel.session_id = cancelled_turn.session_id
1576
+ AND prior_cancel.turn_id = cancelled_turn.id
1577
+ AND prior_cancel.type = 'turn.cancelled'
1578
+ ) THEN
1579
+ cancelled_sequence := cancelled_sequence + 1;
1580
+ INSERT INTO session_events (
1581
+ account_id, workspace_id, session_id, sequence, type, payload,
1582
+ payload_codec_version, turn_id, turn_generation,
1583
+ turn_association, occurred_at
1584
+ ) VALUES (
1585
+ account_id_value, workspace_id_value, cancelled_turn.session_id,
1586
+ cancelled_sequence, 'turn.cancelled',
1587
+ pg_catalog.jsonb_build_object('reason', 'authority_changed'),
1588
+ 1, cancelled_turn.id, cancelled_turn.execution_generation,
1589
+ cancelled_association, now_value
1590
+ );
1591
+ END IF;
1592
+ UPDATE sessions affected
1593
+ SET last_sequence = cancelled_sequence, updated_at = now_value
1594
+ WHERE affected.account_id = account_id_value
1595
+ AND affected.workspace_id = workspace_id_value
1596
+ AND affected.id = cancelled_turn.session_id;
1597
+ END LOOP;
1598
+ UPDATE interaction_interventions intervention
1599
+ SET status = 'cancelled', response_actor_subject_id = actor_subject,
1600
+ version = intervention.version + 1, settled_at = now_value,
1601
+ updated_at = now_value
1602
+ WHERE intervention.account_id = account_id_value
1603
+ AND intervention.workspace_id = workspace_id_value
1604
+ AND intervention.originating_turn_id = ANY(cancelled_turn_ids)
1605
+ AND intervention.status = 'open';
1606
+ DELETE FROM agent_run_states run_state
1607
+ WHERE run_state.account_id = account_id_value
1608
+ AND run_state.workspace_id = workspace_id_value
1609
+ AND run_state.turn_id = ANY(cancelled_turn_ids);
1610
+ UPDATE codex_capacity_waiters waiter
1611
+ SET status = 'superseded', observed_wake_revision = waiter.wake_revision,
1612
+ last_wake_reason = 'organization_membership_' || action_name,
1613
+ updated_at = now_value
1614
+ WHERE waiter.account_id = account_id_value
1615
+ AND waiter.workspace_id = workspace_id_value
1616
+ AND waiter.blocked_turn_id = ANY(cancelled_turn_ids)
1617
+ AND waiter.status = 'waiting';
1618
+ UPDATE xai_capacity_waiters waiter
1619
+ SET status = 'superseded', observed_wake_revision = waiter.wake_revision,
1620
+ last_wake_reason = 'organization_membership_' || action_name,
1621
+ updated_at = now_value
1622
+ WHERE waiter.account_id = account_id_value
1623
+ AND waiter.workspace_id = workspace_id_value
1624
+ AND waiter.blocked_turn_id = ANY(cancelled_turn_ids)
1625
+ AND waiter.status = 'waiting';
1626
+ UPDATE session_realtime_connections connection
1627
+ SET state = 'closed', closed_at = now_value, updated_at = now_value
1628
+ WHERE connection.account_id = account_id_value
1629
+ AND connection.workspace_id = workspace_id_value
1630
+ AND connection.realtime_id = ANY(affected_realtime_ids)
1631
+ AND connection.state IN ('negotiating', 'ready', 'active');
1632
+ WITH ended AS (
1633
+ UPDATE session_realtime_modes realtime
1634
+ SET state = 'ended', version = realtime.version + 1,
1635
+ ended_at = now_value, end_reason = 'authority_revoked',
1636
+ updated_at = now_value
1637
+ WHERE realtime.account_id = account_id_value
1638
+ AND realtime.workspace_id = workspace_id_value
1639
+ AND realtime.id = ANY(affected_realtime_ids)
1640
+ AND realtime.state = 'active'
1641
+ RETURNING realtime.id, realtime.session_id, realtime.operation_id,
1642
+ realtime.version, realtime.connection_epoch
1643
+ ), advanced AS (
1644
+ UPDATE sessions affected
1645
+ SET last_sequence = affected.last_sequence + 1, updated_at = now_value
1646
+ FROM ended
1647
+ WHERE affected.account_id = account_id_value
1648
+ AND affected.workspace_id = workspace_id_value
1649
+ AND affected.id = ended.session_id
1650
+ RETURNING affected.id AS session_id, affected.last_sequence,
1651
+ ended.id AS realtime_id, ended.operation_id,
1652
+ ended.version, ended.connection_epoch
1653
+ )
1654
+ INSERT INTO session_events (
1655
+ account_id, workspace_id, session_id, sequence, type, payload, occurred_at
1656
+ )
1657
+ SELECT account_id_value, workspace_id_value, advanced.session_id,
1658
+ advanced.last_sequence, 'session.realtime.ended',
1659
+ pg_catalog.jsonb_build_object(
1660
+ 'realtimeId', advanced.realtime_id,
1661
+ 'operationId', advanced.operation_id,
1662
+ 'reason', 'authority_revoked',
1663
+ 'version', advanced.version,
1664
+ 'connectionEpoch', advanced.connection_epoch
1665
+ ), now_value
1666
+ FROM advanced;
1667
+ UPDATE sessions affected
1668
+ SET active_turn_id = CASE
1669
+ WHEN affected.active_turn_id = ANY(cancelled_turn_ids) THEN NULL
1670
+ ELSE affected.active_turn_id
1671
+ END,
1672
+ status = CASE
1673
+ WHEN affected.active_turn_id = ANY(cancelled_turn_ids) THEN
1674
+ CASE WHEN EXISTS (
1675
+ SELECT 1 FROM session_turns queued
1676
+ WHERE queued.account_id = affected.account_id
1677
+ AND queued.workspace_id = affected.workspace_id
1678
+ AND queued.session_id = affected.id
1679
+ AND queued.status = 'queued'
1680
+ ) THEN 'queued' ELSE 'idle' END
1681
+ ELSE affected.status
1682
+ END,
1683
+ queue_head_position = COALESCE((
1684
+ SELECT min(queued.position)::bigint FROM session_turns queued
1685
+ WHERE queued.account_id = affected.account_id
1686
+ AND queued.workspace_id = affected.workspace_id
1687
+ AND queued.session_id = affected.id
1688
+ AND queued.status = 'queued'
1689
+ ), 0),
1690
+ queue_tail_position = COALESCE((
1691
+ SELECT max(queued.position)::bigint FROM session_turns queued
1692
+ WHERE queued.account_id = affected.account_id
1693
+ AND queued.workspace_id = affected.workspace_id
1694
+ AND queued.session_id = affected.id
1695
+ AND queued.status = 'queued'
1696
+ ), 0),
1697
+ queue_version = affected.queue_version + 1,
1698
+ updated_at = now_value
1699
+ WHERE affected.account_id = account_id_value
1700
+ AND affected.workspace_id = workspace_id_value
1701
+ AND EXISTS (
1702
+ SELECT 1 FROM session_turns cancelled
1703
+ WHERE cancelled.account_id = affected.account_id
1704
+ AND cancelled.workspace_id = affected.workspace_id
1705
+ AND cancelled.session_id = affected.id
1706
+ AND cancelled.id = ANY(cancelled_turn_ids)
1707
+ );
1708
+ UPDATE session_system_updates update_row SET state = 'cancelled'
1709
+ WHERE update_row.account_id = account_id_value
1710
+ AND update_row.workspace_id = workspace_id_value
1711
+ AND update_row.state = 'pending'
1712
+ AND EXISTS (
1713
+ SELECT 1 FROM sessions owned
1714
+ WHERE owned.id = update_row.session_id
1715
+ AND owned.owner_organization_membership_id = target.id
1716
+ );
1717
+ UPDATE session_system_update_outbox outbox
1718
+ SET status = 'failed', last_error = 'organization_membership_' || action_name,
1719
+ updated_at = now_value
1720
+ WHERE outbox.account_id = account_id_value
1721
+ AND outbox.workspace_id = workspace_id_value
1722
+ AND outbox.status = 'pending'
1723
+ AND EXISTS (
1724
+ SELECT 1 FROM sessions source_session
1725
+ WHERE source_session.id = outbox.source_session_id
1726
+ AND source_session.owner_organization_membership_id = target.id
1727
+ );
1728
+ UPDATE session_goals goal_row
1729
+ SET status = 'paused', paused_reason = 'api',
1730
+ rationale = CASE WHEN action_name = 'suspend'
1731
+ THEN 'Organization membership was suspended; explicit reauthorization is required.'
1732
+ ELSE 'Organization membership was revoked; explicit reauthorization is required.' END,
1733
+ version = goal_row.version + 1, updated_at = now_value
1734
+ WHERE goal_row.account_id = account_id_value
1735
+ AND goal_row.workspace_id = workspace_id_value
1736
+ AND goal_row.status = 'active'
1737
+ AND EXISTS (
1738
+ SELECT 1 FROM sessions owned
1739
+ WHERE owned.id = goal_row.session_id
1740
+ AND owned.owner_organization_membership_id = target.id
1741
+ );
1742
+ UPDATE scheduled_tasks task
1743
+ SET status = 'paused', personal_connection_delegations = '[]'::jsonb,
1744
+ authority_revision = task.authority_revision + 1,
1745
+ updated_at = now_value
1746
+ WHERE task.account_id = account_id_value
1747
+ AND task.workspace_id = workspace_id_value
1748
+ AND task.status = 'active'
1749
+ AND task.created_by_kind = 'subject'
1750
+ AND task.created_by_subject_id = target.subject_id;
1751
+ UPDATE organization_user_resource_grants grant_row
1752
+ SET status = 'revoked', revoked_at = now_value,
1753
+ generation = grant_row.generation + 1, updated_at = now_value
1754
+ WHERE grant_row.account_id = account_id_value
1755
+ AND grant_row.workspace_id = workspace_id_value
1756
+ AND grant_row.status = 'active'
1757
+ AND EXISTS (
1758
+ SELECT 1 FROM sessions owned
1759
+ WHERE owned.id = grant_row.session_id
1760
+ AND owned.owner_organization_membership_id = target.id
1761
+ AND owned.authority_epoch = grant_row.authority_epoch
1762
+ );
1763
+ WITH advanced AS (
1764
+ UPDATE sessions owned SET
1765
+ authority_epoch = owned.authority_epoch + 1,
1766
+ initial_personal_connection_delegations = '[]'::jsonb,
1767
+ last_sequence = owned.last_sequence + 1
1768
+ WHERE owned.account_id = account_id_value
1769
+ AND owned.workspace_id = workspace_id_value
1770
+ AND owned.owner_organization_membership_id = target.id
1771
+ RETURNING owned.id, owned.workspace_id, owned.last_sequence,
1772
+ owned.authority_epoch
1773
+ )
1774
+ INSERT INTO session_events (
1775
+ account_id, workspace_id, session_id, sequence, type, payload, occurred_at
1776
+ )
1777
+ SELECT account_id_value, advanced.workspace_id, advanced.id,
1778
+ advanced.last_sequence,
1779
+ CASE WHEN action_name = 'suspend'
1780
+ THEN 'session.authority.suspended' ELSE 'session.authority.revoked' END,
1781
+ pg_catalog.jsonb_build_object(
1782
+ 'operationId', operation_id_value,
1783
+ 'organizationMembershipId', target.id,
1784
+ 'previousAuthorityEpoch', advanced.authority_epoch - 1,
1785
+ 'authorityEpoch', advanced.authority_epoch
1786
+ ), now_value
1787
+ FROM advanced;
1788
+ INSERT INTO session_workflow_wake_outbox (
1789
+ session_id, account_id, workspace_id, temporal_workflow_id,
1790
+ reason, control_revision
1791
+ )
1792
+ SELECT affected.id, affected.account_id, affected.workspace_id,
1793
+ COALESCE(affected.temporal_workflow_id, 'session-' || affected.id::text),
1794
+ 'organization_membership_' || action_name, 1
1795
+ FROM sessions affected
1796
+ WHERE affected.account_id = account_id_value
1797
+ AND affected.workspace_id = workspace_id_value
1798
+ AND affected.id = ANY(affected_session_ids)
1799
+ ON CONFLICT (session_id) DO UPDATE SET
1800
+ wake_revision = session_workflow_wake_outbox.wake_revision + 1,
1801
+ control_revision = session_workflow_wake_outbox.wake_revision + 1,
1802
+ temporal_workflow_id = EXCLUDED.temporal_workflow_id,
1803
+ reason = EXCLUDED.reason, attempts = 0,
1804
+ next_attempt_at = now_value, last_error = NULL,
1805
+ updated_at = now_value;
1806
+ PERFORM pg_catalog.set_config(
1807
+ 'opengeni.session_activity_gate_state', 'preparing', true
1808
+ );
1809
+ SET CONSTRAINTS ALL IMMEDIATE;
1810
+ SET CONSTRAINTS sessions_activity_insert_commit_guard,
1811
+ sessions_activity_update_commit_guard DEFERRED;
1812
+ PERFORM pg_catalog.set_config(
1813
+ 'opengeni.session_activity_gate_state', 'finalizing', true
1814
+ );
1815
+ UPDATE workspace_session_activity_revisions counter
1816
+ SET revision = counter.revision + 1
1817
+ WHERE counter.workspace_id = workspace_id_value
1818
+ RETURNING counter.revision INTO activity_revision_value;
1819
+ IF activity_revision_value IS NULL THEN
1820
+ RAISE EXCEPTION 'organization membership session activity counter is unavailable'
1821
+ USING ERRCODE = '55000';
1822
+ END IF;
1823
+ UPDATE sessions affected
1824
+ SET activity_revision = activity_revision_value,
1825
+ activity_revision_pending_xid = NULL
1826
+ WHERE affected.account_id = account_id_value
1827
+ AND affected.workspace_id = workspace_id_value
1828
+ AND affected.activity_revision_pending_xid
1829
+ = pg_catalog.pg_current_xact_id()::text::bigint;
1830
+ SET CONSTRAINTS sessions_activity_insert_commit_guard,
1831
+ sessions_activity_update_commit_guard IMMEDIATE;
1832
+ SET CONSTRAINTS sessions_activity_insert_commit_guard,
1833
+ sessions_activity_update_commit_guard DEFERRED;
1834
+ PERFORM pg_catalog.set_config(
1835
+ 'opengeni.session_activity_gate_state',
1836
+ COALESCE(previous_activity_gate_state, ''), true
1837
+ );
1838
+ PERFORM pg_catalog.set_config(
1839
+ 'opengeni.session_activity_gate_workspace_id',
1840
+ COALESCE(previous_activity_gate_workspace, ''), true
1841
+ );
1842
+ END LOOP;
1843
+ DELETE FROM session_visibility_write_capabilities capability
1844
+ WHERE capability.backend_pid = pg_catalog.pg_backend_pid()
1845
+ AND capability.transaction_id = pg_catalog.pg_current_xact_id()
1846
+ AND capability.capability_id = visibility_capability_id;
1847
+ PERFORM pg_catalog.set_config(
1848
+ 'opengeni.session_visibility_write_capability',
1849
+ coalesce(previous_visibility_capability, ''), true
1850
+ );
1851
+ UPDATE organization_memberships SET
1852
+ status = CASE WHEN action_name = 'suspend' THEN 'suspended' ELSE 'revoked' END,
1853
+ authorization_revision = authorization_revision + 1,
1854
+ revoked_at = CASE WHEN action_name = 'offboard' THEN now_value ELSE NULL END,
1855
+ personal_retention_until = CASE
1856
+ WHEN action_name = 'offboard' AND policy.mode = 'delete_after'
1857
+ THEN now_value + pg_catalog.make_interval(days => policy.retention_days)
1858
+ ELSE NULL
1859
+ END,
1860
+ updated_at = now_value
1861
+ WHERE id = target.id RETURNING * INTO target;
1862
+ END IF;
1863
+ result := opengeni_private.organization_membership_row_json(target);
1864
+ END IF;
1865
+ END IF;
1866
+
1867
+ INSERT INTO organization_membership_operation_receipts (
1868
+ account_id, operation_id, action, input_hash, result
1869
+ ) VALUES (
1870
+ account_id_value, operation_id_value, action_name, input_hash_value, result
1871
+ );
1872
+ INSERT INTO organization_membership_lifecycle_events (
1873
+ account_id, operation_id, actor_membership_id, target_membership_id, kind,
1874
+ prior_authorization_revision, resulting_authorization_revision, reason
1875
+ ) VALUES (
1876
+ account_id_value, operation_id_value, actor.id,
1877
+ CASE WHEN target.id IS NULL THEN NULL ELSE target.id END,
1878
+ action_name, expected_revision,
1879
+ CASE WHEN target.id IS NULL THEN NULL ELSE target.authorization_revision END,
1880
+ reason_value
1881
+ );
1882
+ PERFORM pg_catalog.set_config(
1883
+ 'opengeni.workspace_id', coalesce(previous_workspace, ''), true
1884
+ );
1885
+ RETURN result;
1886
+ EXCEPTION WHEN OTHERS THEN
1887
+ PERFORM pg_catalog.set_config(
1888
+ 'opengeni.workspace_id', coalesce(previous_workspace, ''), true
1889
+ );
1890
+ PERFORM pg_catalog.set_config(
1891
+ 'opengeni.session_visibility_write_capability',
1892
+ coalesce(previous_visibility_capability, ''), true
1893
+ );
1894
+ PERFORM pg_catalog.set_config(
1895
+ 'opengeni.session_activity_gate_state',
1896
+ coalesce(previous_activity_gate_state, ''), true
1897
+ );
1898
+ PERFORM pg_catalog.set_config(
1899
+ 'opengeni.session_activity_gate_workspace_id',
1900
+ coalesce(previous_activity_gate_workspace, ''), true
1901
+ );
1902
+ RAISE;
1903
+ END
1904
+ $body$;
1905
+
1906
+ CREATE OR REPLACE FUNCTION get_organization_retention_policy(
1907
+ p_account_id uuid,
1908
+ p_actor_subject_id text
1909
+ ) RETURNS jsonb
1910
+ LANGUAGE plpgsql
1911
+ SECURITY DEFINER
1912
+ SET search_path FROM CURRENT
1913
+ AS $body$
1914
+ DECLARE actor organization_memberships%ROWTYPE; policy organization_user_retention_policies%ROWTYPE;
1915
+ BEGIN
1916
+ IF p_account_id IS DISTINCT FROM opengeni_private.current_account_id()
1917
+ OR p_actor_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
1918
+ THEN RAISE EXCEPTION 'organization authority required' USING ERRCODE = '42501'; END IF;
1919
+ PERFORM pg_catalog.set_config(
1920
+ 'opengeni.organization_tenancy_lifecycle',
1921
+ 'organization_membership_lifecycle', true
1922
+ );
1923
+ SELECT * INTO actor FROM organization_memberships membership
1924
+ WHERE membership.account_id = p_account_id AND membership.subject_id = p_actor_subject_id;
1925
+ IF NOT FOUND OR actor.status <> 'active' OR actor.role NOT IN ('owner', 'admin') THEN
1926
+ RAISE EXCEPTION 'organization administration required' USING ERRCODE = '42501';
1927
+ END IF;
1928
+ SELECT * INTO policy FROM organization_user_retention_policies candidate
1929
+ WHERE candidate.account_id = p_account_id;
1930
+ IF NOT FOUND THEN
1931
+ RETURN pg_catalog.jsonb_build_object(
1932
+ 'organizationId', p_account_id, 'mode', 'retain',
1933
+ 'retentionDays', NULL, 'version', 1, 'updatedAt', actor.created_at
1934
+ );
1935
+ END IF;
1936
+ RETURN pg_catalog.jsonb_build_object(
1937
+ 'organizationId', policy.account_id, 'mode', policy.mode,
1938
+ 'retentionDays', policy.retention_days, 'version', policy.version,
1939
+ 'updatedAt', policy.updated_at
1940
+ );
1941
+ END
1942
+ $body$;
1943
+
1944
+ CREATE OR REPLACE FUNCTION opengeni_private.assert_organization_retention_account(
1945
+ p_account_id uuid
1946
+ ) RETURNS void
1947
+ LANGUAGE plpgsql
1948
+ SET search_path FROM CURRENT
1949
+ AS $body$
1950
+ BEGIN
1951
+ IF p_account_id IS NULL
1952
+ OR p_account_id IS DISTINCT FROM opengeni_private.current_account_id()
1953
+ THEN
1954
+ RAISE EXCEPTION 'retention organization context mismatch' USING ERRCODE = '42501';
1955
+ END IF;
1956
+ END
1957
+ $body$;
1958
+
1959
+ CREATE OR REPLACE FUNCTION assert_active_managed_human_organization_membership(
1960
+ p_account_id uuid,
1961
+ p_subject_id text
1962
+ ) RETURNS bigint
1963
+ LANGUAGE plpgsql
1964
+ SECURITY DEFINER
1965
+ SET search_path FROM CURRENT
1966
+ AS $body$
1967
+ DECLARE
1968
+ membership organization_memberships%ROWTYPE;
1969
+ previous_lifecycle_marker text := pg_catalog.current_setting(
1970
+ 'opengeni.organization_tenancy_lifecycle', true
1971
+ );
1972
+ BEGIN
1973
+ IF p_account_id IS NULL
1974
+ OR p_subject_id IS NULL
1975
+ OR p_account_id IS DISTINCT FROM opengeni_private.current_account_id()
1976
+ OR p_subject_id IS DISTINCT FROM opengeni_private.current_subject_id()
1977
+ THEN
1978
+ RAISE EXCEPTION 'managed-human mutation authority is invalid'
1979
+ USING ERRCODE = '42501';
1980
+ END IF;
1981
+ PERFORM pg_catalog.set_config(
1982
+ 'opengeni.organization_tenancy_lifecycle',
1983
+ 'organization_membership_lifecycle', true
1984
+ );
1985
+ SELECT * INTO membership
1986
+ FROM organization_memberships candidate
1987
+ WHERE candidate.account_id = p_account_id
1988
+ AND candidate.subject_id = p_subject_id
1989
+ FOR KEY SHARE;
1990
+ IF NOT FOUND THEN
1991
+ PERFORM pg_catalog.set_config(
1992
+ 'opengeni.organization_tenancy_lifecycle',
1993
+ COALESCE(previous_lifecycle_marker, ''), true
1994
+ );
1995
+ RETURN NULL;
1996
+ END IF;
1997
+ IF membership.status <> 'active' THEN
1998
+ RAISE EXCEPTION 'active organization membership required for managed-human mutation'
1999
+ USING ERRCODE = '42501';
2000
+ END IF;
2001
+ PERFORM pg_catalog.set_config(
2002
+ 'opengeni.organization_tenancy_lifecycle',
2003
+ COALESCE(previous_lifecycle_marker, ''), true
2004
+ );
2005
+ RETURN membership.authorization_revision;
2006
+ EXCEPTION WHEN OTHERS THEN
2007
+ PERFORM pg_catalog.set_config(
2008
+ 'opengeni.organization_tenancy_lifecycle',
2009
+ COALESCE(previous_lifecycle_marker, ''), true
2010
+ );
2011
+ RAISE;
2012
+ END
2013
+ $body$;
2014
+
2015
+ CREATE OR REPLACE FUNCTION opengeni_private.organization_retention_file_candidates(
2016
+ p_account_id uuid,
2017
+ p_membership_id uuid
2018
+ ) RETURNS TABLE(file_id uuid, object_bucket text, object_key text)
2019
+ LANGUAGE sql
2020
+ SECURITY DEFINER
2021
+ SET search_path FROM CURRENT
2022
+ AS $body$
2023
+ WITH authority_context AS MATERIALIZED (
2024
+ SELECT opengeni_private.assert_organization_retention_account(p_account_id)
2025
+ ), member AS (
2026
+ SELECT membership.personal_workspace_id
2027
+ FROM organization_memberships membership
2028
+ CROSS JOIN authority_context
2029
+ WHERE membership.account_id = p_account_id
2030
+ AND membership.id = p_membership_id
2031
+ ), owned_document_files AS (
2032
+ SELECT document.file_id
2033
+ FROM documents document
2034
+ JOIN organization_user_resource_authorities authority
2035
+ ON authority.id = document.authority_id
2036
+ AND authority.account_id = document.account_id
2037
+ AND authority.organization_membership_id = document.owner_organization_membership_id
2038
+ AND authority.resource_kind = 'document'
2039
+ AND authority.resource_id = document.id
2040
+ WHERE authority.account_id = p_account_id
2041
+ AND authority.organization_membership_id = p_membership_id
2042
+ )
2043
+ SELECT file.id, file.bucket, file.object_key
2044
+ FROM files file
2045
+ CROSS JOIN member
2046
+ WHERE file.account_id = p_account_id
2047
+ AND (
2048
+ file.workspace_id = member.personal_workspace_id
2049
+ OR file.id IN (SELECT owned.file_id FROM owned_document_files owned)
2050
+ )
2051
+ AND NOT EXISTS (
2052
+ SELECT 1
2053
+ FROM documents other
2054
+ LEFT JOIN organization_user_resource_authorities other_authority
2055
+ ON other_authority.id = other.authority_id
2056
+ AND other_authority.account_id = other.account_id
2057
+ AND other_authority.organization_membership_id = other.owner_organization_membership_id
2058
+ AND other_authority.resource_kind = 'document'
2059
+ AND other_authority.resource_id = other.id
2060
+ WHERE other.file_id = file.id
2061
+ AND (
2062
+ other_authority.id IS NULL
2063
+ OR other_authority.account_id <> p_account_id
2064
+ OR other_authority.organization_membership_id <> p_membership_id
2065
+ )
2066
+ )
2067
+ ORDER BY file.id
2068
+ $body$;
2069
+
2070
+ -- Closed inventory of every @opengeni/storage key whose owning metadata is
2071
+ -- removed with a personal workspace. Provider-native sandbox checkpoint
2072
+ -- artifacts deliberately do not appear here: their global GC rows survive
2073
+ -- workspace deletion and remain the sole provider-bound deletion authority.
2074
+ CREATE OR REPLACE FUNCTION opengeni_private.organization_retention_object_candidates(
2075
+ p_account_id uuid,
2076
+ p_membership_id uuid
2077
+ ) RETURNS TABLE(object_kind text, source_id text, object_bucket text, object_key text)
2078
+ LANGUAGE sql
2079
+ SECURITY DEFINER
2080
+ SET search_path FROM CURRENT
2081
+ AS $body$
2082
+ WITH authority_context AS MATERIALIZED (
2083
+ SELECT opengeni_private.assert_organization_retention_account(p_account_id)
2084
+ ), member AS (
2085
+ SELECT membership.personal_workspace_id
2086
+ FROM organization_memberships membership
2087
+ CROSS JOIN authority_context
2088
+ WHERE membership.account_id = p_account_id
2089
+ AND membership.id = p_membership_id
2090
+ )
2091
+ SELECT 'file'::text, candidate.file_id::text, candidate.object_bucket, candidate.object_key
2092
+ FROM opengeni_private.organization_retention_file_candidates(
2093
+ p_account_id, p_membership_id
2094
+ ) candidate
2095
+ UNION ALL
2096
+ SELECT 'session_recording', recording.id::text, NULL::text, recording.storage_key
2097
+ FROM session_recordings recording CROSS JOIN member
2098
+ WHERE recording.account_id = p_account_id
2099
+ AND recording.workspace_id = member.personal_workspace_id
2100
+ AND recording.storage_key IS NOT NULL
2101
+ UNION ALL
2102
+ SELECT 'browser_state_artifact', artifact.id::text, NULL::text, artifact.object_key
2103
+ FROM browser_state_artifacts artifact CROSS JOIN member
2104
+ WHERE artifact.account_id = p_account_id
2105
+ AND artifact.workspace_id = member.personal_workspace_id
2106
+ AND artifact.state <> 'deleted'
2107
+ UNION ALL
2108
+ SELECT 'browser_state_upload', upload.id::text, NULL::text, upload.object_key
2109
+ FROM browser_state_uploads upload CROSS JOIN member
2110
+ WHERE upload.account_id = p_account_id
2111
+ AND upload.workspace_id = member.personal_workspace_id
2112
+ AND upload.state <> 'deleted'
2113
+ AND (
2114
+ upload.state <> 'committed'
2115
+ OR NOT EXISTS (
2116
+ SELECT 1
2117
+ FROM browser_state_artifacts committed
2118
+ WHERE committed.account_id = upload.account_id
2119
+ AND committed.workspace_id = upload.workspace_id
2120
+ AND committed.id = upload.committed_artifact_id
2121
+ AND committed.object_key = upload.object_key
2122
+ AND committed.state <> 'deleted'
2123
+ )
2124
+ )
2125
+ UNION ALL
2126
+ SELECT 'transcription_recording_object',
2127
+ recording_object.recording_id::text || ':' || encode(
2128
+ digest(recording_object.object_key, 'sha256'), 'hex'
2129
+ ),
2130
+ NULL::text,
2131
+ recording_object.object_key
2132
+ FROM transcription_recording_objects recording_object CROSS JOIN member
2133
+ WHERE recording_object.account_id = p_account_id
2134
+ AND recording_object.workspace_id = member.personal_workspace_id
2135
+ AND recording_object.cleaned_at IS NULL
2136
+ UNION ALL
2137
+ SELECT 'video_staging_reference',
2138
+ reference.operation_id::text || ':' || reference.ordinal::text,
2139
+ NULL::text,
2140
+ reference.staging_object_key
2141
+ FROM video_generation_references reference CROSS JOIN member
2142
+ WHERE reference.account_id = p_account_id
2143
+ AND reference.workspace_id = member.personal_workspace_id
2144
+ AND reference.staging_object_key IS NOT NULL
2145
+ AND reference.cleaned_at IS NULL
2146
+ UNION ALL
2147
+ SELECT 'workspace_artifact_version', version.id::text, NULL::text, version.content_key
2148
+ FROM workspace_artifact_versions version CROSS JOIN member
2149
+ WHERE version.account_id = p_account_id
2150
+ AND version.workspace_id = member.personal_workspace_id
2151
+ UNION ALL
2152
+ SELECT 'editable_artifact_blob',
2153
+ blob.artifact_id || ':' || blob.id,
2154
+ NULL::text,
2155
+ blob.object_reference
2156
+ FROM editable_artifact_blob_refs blob CROSS JOIN member
2157
+ WHERE blob.account_id = p_account_id
2158
+ AND blob.workspace_id = member.personal_workspace_id
2159
+ UNION ALL
2160
+ SELECT 'workspace_capture_manifest', capture.id::text, NULL::text, capture.manifest_key
2161
+ FROM workspace_captures capture CROSS JOIN member
2162
+ WHERE capture.account_id = p_account_id
2163
+ AND capture.workspace_id = member.personal_workspace_id
2164
+ AND capture.manifest_key IS NOT NULL
2165
+ UNION ALL
2166
+ SELECT 'workspace_capture_tree_index', capture.id::text, NULL::text, capture.tree_index_key
2167
+ FROM workspace_captures capture CROSS JOIN member
2168
+ WHERE capture.account_id = p_account_id
2169
+ AND capture.workspace_id = member.personal_workspace_id
2170
+ AND capture.tree_index_key IS NOT NULL
2171
+ UNION ALL
2172
+ SELECT 'workspace_capture_blob',
2173
+ capture.id::text || ':' || blob.ordinality::text,
2174
+ NULL::text,
2175
+ blob.value #>> '{}'
2176
+ FROM workspace_captures capture CROSS JOIN member
2177
+ CROSS JOIN LATERAL jsonb_array_elements(
2178
+ CASE WHEN jsonb_typeof(capture.blob_keys) = 'array'
2179
+ THEN capture.blob_keys ELSE '[]'::jsonb END
2180
+ ) WITH ORDINALITY AS blob(value, ordinality)
2181
+ WHERE capture.account_id = p_account_id
2182
+ AND capture.workspace_id = member.personal_workspace_id
2183
+ AND jsonb_typeof(blob.value) = 'string'
2184
+ ORDER BY 1, 2
2185
+ $body$;
2186
+
2187
+ CREATE OR REPLACE FUNCTION preview_organization_retention_deletions(
2188
+ p_account_id uuid,
2189
+ p_limit integer DEFAULT 25
2190
+ ) RETURNS jsonb
2191
+ LANGUAGE plpgsql
2192
+ SECURITY DEFINER
2193
+ SET search_path FROM CURRENT
2194
+ AS $body$
2195
+ DECLARE result jsonb;
2196
+ BEGIN
2197
+ IF p_account_id IS NULL OR p_limit IS NULL OR p_limit NOT BETWEEN 1 AND 100 THEN
2198
+ RAISE EXCEPTION 'invalid retention preview input' USING ERRCODE = '22023';
2199
+ END IF;
2200
+ PERFORM opengeni_private.assert_organization_retention_account(p_account_id);
2201
+ PERFORM pg_catalog.set_config(
2202
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
2203
+ );
2204
+ SELECT COALESCE(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object(
2205
+ 'membershipId', candidate.id,
2206
+ 'retentionUntil', candidate.personal_retention_until,
2207
+ 'personalWorkspaceId', candidate.personal_workspace_id,
2208
+ 'resourceCount', candidate.resource_count,
2209
+ 'objectCount', candidate.object_count
2210
+ ) ORDER BY candidate.personal_retention_until, candidate.id), '[]'::jsonb)
2211
+ INTO result
2212
+ FROM (
2213
+ SELECT membership.id, membership.personal_retention_until,
2214
+ membership.personal_workspace_id,
2215
+ (SELECT count(*)::integer FROM organization_user_resource_authorities authority
2216
+ WHERE authority.account_id = p_account_id
2217
+ AND authority.organization_membership_id = membership.id) AS resource_count,
2218
+ CASE WHEN deletion.database_finalized_at IS NOT NULL THEN
2219
+ (SELECT count(*)::integer
2220
+ FROM organization_user_retention_object_obligations receipt
2221
+ WHERE receipt.account_id = p_account_id
2222
+ AND receipt.membership_id = membership.id
2223
+ AND NOT EXISTS (
2224
+ SELECT 1
2225
+ FROM organization_user_retention_object_deletion_receipts deleted
2226
+ WHERE deleted.account_id = receipt.account_id
2227
+ AND deleted.membership_id = receipt.membership_id
2228
+ AND deleted.object_kind = receipt.object_kind
2229
+ AND deleted.source_id = receipt.source_id
2230
+ AND deleted.object_bucket = receipt.object_bucket
2231
+ ))
2232
+ ELSE
2233
+ (SELECT count(*)::integer
2234
+ FROM opengeni_private.organization_retention_object_candidates(
2235
+ p_account_id, membership.id
2236
+ ))
2237
+ END AS object_count
2238
+ FROM organization_memberships membership
2239
+ LEFT JOIN organization_user_retention_deletions deletion
2240
+ ON deletion.account_id = membership.account_id
2241
+ AND deletion.membership_id = membership.id
2242
+ WHERE membership.account_id = p_account_id
2243
+ AND membership.status = 'revoked'
2244
+ AND membership.personal_retention_until IS NOT NULL
2245
+ AND membership.personal_retention_until <= clock_timestamp()
2246
+ AND (deletion.state IS NULL OR deletion.state <> 'completed')
2247
+ ORDER BY membership.personal_retention_until, membership.id
2248
+ LIMIT p_limit
2249
+ ) candidate;
2250
+ RETURN result;
2251
+ END
2252
+ $body$;
2253
+
2254
+ CREATE OR REPLACE FUNCTION claim_organization_retention_deletion(
2255
+ p_account_id uuid,
2256
+ p_operation_id uuid,
2257
+ p_excluded_membership_ids uuid[] DEFAULT ARRAY[]::uuid[]
2258
+ ) RETURNS jsonb
2259
+ LANGUAGE plpgsql
2260
+ SECURITY DEFINER
2261
+ SET search_path FROM CURRENT
2262
+ AS $body$
2263
+ DECLARE
2264
+ member organization_memberships%ROWTYPE;
2265
+ deletion organization_user_retention_deletions%ROWTYPE;
2266
+ prior_event organization_user_retention_deletion_events%ROWTYPE;
2267
+ result_value jsonb;
2268
+ object_count integer;
2269
+ deleted_object_count integer;
2270
+ BEGIN
2271
+ IF p_account_id IS NULL OR p_operation_id IS NULL
2272
+ OR p_excluded_membership_ids IS NULL
2273
+ THEN
2274
+ RAISE EXCEPTION 'invalid retention claim input' USING ERRCODE = '22023';
2275
+ END IF;
2276
+ IF coalesce(pg_catalog.array_length(p_excluded_membership_ids, 1), 0) > 100 THEN
2277
+ RAISE EXCEPTION 'too many excluded retention memberships' USING ERRCODE = '22023';
2278
+ END IF;
2279
+ PERFORM opengeni_private.assert_organization_retention_account(p_account_id);
2280
+ PERFORM pg_catalog.set_config(
2281
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
2282
+ );
2283
+ PERFORM pg_catalog.pg_advisory_xact_lock(
2284
+ pg_catalog.hashtextextended('organization-retention:' || p_operation_id::text, 0)
2285
+ );
2286
+ SELECT * INTO prior_event
2287
+ FROM organization_user_retention_deletion_events event
2288
+ WHERE event.account_id = p_account_id AND event.operation_id = p_operation_id
2289
+ ORDER BY CASE event.kind WHEN 'failed' THEN 0 WHEN 'claimed' THEN 1 ELSE 2 END
2290
+ LIMIT 1;
2291
+ IF FOUND THEN
2292
+ IF prior_event.membership_id = ANY(p_excluded_membership_ids) THEN
2293
+ RAISE EXCEPTION 'retention operation replay conflicts with excluded membership'
2294
+ USING ERRCODE = '23505';
2295
+ END IF;
2296
+ IF prior_event.kind = 'failed' THEN
2297
+ RAISE EXCEPTION 'retention operation previously failed: %', prior_event.reason_code
2298
+ USING ERRCODE = '55000';
2299
+ END IF;
2300
+ RETURN prior_event.result;
2301
+ END IF;
2302
+ IF EXISTS (
2303
+ SELECT 1 FROM organization_user_retention_deletion_events event
2304
+ WHERE event.operation_id = p_operation_id AND event.account_id <> p_account_id
2305
+ ) THEN
2306
+ RAISE EXCEPTION 'retention operation belongs to another organization'
2307
+ USING ERRCODE = '42501';
2308
+ END IF;
2309
+
2310
+ SELECT membership.* INTO member
2311
+ FROM organization_memberships membership
2312
+ LEFT JOIN organization_user_retention_deletions current_deletion
2313
+ ON current_deletion.account_id = membership.account_id
2314
+ AND current_deletion.membership_id = membership.id
2315
+ WHERE membership.account_id = p_account_id
2316
+ AND NOT (membership.id = ANY(p_excluded_membership_ids))
2317
+ AND membership.status = 'revoked'
2318
+ AND membership.personal_retention_until IS NOT NULL
2319
+ AND membership.personal_retention_until <= clock_timestamp()
2320
+ AND (
2321
+ current_deletion.state IS NULL
2322
+ OR current_deletion.state = 'failed'
2323
+ OR (current_deletion.state = 'claimed'
2324
+ AND current_deletion.claim_expires_at <= clock_timestamp())
2325
+ )
2326
+ ORDER BY membership.personal_retention_until, membership.id
2327
+ FOR UPDATE OF membership SKIP LOCKED
2328
+ LIMIT 1;
2329
+ IF NOT FOUND THEN RETURN NULL; END IF;
2330
+
2331
+ INSERT INTO organization_user_retention_deletions (
2332
+ account_id, membership_id, retention_until, state, claim_operation_id,
2333
+ claim_expires_at, attempt_count, updated_at
2334
+ ) VALUES (
2335
+ p_account_id, member.id, member.personal_retention_until, 'claimed',
2336
+ p_operation_id, clock_timestamp() + interval '15 minutes', 1, clock_timestamp()
2337
+ )
2338
+ ON CONFLICT (account_id, membership_id) DO UPDATE SET
2339
+ retention_until = EXCLUDED.retention_until,
2340
+ state = 'claimed',
2341
+ claim_operation_id = EXCLUDED.claim_operation_id,
2342
+ claim_expires_at = EXCLUDED.claim_expires_at,
2343
+ attempt_count = organization_user_retention_deletions.attempt_count + 1,
2344
+ result = NULL,
2345
+ completed_at = NULL,
2346
+ updated_at = clock_timestamp()
2347
+ RETURNING * INTO deletion;
2348
+
2349
+ IF deletion.database_finalized_at IS NULL THEN
2350
+ SELECT count(*)::integer INTO object_count
2351
+ FROM opengeni_private.organization_retention_object_candidates(p_account_id, member.id);
2352
+ ELSE
2353
+ SELECT count(*)::integer INTO object_count
2354
+ FROM organization_user_retention_object_obligations receipt
2355
+ WHERE receipt.account_id = p_account_id AND receipt.membership_id = member.id;
2356
+ END IF;
2357
+ SELECT count(*)::integer INTO deleted_object_count
2358
+ FROM organization_user_retention_object_deletion_receipts receipt
2359
+ WHERE receipt.account_id = p_account_id AND receipt.membership_id = member.id;
2360
+ result_value := pg_catalog.jsonb_build_object(
2361
+ 'organizationId', p_account_id,
2362
+ 'membershipId', member.id,
2363
+ 'operationId', p_operation_id,
2364
+ 'retentionUntil', member.personal_retention_until,
2365
+ 'claimExpiresAt', deletion.claim_expires_at,
2366
+ 'personalWorkspaceId', member.personal_workspace_id,
2367
+ 'objectCount', object_count,
2368
+ 'deletedObjectCount', deleted_object_count
2369
+ );
2370
+ INSERT INTO organization_user_retention_deletion_events (
2371
+ account_id, membership_id, operation_id, kind, result
2372
+ ) VALUES (p_account_id, member.id, p_operation_id, 'claimed', result_value);
2373
+ RETURN result_value;
2374
+ END
2375
+ $body$;
2376
+
2377
+ CREATE OR REPLACE FUNCTION list_organization_retention_deletion_objects(
2378
+ p_account_id uuid,
2379
+ p_membership_id uuid,
2380
+ p_operation_id uuid,
2381
+ p_object_bucket text,
2382
+ p_limit integer DEFAULT 100
2383
+ ) RETURNS jsonb
2384
+ LANGUAGE plpgsql
2385
+ SECURITY DEFINER
2386
+ SET search_path FROM CURRENT
2387
+ AS $body$
2388
+ DECLARE result jsonb;
2389
+ BEGIN
2390
+ IF nullif(p_object_bucket, '') IS NULL
2391
+ OR octet_length(convert_to(p_object_bucket, 'UTF8')) > 1024
2392
+ THEN
2393
+ RAISE EXCEPTION 'invalid retention object bucket' USING ERRCODE = '22023';
2394
+ END IF;
2395
+ IF p_limit IS NULL OR p_limit NOT BETWEEN 1 AND 100 THEN
2396
+ RAISE EXCEPTION 'invalid retention object page size' USING ERRCODE = '22023';
2397
+ END IF;
2398
+ PERFORM opengeni_private.assert_organization_retention_account(p_account_id);
2399
+ PERFORM pg_catalog.set_config(
2400
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
2401
+ );
2402
+ UPDATE organization_user_retention_deletions deletion
2403
+ SET claim_expires_at = clock_timestamp() + interval '15 minutes', updated_at = clock_timestamp()
2404
+ WHERE deletion.account_id = p_account_id
2405
+ AND deletion.membership_id = p_membership_id
2406
+ AND deletion.claim_operation_id = p_operation_id
2407
+ AND deletion.state = 'claimed'
2408
+ AND deletion.claim_expires_at > clock_timestamp()
2409
+ AND deletion.database_finalized_at IS NOT NULL
2410
+ AND deletion.database_result ->> 'objectBucket' = p_object_bucket;
2411
+ IF NOT FOUND THEN
2412
+ RAISE EXCEPTION 'retention deletion claim is stale' USING ERRCODE = '40001';
2413
+ END IF;
2414
+ SELECT COALESCE(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object(
2415
+ 'objectKind', candidate.object_kind,
2416
+ 'sourceId', candidate.source_id,
2417
+ 'objectBucket', candidate.object_bucket,
2418
+ 'objectKey', candidate.object_key
2419
+ ) ORDER BY candidate.object_kind, candidate.source_id), '[]'::jsonb)
2420
+ INTO result
2421
+ FROM (
2422
+ SELECT receipt.object_kind, receipt.source_id, receipt.object_bucket, receipt.object_key
2423
+ FROM organization_user_retention_object_obligations receipt
2424
+ WHERE receipt.account_id = p_account_id
2425
+ AND receipt.membership_id = p_membership_id
2426
+ AND NOT EXISTS (
2427
+ SELECT 1
2428
+ FROM organization_user_retention_object_deletion_receipts deleted
2429
+ WHERE deleted.account_id = receipt.account_id
2430
+ AND deleted.membership_id = receipt.membership_id
2431
+ AND deleted.object_kind = receipt.object_kind
2432
+ AND deleted.source_id = receipt.source_id
2433
+ AND deleted.object_bucket = receipt.object_bucket
2434
+ )
2435
+ ORDER BY receipt.object_kind, receipt.source_id
2436
+ LIMIT p_limit
2437
+ ) candidate;
2438
+ RETURN result;
2439
+ END
2440
+ $body$;
2441
+
2442
+ CREATE OR REPLACE FUNCTION record_organization_retention_object_deleted(
2443
+ p_account_id uuid,
2444
+ p_membership_id uuid,
2445
+ p_operation_id uuid,
2446
+ p_object_kind text,
2447
+ p_source_id text,
2448
+ p_object_bucket text,
2449
+ p_object_key text
2450
+ ) RETURNS boolean
2451
+ LANGUAGE plpgsql
2452
+ SECURITY DEFINER
2453
+ SET search_path FROM CURRENT
2454
+ AS $body$
2455
+ DECLARE obligation organization_user_retention_object_obligations%ROWTYPE;
2456
+ BEGIN
2457
+ IF p_object_kind NOT IN (
2458
+ 'file', 'session_recording', 'browser_state_artifact', 'browser_state_upload',
2459
+ 'transcription_recording_object', 'video_staging_reference',
2460
+ 'workspace_artifact_version', 'editable_artifact_blob',
2461
+ 'workspace_capture_manifest', 'workspace_capture_tree_index',
2462
+ 'workspace_capture_blob'
2463
+ )
2464
+ OR nullif(p_source_id, '') IS NULL
2465
+ OR octet_length(convert_to(p_source_id, 'UTF8')) > 2048
2466
+ OR nullif(p_object_bucket, '') IS NULL
2467
+ OR octet_length(convert_to(p_object_bucket, 'UTF8')) > 1024
2468
+ OR nullif(p_object_key, '') IS NULL
2469
+ OR octet_length(convert_to(p_object_key, 'UTF8')) > 4096
2470
+ THEN RAISE EXCEPTION 'invalid retention object key' USING ERRCODE = '22023'; END IF;
2471
+ PERFORM opengeni_private.assert_organization_retention_account(p_account_id);
2472
+ PERFORM pg_catalog.set_config(
2473
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
2474
+ );
2475
+ UPDATE organization_user_retention_deletions deletion
2476
+ SET claim_expires_at = clock_timestamp() + interval '15 minutes', updated_at = clock_timestamp()
2477
+ WHERE deletion.account_id = p_account_id
2478
+ AND deletion.membership_id = p_membership_id
2479
+ AND deletion.claim_operation_id = p_operation_id
2480
+ AND deletion.state = 'claimed'
2481
+ AND deletion.claim_expires_at > clock_timestamp()
2482
+ AND deletion.database_finalized_at IS NOT NULL
2483
+ AND deletion.database_result ->> 'objectBucket' = p_object_bucket;
2484
+ IF NOT FOUND THEN RAISE EXCEPTION 'retention deletion claim is stale' USING ERRCODE = '40001'; END IF;
2485
+ SELECT * INTO obligation
2486
+ FROM organization_user_retention_object_obligations receipt
2487
+ WHERE receipt.account_id = p_account_id
2488
+ AND receipt.membership_id = p_membership_id
2489
+ AND receipt.object_kind = p_object_kind
2490
+ AND receipt.source_id = p_source_id
2491
+ AND receipt.object_bucket = p_object_bucket
2492
+ FOR UPDATE;
2493
+ IF NOT FOUND OR obligation.object_key IS DISTINCT FROM p_object_key
2494
+ OR obligation.object_key_hash <> encode(digest(p_object_key, 'sha256'), 'hex')
2495
+ THEN
2496
+ RAISE EXCEPTION 'retention object is outside the exact claim' USING ERRCODE = '42501';
2497
+ END IF;
2498
+ INSERT INTO organization_user_retention_object_deletion_receipts (
2499
+ account_id, membership_id, object_kind, source_id, object_bucket, operation_id
2500
+ ) VALUES (
2501
+ p_account_id, p_membership_id, p_object_kind, p_source_id, p_object_bucket, p_operation_id
2502
+ ) ON CONFLICT (account_id, membership_id, object_kind, source_id, object_bucket) DO NOTHING;
2503
+ RETURN FOUND;
2504
+ END
2505
+ $body$;
2506
+
2507
+ CREATE OR REPLACE FUNCTION fail_organization_retention_deletion(
2508
+ p_account_id uuid,
2509
+ p_membership_id uuid,
2510
+ p_operation_id uuid,
2511
+ p_reason_code text
2512
+ ) RETURNS boolean
2513
+ LANGUAGE plpgsql
2514
+ SECURITY DEFINER
2515
+ SET search_path FROM CURRENT
2516
+ AS $body$
2517
+ DECLARE
2518
+ prior_event organization_user_retention_deletion_events%ROWTYPE;
2519
+ event_result jsonb;
2520
+ BEGIN
2521
+ IF p_reason_code IS NULL OR p_reason_code !~ '^[a-z0-9_]{1,64}$' THEN
2522
+ RAISE EXCEPTION 'invalid retention failure code' USING ERRCODE = '22023';
2523
+ END IF;
2524
+ PERFORM opengeni_private.assert_organization_retention_account(p_account_id);
2525
+ PERFORM pg_catalog.set_config(
2526
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
2527
+ );
2528
+ PERFORM pg_catalog.pg_advisory_xact_lock(
2529
+ pg_catalog.hashtextextended('organization-retention:' || p_operation_id::text, 0)
2530
+ );
2531
+ SELECT * INTO prior_event
2532
+ FROM organization_user_retention_deletion_events event
2533
+ WHERE event.account_id = p_account_id
2534
+ AND event.operation_id = p_operation_id
2535
+ AND event.kind = 'failed';
2536
+ IF FOUND THEN
2537
+ IF prior_event.membership_id IS DISTINCT FROM p_membership_id
2538
+ OR prior_event.reason_code IS DISTINCT FROM p_reason_code
2539
+ THEN
2540
+ RAISE EXCEPTION 'retention failure operation id was reused with different input'
2541
+ USING ERRCODE = '23505';
2542
+ END IF;
2543
+ RETURN true;
2544
+ END IF;
2545
+ UPDATE organization_user_retention_deletions deletion
2546
+ SET state = 'failed', claim_expires_at = clock_timestamp(), updated_at = clock_timestamp()
2547
+ WHERE deletion.account_id = p_account_id
2548
+ AND deletion.membership_id = p_membership_id
2549
+ AND deletion.claim_operation_id = p_operation_id
2550
+ AND deletion.state = 'claimed';
2551
+ IF NOT FOUND THEN
2552
+ RETURN false;
2553
+ END IF;
2554
+ event_result := pg_catalog.jsonb_build_object(
2555
+ 'organizationId', p_account_id, 'membershipId', p_membership_id,
2556
+ 'operationId', p_operation_id, 'outcome', 'failed', 'reasonCode', p_reason_code
2557
+ );
2558
+ INSERT INTO organization_user_retention_deletion_events (
2559
+ account_id, membership_id, operation_id, kind, reason_code, result
2560
+ ) VALUES (
2561
+ p_account_id, p_membership_id, p_operation_id, 'failed', p_reason_code, event_result
2562
+ ) ON CONFLICT (account_id, operation_id, kind) DO NOTHING;
2563
+ SELECT * INTO prior_event
2564
+ FROM organization_user_retention_deletion_events event
2565
+ WHERE event.account_id = p_account_id
2566
+ AND event.operation_id = p_operation_id
2567
+ AND event.kind = 'failed';
2568
+ IF NOT FOUND
2569
+ OR prior_event.membership_id IS DISTINCT FROM p_membership_id
2570
+ OR prior_event.reason_code IS DISTINCT FROM p_reason_code
2571
+ THEN
2572
+ RAISE EXCEPTION 'retention failure operation id was reused with different input'
2573
+ USING ERRCODE = '23505';
2574
+ END IF;
2575
+ RETURN true;
2576
+ END
2577
+ $body$;
2578
+
2579
+ CREATE OR REPLACE FUNCTION finalize_organization_retention_deletion(
2580
+ p_account_id uuid,
2581
+ p_membership_id uuid,
2582
+ p_operation_id uuid,
2583
+ p_object_bucket text
2584
+ ) RETURNS jsonb
2585
+ LANGUAGE plpgsql
2586
+ SECURITY DEFINER
2587
+ SET search_path FROM CURRENT
2588
+ AS $body$
2589
+ DECLARE
2590
+ member organization_memberships%ROWTYPE;
2591
+ deletion organization_user_retention_deletions%ROWTYPE;
2592
+ prior_event organization_user_retention_deletion_events%ROWTYPE;
2593
+ unsupported_kind text;
2594
+ candidate_file_ids uuid[];
2595
+ deleted_documents integer := 0;
2596
+ deleted_files integer := 0;
2597
+ deleted_variable_sets integer := 0;
2598
+ deleted_rigs integer := 0;
2599
+ deleted_connections integer := 0;
2600
+ deleted_codex integer := 0;
2601
+ deleted_xai integer := 0;
2602
+ tombstoned_machines integer := 0;
2603
+ deleted_workspace integer := 0;
2604
+ object_count integer := 0;
2605
+ locked_personal_workspace_id uuid;
2606
+ result_value jsonb;
2607
+ BEGIN
2608
+ IF nullif(p_object_bucket, '') IS NULL
2609
+ OR octet_length(convert_to(p_object_bucket, 'UTF8')) > 1024
2610
+ THEN
2611
+ RAISE EXCEPTION 'invalid retention object bucket' USING ERRCODE = '22023';
2612
+ END IF;
2613
+ PERFORM opengeni_private.assert_organization_retention_account(p_account_id);
2614
+ PERFORM pg_catalog.set_config(
2615
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
2616
+ );
2617
+ PERFORM pg_catalog.pg_advisory_xact_lock(
2618
+ pg_catalog.hashtextextended('organization-retention:' || p_operation_id::text, 0)
2619
+ );
2620
+ SELECT * INTO prior_event FROM organization_user_retention_deletion_events event
2621
+ WHERE event.account_id = p_account_id AND event.operation_id = p_operation_id
2622
+ AND event.kind = 'completed';
2623
+ IF FOUND THEN
2624
+ IF prior_event.membership_id IS DISTINCT FROM p_membership_id THEN
2625
+ RAISE EXCEPTION 'retention completion operation id belongs to another membership'
2626
+ USING ERRCODE = '23505';
2627
+ END IF;
2628
+ SELECT candidate.database_result INTO result_value
2629
+ FROM organization_user_retention_deletions candidate
2630
+ WHERE candidate.account_id = p_account_id
2631
+ AND candidate.membership_id = p_membership_id;
2632
+ IF NOT FOUND
2633
+ OR result_value ->> 'objectBucket' IS DISTINCT FROM p_object_bucket
2634
+ THEN
2635
+ RAISE EXCEPTION 'retention completion operation id has different frozen input'
2636
+ USING ERRCODE = '23505';
2637
+ END IF;
2638
+ RETURN result_value;
2639
+ END IF;
2640
+ SELECT * INTO member FROM organization_memberships membership
2641
+ WHERE membership.account_id = p_account_id AND membership.id = p_membership_id;
2642
+ IF NOT FOUND OR member.status <> 'revoked'
2643
+ OR member.personal_retention_until IS NULL
2644
+ OR member.personal_retention_until > clock_timestamp()
2645
+ THEN RAISE EXCEPTION 'retention membership is not eligible' USING ERRCODE = '42501'; END IF;
2646
+ locked_personal_workspace_id := member.personal_workspace_id;
2647
+ IF locked_personal_workspace_id IS NOT NULL THEN
2648
+ PERFORM 1 FROM workspaces workspace
2649
+ WHERE workspace.account_id = p_account_id
2650
+ AND workspace.id = locked_personal_workspace_id
2651
+ FOR UPDATE;
2652
+ IF NOT FOUND THEN
2653
+ RAISE EXCEPTION 'retention personal workspace is missing' USING ERRCODE = '40001';
2654
+ END IF;
2655
+ END IF;
2656
+ SELECT * INTO member FROM organization_memberships membership
2657
+ WHERE membership.account_id = p_account_id AND membership.id = p_membership_id
2658
+ FOR UPDATE;
2659
+ IF NOT FOUND OR member.status <> 'revoked'
2660
+ OR member.personal_retention_until IS NULL
2661
+ OR member.personal_retention_until > clock_timestamp()
2662
+ OR member.personal_workspace_id IS DISTINCT FROM locked_personal_workspace_id
2663
+ THEN RAISE EXCEPTION 'retention membership changed during finalization'
2664
+ USING ERRCODE = '40001'; END IF;
2665
+ SELECT * INTO deletion FROM organization_user_retention_deletions candidate
2666
+ WHERE candidate.account_id = p_account_id AND candidate.membership_id = p_membership_id
2667
+ FOR UPDATE;
2668
+ IF NOT FOUND OR deletion.state <> 'claimed'
2669
+ OR deletion.claim_operation_id <> p_operation_id
2670
+ OR deletion.claim_expires_at <= clock_timestamp()
2671
+ THEN RAISE EXCEPTION 'retention deletion claim is stale' USING ERRCODE = '40001'; END IF;
2672
+ IF deletion.retention_until IS DISTINCT FROM member.personal_retention_until THEN
2673
+ RAISE EXCEPTION 'retention deadline changed after claim' USING ERRCODE = '40001';
2674
+ END IF;
2675
+ IF deletion.database_finalized_at IS NOT NULL THEN
2676
+ IF deletion.database_result ->> 'objectBucket' IS DISTINCT FROM p_object_bucket
2677
+ OR EXISTS (
2678
+ SELECT 1 FROM organization_user_retention_object_obligations obligation
2679
+ WHERE obligation.account_id = p_account_id
2680
+ AND obligation.membership_id = p_membership_id
2681
+ AND obligation.object_bucket <> p_object_bucket
2682
+ )
2683
+ THEN
2684
+ RAISE EXCEPTION 'retention object bucket changed after database finalization'
2685
+ USING ERRCODE = '40001';
2686
+ END IF;
2687
+ RETURN pg_catalog.jsonb_set(
2688
+ deletion.database_result,
2689
+ '{operationId}',
2690
+ pg_catalog.to_jsonb(p_operation_id::text),
2691
+ false
2692
+ );
2693
+ END IF;
2694
+ IF member.personal_workspace_id IS NOT NULL AND EXISTS (
2695
+ SELECT 1
2696
+ FROM documents document
2697
+ LEFT JOIN organization_user_resource_authorities authority
2698
+ ON authority.id = document.authority_id
2699
+ AND authority.account_id = document.account_id
2700
+ AND authority.organization_membership_id = document.owner_organization_membership_id
2701
+ AND authority.resource_kind = 'document'
2702
+ AND authority.resource_id = document.id
2703
+ WHERE document.account_id = p_account_id
2704
+ AND document.workspace_id = member.personal_workspace_id
2705
+ AND (
2706
+ authority.id IS NULL
2707
+ OR authority.organization_membership_id <> p_membership_id
2708
+ )
2709
+ ) THEN
2710
+ RAISE EXCEPTION 'personal workspace contains retained external document authority'
2711
+ USING ERRCODE = '55000';
2712
+ END IF;
2713
+ IF member.personal_workspace_id IS NOT NULL AND EXISTS (
2714
+ SELECT 1
2715
+ FROM workspace_captures capture
2716
+ WHERE capture.account_id = p_account_id
2717
+ AND capture.workspace_id = member.personal_workspace_id
2718
+ AND (
2719
+ pg_catalog.jsonb_typeof(capture.blob_keys) <> 'array'
2720
+ OR CASE WHEN pg_catalog.jsonb_typeof(capture.blob_keys) = 'array' THEN EXISTS (
2721
+ SELECT 1
2722
+ FROM pg_catalog.jsonb_array_elements(capture.blob_keys) blob(value)
2723
+ WHERE pg_catalog.jsonb_typeof(blob.value) <> 'string'
2724
+ OR octet_length(convert_to(blob.value #>> '{}', 'UTF8')) NOT BETWEEN 1 AND 4096
2725
+ ) ELSE false END
2726
+ )
2727
+ ) THEN
2728
+ RAISE EXCEPTION 'personal workspace contains malformed external object inventory'
2729
+ USING ERRCODE = '55000';
2730
+ END IF;
2731
+ SELECT authority.resource_kind INTO unsupported_kind
2732
+ FROM organization_user_resource_authorities authority
2733
+ WHERE authority.account_id = p_account_id
2734
+ AND authority.organization_membership_id = p_membership_id
2735
+ AND authority.resource_kind NOT IN (
2736
+ 'codex_subscription', 'connected_machine', 'connection', 'document',
2737
+ 'rig', 'variable_set', 'xai_subscription'
2738
+ )
2739
+ ORDER BY authority.resource_kind LIMIT 1;
2740
+ IF FOUND THEN
2741
+ RAISE EXCEPTION 'unsupported retained resource kind: %', unsupported_kind
2742
+ USING ERRCODE = '55000';
2743
+ END IF;
2744
+ PERFORM 1 FROM organization_user_resource_authorities authority
2745
+ WHERE authority.account_id = p_account_id
2746
+ AND authority.organization_membership_id = p_membership_id
2747
+ ORDER BY authority.id FOR UPDATE;
2748
+ SELECT COALESCE(array_agg(candidate.file_id ORDER BY candidate.file_id), ARRAY[]::uuid[])
2749
+ INTO candidate_file_ids
2750
+ FROM opengeni_private.organization_retention_file_candidates(
2751
+ p_account_id, p_membership_id
2752
+ ) candidate;
2753
+ PERFORM 1 FROM files file
2754
+ WHERE file.account_id = p_account_id AND file.id = ANY(candidate_file_ids)
2755
+ ORDER BY file.id FOR UPDATE;
2756
+ IF EXISTS (
2757
+ SELECT 1
2758
+ FROM opengeni_private.organization_retention_file_candidates(
2759
+ p_account_id, p_membership_id
2760
+ ) candidate
2761
+ WHERE candidate.object_bucket IS DISTINCT FROM p_object_bucket
2762
+ ) THEN
2763
+ RAISE EXCEPTION 'retention file bucket differs from configured object storage'
2764
+ USING ERRCODE = '55000';
2765
+ END IF;
2766
+ -- Freeze every external-key source before materializing obligations. The
2767
+ -- workspace row above excludes new FK children; these row locks exclude key
2768
+ -- rotation while exact obligations are copied and the workspace is erased.
2769
+ PERFORM 1 FROM session_recordings recording
2770
+ WHERE recording.account_id = p_account_id
2771
+ AND recording.workspace_id = member.personal_workspace_id
2772
+ ORDER BY recording.id FOR UPDATE;
2773
+ PERFORM 1 FROM browser_state_artifacts artifact
2774
+ WHERE artifact.account_id = p_account_id
2775
+ AND artifact.workspace_id = member.personal_workspace_id
2776
+ ORDER BY artifact.id FOR UPDATE;
2777
+ PERFORM 1 FROM browser_state_uploads upload
2778
+ WHERE upload.account_id = p_account_id
2779
+ AND upload.workspace_id = member.personal_workspace_id
2780
+ ORDER BY upload.id FOR UPDATE;
2781
+ PERFORM 1 FROM transcription_recording_objects recording_object
2782
+ WHERE recording_object.account_id = p_account_id
2783
+ AND recording_object.workspace_id = member.personal_workspace_id
2784
+ ORDER BY recording_object.object_key FOR UPDATE;
2785
+ PERFORM 1 FROM video_generation_references reference
2786
+ WHERE reference.account_id = p_account_id
2787
+ AND reference.workspace_id = member.personal_workspace_id
2788
+ ORDER BY reference.operation_id, reference.ordinal FOR UPDATE;
2789
+ PERFORM 1 FROM workspace_artifact_versions version
2790
+ WHERE version.account_id = p_account_id
2791
+ AND version.workspace_id = member.personal_workspace_id
2792
+ ORDER BY version.id FOR UPDATE;
2793
+ PERFORM 1 FROM editable_artifact_blob_refs blob
2794
+ WHERE blob.account_id = p_account_id
2795
+ AND blob.workspace_id = member.personal_workspace_id
2796
+ ORDER BY blob.artifact_id, blob.id FOR UPDATE;
2797
+ PERFORM 1 FROM workspace_captures capture
2798
+ WHERE capture.account_id = p_account_id
2799
+ AND capture.workspace_id = member.personal_workspace_id
2800
+ ORDER BY capture.id FOR UPDATE;
2801
+ INSERT INTO organization_user_retention_object_obligations (
2802
+ account_id, membership_id, object_kind, source_id, object_bucket, object_key,
2803
+ prepared_operation_id, object_key_hash
2804
+ )
2805
+ SELECT p_account_id, p_membership_id, candidate.object_kind,
2806
+ candidate.source_id, p_object_bucket, candidate.object_key, p_operation_id,
2807
+ encode(digest(candidate.object_key, 'sha256'), 'hex')
2808
+ FROM opengeni_private.organization_retention_object_candidates(
2809
+ p_account_id, p_membership_id
2810
+ ) candidate;
2811
+ GET DIAGNOSTICS object_count = ROW_COUNT;
2812
+
2813
+ DELETE FROM documents document
2814
+ USING organization_user_resource_authorities authority
2815
+ WHERE authority.account_id = p_account_id
2816
+ AND authority.organization_membership_id = p_membership_id
2817
+ AND authority.resource_kind = 'document'
2818
+ AND authority.resource_id = document.id
2819
+ AND document.account_id = authority.account_id
2820
+ AND document.authority_id = authority.id
2821
+ AND document.owner_organization_membership_id = authority.organization_membership_id;
2822
+ GET DIAGNOSTICS deleted_documents = ROW_COUNT;
2823
+ DELETE FROM files file
2824
+ WHERE file.account_id = p_account_id AND file.id = ANY(candidate_file_ids)
2825
+ AND NOT EXISTS (SELECT 1 FROM documents document WHERE document.file_id = file.id);
2826
+ GET DIAGNOSTICS deleted_files = ROW_COUNT;
2827
+
2828
+ UPDATE scheduled_tasks task SET status = 'paused', variable_set_id = NULL,
2829
+ authority_revision = authority_revision + 1, updated_at = clock_timestamp()
2830
+ WHERE task.account_id = p_account_id AND task.variable_set_id IN (
2831
+ SELECT authority.resource_id FROM organization_user_resource_authorities authority
2832
+ WHERE authority.account_id = p_account_id
2833
+ AND authority.organization_membership_id = p_membership_id
2834
+ AND authority.resource_kind = 'variable_set'
2835
+ );
2836
+ DELETE FROM workspace_variable_sets resource
2837
+ USING organization_user_resource_authorities authority
2838
+ WHERE authority.account_id = p_account_id
2839
+ AND authority.organization_membership_id = p_membership_id
2840
+ AND authority.resource_kind = 'variable_set'
2841
+ AND authority.resource_id = resource.id
2842
+ AND resource.account_id = authority.account_id
2843
+ AND resource.authority_id = authority.id
2844
+ AND resource.owner_organization_membership_id = authority.organization_membership_id;
2845
+ GET DIAGNOSTICS deleted_variable_sets = ROW_COUNT;
2846
+
2847
+ UPDATE capability_facet_installations binding SET connection_id = NULL,
2848
+ status = 'needs_attention',
2849
+ attention_code = 'owner_retention_deleted',
2850
+ version = version + 1, updated_at = clock_timestamp()
2851
+ WHERE binding.account_id = p_account_id AND binding.connection_id IN (
2852
+ SELECT authority.resource_id FROM organization_user_resource_authorities authority
2853
+ WHERE authority.account_id = p_account_id
2854
+ AND authority.organization_membership_id = p_membership_id
2855
+ AND authority.resource_kind = 'connection'
2856
+ );
2857
+ UPDATE integration_facet_bindings binding SET connection_id = NULL,
2858
+ status = 'needs_attention',
2859
+ last_error_code = 'owner_retention_deleted',
2860
+ version = version + 1, updated_at = clock_timestamp()
2861
+ WHERE binding.account_id = p_account_id AND binding.connection_id IN (
2862
+ SELECT authority.resource_id FROM organization_user_resource_authorities authority
2863
+ WHERE authority.account_id = p_account_id
2864
+ AND authority.organization_membership_id = p_membership_id
2865
+ AND authority.resource_kind = 'connection'
2866
+ );
2867
+ DELETE FROM connections resource
2868
+ USING organization_user_resource_authorities authority
2869
+ WHERE authority.account_id = p_account_id
2870
+ AND authority.organization_membership_id = p_membership_id
2871
+ AND authority.resource_kind = 'connection'
2872
+ AND authority.resource_id = resource.id
2873
+ AND resource.account_id = authority.account_id
2874
+ AND resource.authority_id = authority.id
2875
+ AND resource.owner_organization_membership_id = authority.organization_membership_id;
2876
+ GET DIAGNOSTICS deleted_connections = ROW_COUNT;
2877
+
2878
+ DELETE FROM codex_subscription_credentials resource
2879
+ USING organization_user_resource_authorities authority
2880
+ WHERE authority.account_id = p_account_id
2881
+ AND authority.organization_membership_id = p_membership_id
2882
+ AND authority.resource_kind = 'codex_subscription'
2883
+ AND authority.resource_id = resource.id
2884
+ AND resource.account_id = authority.account_id
2885
+ AND resource.organization_user_resource_authority_id = authority.id
2886
+ AND resource.owner_organization_membership_id = authority.organization_membership_id;
2887
+ GET DIAGNOSTICS deleted_codex = ROW_COUNT;
2888
+ DELETE FROM xai_subscription_credentials resource
2889
+ USING organization_user_resource_authorities authority
2890
+ WHERE authority.account_id = p_account_id
2891
+ AND authority.organization_membership_id = p_membership_id
2892
+ AND authority.resource_kind = 'xai_subscription'
2893
+ AND authority.resource_id = resource.id
2894
+ AND resource.account_id = authority.account_id
2895
+ AND resource.organization_user_resource_authority_id = authority.id
2896
+ AND resource.owner_organization_membership_id = authority.organization_membership_id;
2897
+ GET DIAGNOSTICS deleted_xai = ROW_COUNT;
2898
+
2899
+ DELETE FROM rigs resource
2900
+ USING organization_user_resource_authorities authority
2901
+ WHERE authority.account_id = p_account_id
2902
+ AND authority.organization_membership_id = p_membership_id
2903
+ AND authority.resource_kind = 'rig'
2904
+ AND authority.resource_id = resource.id
2905
+ AND resource.account_id = authority.account_id
2906
+ AND resource.authority_id = authority.id
2907
+ AND resource.owner_organization_membership_id = authority.organization_membership_id;
2908
+ GET DIAGNOSTICS deleted_rigs = ROW_COUNT;
2909
+
2910
+ UPDATE enrollments resource SET status = 'revoked',
2911
+ revoked_at = COALESCE(resource.revoked_at, clock_timestamp()),
2912
+ generation = resource.generation + 1, connection_instance_id = NULL,
2913
+ connection_lease_expires_at = NULL, desktop_unavailable_reason = NULL,
2914
+ updated_at = clock_timestamp()
2915
+ FROM organization_user_resource_authorities authority
2916
+ WHERE authority.account_id = p_account_id
2917
+ AND authority.organization_membership_id = p_membership_id
2918
+ AND authority.resource_kind = 'connected_machine'
2919
+ AND authority.resource_id = resource.id
2920
+ AND resource.account_id = authority.account_id
2921
+ AND resource.authority_id = authority.id
2922
+ AND resource.owner_organization_membership_id = authority.organization_membership_id;
2923
+ GET DIAGNOSTICS tombstoned_machines = ROW_COUNT;
2924
+
2925
+ UPDATE organization_user_resource_authorities authority
2926
+ SET status = 'revoked', revoked_at = COALESCE(authority.revoked_at, clock_timestamp()),
2927
+ generation = authority.generation + 1, updated_at = clock_timestamp()
2928
+ WHERE authority.account_id = p_account_id
2929
+ AND authority.organization_membership_id = p_membership_id
2930
+ AND authority.status <> 'revoked';
2931
+ UPDATE organization_memberships membership
2932
+ SET personal_workspace_id = NULL, updated_at = clock_timestamp()
2933
+ WHERE membership.account_id = p_account_id AND membership.id = p_membership_id;
2934
+ IF member.personal_workspace_id IS NOT NULL THEN
2935
+ DELETE FROM workspaces workspace
2936
+ WHERE workspace.account_id = p_account_id AND workspace.id = member.personal_workspace_id;
2937
+ GET DIAGNOSTICS deleted_workspace = ROW_COUNT;
2938
+ END IF;
2939
+ result_value := pg_catalog.jsonb_build_object(
2940
+ 'organizationId', p_account_id,
2941
+ 'membershipId', p_membership_id,
2942
+ 'operationId', p_operation_id,
2943
+ 'outcome', 'cleanup_pending',
2944
+ 'objectBucket', p_object_bucket,
2945
+ 'objectCount', object_count,
2946
+ 'deletedResources', pg_catalog.jsonb_build_object(
2947
+ 'documents', deleted_documents, 'files', deleted_files,
2948
+ 'variableSets', deleted_variable_sets, 'rigs', deleted_rigs,
2949
+ 'connections', deleted_connections, 'codexSubscriptions', deleted_codex,
2950
+ 'xaiSubscriptions', deleted_xai, 'connectedMachinesTombstoned', tombstoned_machines,
2951
+ 'personalWorkspaces', deleted_workspace
2952
+ ),
2953
+ 'databaseFinalizedAt', clock_timestamp()
2954
+ );
2955
+ UPDATE organization_user_retention_deletions current_deletion
2956
+ SET database_result = result_value, database_finalized_at = clock_timestamp(),
2957
+ claim_expires_at = clock_timestamp() + interval '15 minutes',
2958
+ updated_at = clock_timestamp()
2959
+ WHERE current_deletion.account_id = p_account_id
2960
+ AND current_deletion.membership_id = p_membership_id
2961
+ AND current_deletion.claim_operation_id = p_operation_id;
2962
+ RETURN result_value;
2963
+ END
2964
+ $body$;
2965
+
2966
+ CREATE OR REPLACE FUNCTION complete_organization_retention_deletion(
2967
+ p_account_id uuid,
2968
+ p_membership_id uuid,
2969
+ p_operation_id uuid,
2970
+ p_object_bucket text
2971
+ ) RETURNS jsonb
2972
+ LANGUAGE plpgsql
2973
+ SECURITY DEFINER
2974
+ SET search_path FROM CURRENT
2975
+ AS $body$
2976
+ DECLARE
2977
+ deletion organization_user_retention_deletions%ROWTYPE;
2978
+ prior_event organization_user_retention_deletion_events%ROWTYPE;
2979
+ result_value jsonb;
2980
+ object_count integer;
2981
+ BEGIN
2982
+ IF nullif(p_object_bucket, '') IS NULL
2983
+ OR octet_length(convert_to(p_object_bucket, 'UTF8')) > 1024
2984
+ THEN
2985
+ RAISE EXCEPTION 'invalid retention object bucket' USING ERRCODE = '22023';
2986
+ END IF;
2987
+ PERFORM opengeni_private.assert_organization_retention_account(p_account_id);
2988
+ PERFORM pg_catalog.set_config(
2989
+ 'opengeni.organization_tenancy_lifecycle', 'organization_membership_lifecycle', true
2990
+ );
2991
+ SELECT * INTO prior_event
2992
+ FROM organization_user_retention_deletion_events event
2993
+ WHERE event.account_id = p_account_id
2994
+ AND event.operation_id = p_operation_id
2995
+ AND event.kind = 'completed';
2996
+ IF FOUND THEN
2997
+ IF prior_event.membership_id IS DISTINCT FROM p_membership_id THEN
2998
+ RAISE EXCEPTION 'retention completion operation id belongs to another membership'
2999
+ USING ERRCODE = '23505';
3000
+ END IF;
3001
+ SELECT * INTO deletion
3002
+ FROM organization_user_retention_deletions candidate
3003
+ WHERE candidate.account_id = p_account_id
3004
+ AND candidate.membership_id = p_membership_id;
3005
+ IF NOT FOUND
3006
+ OR deletion.database_result ->> 'objectBucket' IS DISTINCT FROM p_object_bucket
3007
+ THEN
3008
+ RAISE EXCEPTION 'retention completion operation id has different frozen input'
3009
+ USING ERRCODE = '23505';
3010
+ END IF;
3011
+ RETURN prior_event.result;
3012
+ END IF;
3013
+ SELECT * INTO deletion
3014
+ FROM organization_user_retention_deletions candidate
3015
+ WHERE candidate.account_id = p_account_id
3016
+ AND candidate.membership_id = p_membership_id
3017
+ FOR UPDATE;
3018
+ IF NOT FOUND THEN
3019
+ RAISE EXCEPTION 'retention deletion claim is stale' USING ERRCODE = '40001';
3020
+ END IF;
3021
+ -- A concurrent exact retry can miss the completed event before waiting on
3022
+ -- the deletion-row lock. Recheck under the lock so the loser returns the
3023
+ -- winner's immutable result instead of misclassifying completion as stale.
3024
+ SELECT * INTO prior_event
3025
+ FROM organization_user_retention_deletion_events event
3026
+ WHERE event.account_id = p_account_id
3027
+ AND event.operation_id = p_operation_id
3028
+ AND event.kind = 'completed';
3029
+ IF FOUND THEN
3030
+ IF prior_event.membership_id IS DISTINCT FROM p_membership_id
3031
+ OR deletion.database_result ->> 'objectBucket' IS DISTINCT FROM p_object_bucket
3032
+ THEN
3033
+ RAISE EXCEPTION 'retention completion operation id has different frozen input'
3034
+ USING ERRCODE = '23505';
3035
+ END IF;
3036
+ RETURN prior_event.result;
3037
+ END IF;
3038
+ IF deletion.state <> 'claimed'
3039
+ OR deletion.claim_operation_id <> p_operation_id
3040
+ OR deletion.claim_expires_at <= clock_timestamp()
3041
+ OR deletion.database_finalized_at IS NULL
3042
+ OR deletion.database_result ->> 'objectBucket' IS DISTINCT FROM p_object_bucket
3043
+ THEN
3044
+ RAISE EXCEPTION 'retention deletion claim is stale' USING ERRCODE = '40001';
3045
+ END IF;
3046
+ IF EXISTS (
3047
+ SELECT 1 FROM organization_user_retention_object_obligations receipt
3048
+ WHERE receipt.account_id = p_account_id
3049
+ AND receipt.membership_id = p_membership_id
3050
+ AND NOT EXISTS (
3051
+ SELECT 1
3052
+ FROM organization_user_retention_object_deletion_receipts deleted
3053
+ WHERE deleted.account_id = receipt.account_id
3054
+ AND deleted.membership_id = receipt.membership_id
3055
+ AND deleted.object_kind = receipt.object_kind
3056
+ AND deleted.source_id = receipt.source_id
3057
+ AND deleted.object_bucket = receipt.object_bucket
3058
+ )
3059
+ ) THEN
3060
+ RAISE EXCEPTION 'retention object cleanup remains incomplete' USING ERRCODE = '55000';
3061
+ END IF;
3062
+ SELECT count(*)::integer INTO object_count
3063
+ FROM organization_user_retention_object_obligations receipt
3064
+ WHERE receipt.account_id = p_account_id
3065
+ AND receipt.membership_id = p_membership_id;
3066
+ result_value := pg_catalog.jsonb_build_object(
3067
+ 'organizationId', p_account_id,
3068
+ 'membershipId', p_membership_id,
3069
+ 'operationId', p_operation_id,
3070
+ 'outcome', 'completed',
3071
+ 'deletedResources', deletion.database_result -> 'deletedResources'
3072
+ || pg_catalog.jsonb_build_object('externalObjects', object_count),
3073
+ 'completedAt', clock_timestamp()
3074
+ );
3075
+ UPDATE organization_user_retention_deletions current_deletion
3076
+ SET state = 'completed', result = result_value, completed_at = clock_timestamp(),
3077
+ claim_expires_at = clock_timestamp(), updated_at = clock_timestamp()
3078
+ WHERE current_deletion.account_id = p_account_id
3079
+ AND current_deletion.membership_id = p_membership_id
3080
+ AND current_deletion.claim_operation_id = p_operation_id;
3081
+ INSERT INTO organization_user_retention_deletion_events (
3082
+ account_id, membership_id, operation_id, kind, result
3083
+ ) VALUES (p_account_id, p_membership_id, p_operation_id, 'completed', result_value);
3084
+ RETURN result_value;
3085
+ END
3086
+ $body$;
3087
+
3088
+ -- Pin every SECURITY DEFINER routine after creation. FROM CURRENT is used only
3089
+ -- while parsing the target-schema migration; the durable posture is closed.
3090
+ DO $pin_and_grant$
3091
+ DECLARE data_schema text := current_schema();
3092
+ BEGIN
3093
+ EXECUTE format(
3094
+ 'ALTER FUNCTION opengeni_private.assign_managed_self_organization_owner() SET search_path = pg_catalog, %I, pg_temp',
3095
+ data_schema
3096
+ );
3097
+ EXECUTE format(
3098
+ 'ALTER FUNCTION %I.list_self_organization_memberships(text) SET search_path = pg_catalog, %I, pg_temp',
3099
+ data_schema, data_schema
3100
+ );
3101
+ EXECUTE format(
3102
+ 'ALTER FUNCTION %I.list_self_organization_invitations(text) SET search_path = pg_catalog, %I, pg_temp',
3103
+ data_schema, data_schema
3104
+ );
3105
+ EXECUTE format(
3106
+ 'ALTER FUNCTION %I.list_self_organization_invitations(text,uuid,integer) SET search_path = pg_catalog, %I, pg_temp',
3107
+ data_schema, data_schema
3108
+ );
3109
+ EXECUTE format(
3110
+ 'ALTER FUNCTION %I.get_self_organization_invitation(text,uuid) SET search_path = pg_catalog, %I, pg_temp',
3111
+ data_schema, data_schema
3112
+ );
3113
+ EXECUTE format(
3114
+ 'ALTER FUNCTION %I.list_organization_members(uuid,text) SET search_path = pg_catalog, %I, pg_temp',
3115
+ data_schema, data_schema
3116
+ );
3117
+ EXECUTE format(
3118
+ 'ALTER FUNCTION %I.list_organization_invitations(uuid,text,uuid,integer) SET search_path = pg_catalog, %I, pg_temp',
3119
+ data_schema, data_schema
3120
+ );
3121
+ EXECUTE format(
3122
+ 'ALTER FUNCTION %I.organization_membership_command(jsonb) SET search_path = pg_catalog, %I, pg_temp',
3123
+ data_schema, data_schema
3124
+ );
3125
+ EXECUTE format(
3126
+ 'ALTER FUNCTION %I.prepare_organization_membership_protocol_settlements(jsonb) SET search_path = pg_catalog, %I, pg_temp',
3127
+ data_schema, data_schema
3128
+ );
3129
+ EXECUTE format(
3130
+ 'ALTER FUNCTION %I.get_organization_retention_policy(uuid,text) SET search_path = pg_catalog, %I, pg_temp',
3131
+ data_schema, data_schema
3132
+ );
3133
+ EXECUTE format(
3134
+ 'ALTER FUNCTION opengeni_private.assert_organization_retention_account(uuid) SET search_path = pg_catalog, %I, pg_temp',
3135
+ data_schema
3136
+ );
3137
+ EXECUTE format(
3138
+ 'ALTER FUNCTION %I.assert_active_managed_human_organization_membership(uuid,text) SET search_path = pg_catalog, %I, pg_temp',
3139
+ data_schema, data_schema
3140
+ );
3141
+ EXECUTE format(
3142
+ 'ALTER FUNCTION opengeni_private.organization_retention_file_candidates(uuid,uuid) SET search_path = pg_catalog, %I, pg_temp',
3143
+ data_schema
3144
+ );
3145
+ EXECUTE format(
3146
+ 'ALTER FUNCTION opengeni_private.organization_retention_object_candidates(uuid,uuid) SET search_path = pg_catalog, %I, pg_temp',
3147
+ data_schema
3148
+ );
3149
+ EXECUTE format(
3150
+ 'ALTER FUNCTION %I.preview_organization_retention_deletions(uuid,integer) SET search_path = pg_catalog, %I, pg_temp',
3151
+ data_schema, data_schema
3152
+ );
3153
+ EXECUTE format(
3154
+ 'ALTER FUNCTION %I.claim_organization_retention_deletion(uuid,uuid,uuid[]) SET search_path = pg_catalog, %I, pg_temp',
3155
+ data_schema, data_schema
3156
+ );
3157
+ EXECUTE format(
3158
+ 'ALTER FUNCTION %I.list_organization_retention_deletion_objects(uuid,uuid,uuid,text,integer) SET search_path = pg_catalog, %I, pg_temp',
3159
+ data_schema, data_schema
3160
+ );
3161
+ EXECUTE format(
3162
+ 'ALTER FUNCTION %I.record_organization_retention_object_deleted(uuid,uuid,uuid,text,text,text,text) SET search_path = pg_catalog, %I, pg_temp',
3163
+ data_schema, data_schema
3164
+ );
3165
+ EXECUTE format(
3166
+ 'ALTER FUNCTION %I.fail_organization_retention_deletion(uuid,uuid,uuid,text) SET search_path = pg_catalog, %I, pg_temp',
3167
+ data_schema, data_schema
3168
+ );
3169
+ EXECUTE format(
3170
+ 'ALTER FUNCTION %I.finalize_organization_retention_deletion(uuid,uuid,uuid,text) SET search_path = pg_catalog, %I, pg_temp',
3171
+ data_schema, data_schema
3172
+ );
3173
+ EXECUTE format(
3174
+ 'ALTER FUNCTION %I.complete_organization_retention_deletion(uuid,uuid,uuid,text) SET search_path = pg_catalog, %I, pg_temp',
3175
+ data_schema, data_schema
3176
+ );
3177
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
3178
+ GRANT EXECUTE ON FUNCTION opengeni_private.assign_managed_self_organization_owner() TO opengeni_app;
3179
+ GRANT EXECUTE ON FUNCTION opengeni_private.organization_membership_row_json(organization_memberships) TO opengeni_app;
3180
+ GRANT EXECUTE ON FUNCTION opengeni_private.organization_invitation_row_json(organization_membership_invitations) TO opengeni_app;
3181
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.list_self_organization_memberships(text) TO opengeni_app', data_schema);
3182
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.list_self_organization_invitations(text) TO opengeni_app', data_schema);
3183
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.list_self_organization_invitations(text,uuid,integer) TO opengeni_app', data_schema);
3184
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.get_self_organization_invitation(text,uuid) TO opengeni_app', data_schema);
3185
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.list_organization_members(uuid,text) TO opengeni_app', data_schema);
3186
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.list_organization_invitations(uuid,text,uuid,integer) TO opengeni_app', data_schema);
3187
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.organization_membership_command(jsonb) TO opengeni_app', data_schema);
3188
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.prepare_organization_membership_protocol_settlements(jsonb) TO opengeni_app', data_schema);
3189
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.get_organization_retention_policy(uuid,text) TO opengeni_app', data_schema);
3190
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.assert_active_managed_human_organization_membership(uuid,text) TO opengeni_app', data_schema);
3191
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.preview_organization_retention_deletions(uuid,integer) TO opengeni_app', data_schema);
3192
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.claim_organization_retention_deletion(uuid,uuid,uuid[]) TO opengeni_app', data_schema);
3193
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.list_organization_retention_deletion_objects(uuid,uuid,uuid,text,integer) TO opengeni_app', data_schema);
3194
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.record_organization_retention_object_deleted(uuid,uuid,uuid,text,text,text,text) TO opengeni_app', data_schema);
3195
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.fail_organization_retention_deletion(uuid,uuid,uuid,text) TO opengeni_app', data_schema);
3196
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.finalize_organization_retention_deletion(uuid,uuid,uuid,text) TO opengeni_app', data_schema);
3197
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %I.complete_organization_retention_deletion(uuid,uuid,uuid,text) TO opengeni_app', data_schema);
3198
+ END IF;
3199
+ END
3200
+ $pin_and_grant$;
3201
+
3202
+ REVOKE ALL ON FUNCTION opengeni_private.organization_membership_row_json(organization_memberships) FROM PUBLIC;
3203
+ REVOKE ALL ON FUNCTION opengeni_private.organization_invitation_row_json(organization_membership_invitations) FROM PUBLIC;
3204
+ REVOKE ALL ON FUNCTION opengeni_private.assign_managed_self_organization_owner() FROM PUBLIC;
3205
+ REVOKE ALL ON FUNCTION list_self_organization_memberships(text) FROM PUBLIC;
3206
+ REVOKE ALL ON FUNCTION list_self_organization_invitations(text) FROM PUBLIC;
3207
+ REVOKE ALL ON FUNCTION list_self_organization_invitations(text,uuid,integer) FROM PUBLIC;
3208
+ REVOKE ALL ON FUNCTION get_self_organization_invitation(text,uuid) FROM PUBLIC;
3209
+ REVOKE ALL ON FUNCTION list_organization_members(uuid,text) FROM PUBLIC;
3210
+ REVOKE ALL ON FUNCTION list_organization_invitations(uuid,text,uuid,integer) FROM PUBLIC;
3211
+ REVOKE ALL ON FUNCTION organization_membership_command(jsonb) FROM PUBLIC;
3212
+ REVOKE ALL ON FUNCTION prepare_organization_membership_protocol_settlements(jsonb) FROM PUBLIC;
3213
+ REVOKE ALL ON FUNCTION get_organization_retention_policy(uuid,text) FROM PUBLIC;
3214
+ REVOKE ALL ON FUNCTION assert_active_managed_human_organization_membership(uuid,text) FROM PUBLIC;
3215
+ REVOKE ALL ON FUNCTION opengeni_private.assert_organization_retention_account(uuid) FROM PUBLIC;
3216
+ REVOKE ALL ON FUNCTION opengeni_private.organization_retention_file_candidates(uuid,uuid) FROM PUBLIC;
3217
+ REVOKE ALL ON FUNCTION opengeni_private.organization_retention_object_candidates(uuid,uuid) FROM PUBLIC;
3218
+ REVOKE ALL ON FUNCTION preview_organization_retention_deletions(uuid,integer) FROM PUBLIC;
3219
+ REVOKE ALL ON FUNCTION claim_organization_retention_deletion(uuid,uuid,uuid[]) FROM PUBLIC;
3220
+ REVOKE ALL ON FUNCTION list_organization_retention_deletion_objects(uuid,uuid,uuid,text,integer) FROM PUBLIC;
3221
+ REVOKE ALL ON FUNCTION record_organization_retention_object_deleted(uuid,uuid,uuid,text,text,text,text) FROM PUBLIC;
3222
+ REVOKE ALL ON FUNCTION fail_organization_retention_deletion(uuid,uuid,uuid,text) FROM PUBLIC;
3223
+ REVOKE ALL ON FUNCTION finalize_organization_retention_deletion(uuid,uuid,uuid,text) FROM PUBLIC;
3224
+ REVOKE ALL ON FUNCTION complete_organization_retention_deletion(uuid,uuid,uuid,text) FROM PUBLIC;
3225
+
3226
+ REVOKE ALL ON TABLE organization_memberships FROM PUBLIC;
3227
+ REVOKE ALL ON TABLE organization_user_retention_policies FROM PUBLIC;
3228
+ REVOKE ALL ON TABLE organization_membership_invitations FROM PUBLIC;
3229
+ REVOKE ALL ON TABLE organization_membership_operation_receipts FROM PUBLIC;
3230
+ REVOKE ALL ON TABLE organization_user_retention_deletions FROM PUBLIC;
3231
+ REVOKE ALL ON TABLE organization_user_retention_object_obligations FROM PUBLIC;
3232
+ REVOKE ALL ON TABLE organization_user_retention_object_deletion_receipts FROM PUBLIC;
3233
+ REVOKE ALL ON TABLE organization_user_retention_deletion_events FROM PUBLIC;
3234
+ DO $revoke_app_dml$
3235
+ BEGIN
3236
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
3237
+ REVOKE ALL ON TABLE organization_memberships FROM opengeni_app;
3238
+ REVOKE ALL ON TABLE organization_user_retention_policies FROM opengeni_app;
3239
+ REVOKE ALL ON TABLE organization_membership_invitations FROM opengeni_app;
3240
+ REVOKE ALL ON TABLE organization_membership_operation_receipts FROM opengeni_app;
3241
+ REVOKE ALL ON TABLE organization_membership_lifecycle_events FROM opengeni_app;
3242
+ REVOKE ALL ON TABLE organization_user_retention_deletions FROM opengeni_app;
3243
+ REVOKE ALL ON TABLE organization_user_retention_object_obligations FROM opengeni_app;
3244
+ REVOKE ALL ON TABLE organization_user_retention_object_deletion_receipts FROM opengeni_app;
3245
+ REVOKE ALL ON TABLE organization_user_retention_deletion_events FROM opengeni_app;
3246
+ END IF;
3247
+ END
3248
+ $revoke_app_dml$;
3249
+
3250
+ CREATE OR REPLACE FUNCTION opengeni_private.organization_membership_history_immutable()
3251
+ RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog AS $body$
3252
+ BEGIN RAISE EXCEPTION 'organization membership history is immutable' USING ERRCODE = '55000'; END
3253
+ $body$;
3254
+ REVOKE ALL ON FUNCTION opengeni_private.organization_membership_history_immutable() FROM PUBLIC;
3255
+ CREATE TRIGGER organization_membership_operation_receipts_immutable
3256
+ BEFORE UPDATE OR DELETE ON organization_membership_operation_receipts
3257
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.organization_membership_history_immutable();
3258
+ CREATE TRIGGER organization_membership_lifecycle_events_immutable
3259
+ BEFORE UPDATE OR DELETE ON organization_membership_lifecycle_events
3260
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.organization_membership_history_immutable();
3261
+ CREATE TRIGGER organization_user_retention_object_obligations_immutable
3262
+ BEFORE UPDATE OR DELETE ON organization_user_retention_object_obligations
3263
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.organization_membership_history_immutable();
3264
+ CREATE TRIGGER organization_user_retention_object_deletion_receipts_immutable
3265
+ BEFORE UPDATE OR DELETE ON organization_user_retention_object_deletion_receipts
3266
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.organization_membership_history_immutable();
3267
+ CREATE TRIGGER organization_user_retention_deletion_events_immutable
3268
+ BEFORE UPDATE OR DELETE ON organization_user_retention_deletion_events
3269
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.organization_membership_history_immutable();
3270
+
3271
+ COMMENT ON TABLE organization_membership_invitations IS
3272
+ 'Registered-human organization invitations; acceptance is exact-subject and provider delivery is external.';
3273
+ COMMENT ON TABLE organization_membership_operation_receipts IS
3274
+ 'Immutable input-bound idempotency receipts for organization membership lifecycle commands.';
3275
+ COMMENT ON TABLE organization_membership_lifecycle_events IS
3276
+ 'Immutable value-bounded organization membership lifecycle audit evidence.';
3277
+ COMMENT ON TABLE organization_user_retention_deletions IS
3278
+ 'Mutable, claim-fenced state for bounded destructive retention of one expired offboarded member.';
3279
+ COMMENT ON TABLE organization_user_retention_object_obligations IS
3280
+ 'Immutable exact-bucket/key cleanup obligations prepared by database-first retention finalization; protected lifecycle capability only.';
3281
+ COMMENT ON TABLE organization_user_retention_object_deletion_receipts IS
3282
+ 'Immutable content-free proof that one exact-bucket/key prepared retention cleanup obligation was deleted from external storage.';
3283
+ COMMENT ON TABLE organization_user_retention_deletion_events IS
3284
+ 'Immutable content-free claim, failure and completion evidence for membership retention deletion.';