@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,735 @@
1
+ /**
2
+ * Phase D (docs/organization-tenancy.md) membership and personal-workspace
3
+ * backfill driver.
4
+ *
5
+ * The population this drains is the one 0285's inventory counts as
6
+ * `workspaceMemberSubjectsWithoutMembershipAnchor`: humans who held workspace
7
+ * access before migration 0219 and never re-authenticated afterwards, so the
8
+ * Better Auth managed-access hook never ran the provisioning seam for them and
9
+ * they have no `organization_memberships` anchor at all. It also drains
10
+ * `organizationMemberships.activeWithoutPersonalWorkspace`.
11
+ *
12
+ * Three rules shape everything here:
13
+ *
14
+ * 1. **Reuse the lifecycle seam.** Provisioning goes through
15
+ * `ensureManagedHumanPersonalWorkspace`, the single shared implementation
16
+ * over 0219's `ensure_managed_human_personal_workspace` SECURITY DEFINER
17
+ * capability - the same call the Better Auth hook makes. This driver holds
18
+ * no authority logic of its own and never writes an organization-tenancy
19
+ * table directly; the seam fails closed on every identity it cannot prove.
20
+ *
21
+ * 2. **Never infer authority.** Phase D forbids inferring user authority from
22
+ * `created_by`, connection attribution, a default workspace, a resource
23
+ * name, or current access. This driver acts only on deterministic evidence:
24
+ * a live Better Auth login identity for the exact subject, an organization
25
+ * whose own external identity IS that human, and a persisted owner-role
26
+ * workspace membership. Anything short of that is recorded unresolved with
27
+ * a reason code and left untouched - never guessed, never widened.
28
+ *
29
+ * 3. **Idempotent, resumable, concurrency-safe.** Each candidate is claimed
30
+ * independently with `FOR UPDATE SKIP LOCKED` on its exact owner workspace
31
+ * membership, so two operators running at once take disjoint work and
32
+ * neither double-provisions; the seam's own row locks are the final
33
+ * authority. Running the command again converges rather than duplicating,
34
+ * and `--dry-run` writes nothing at all.
35
+ *
36
+ * 4. **A bounded pass is a keyset window, never a fixed window.** Both
37
+ * populations are ordered by `subject_id`, and a pass reads only subjects
38
+ * strictly greater than `afterSubjectId`. This is what makes repeated passes
39
+ * *converge* rather than re-read: a subject the driver cannot resolve stays
40
+ * in its population forever (a missing login identity is not going to
41
+ * appear because we looked again), so a fixed `LIMIT n` window over an
42
+ * organization with more than `n` subjects would return the same first `n`
43
+ * rows on every pass and never reach subject `n + 1`. Each source is read
44
+ * `limit`-deep from the same cursor before the merge, so the merged window
45
+ * is a true prefix of the merged ordered stream and one population can
46
+ * never starve the other.
47
+ */
48
+ import { sql } from "drizzle-orm";
49
+ import type { Database } from "./database";
50
+ import { rawRows, setRlsContext, withRlsContext } from "./database";
51
+ import { ensureManagedHumanPersonalWorkspace } from "./managed-human-provisioning";
52
+
53
+ /** Why a candidate cannot be provisioned from deterministic evidence alone. */
54
+ export type OrganizationMembershipBackfillUnresolvedReason =
55
+ /** No Better Auth login identity exists for this subject: the `user:<id>`
56
+ * subject is a persisted string with nothing behind it. Guessing an
57
+ * identity here would be exactly the inference Phase D forbids. */
58
+ | "missing_login_identity"
59
+ /** The organization's own external identity is not this human. 0219's seam
60
+ * provisions only a managed human's self-owned organization, and 0263's
61
+ * invitation acceptance is bound to the invited human's own authenticated
62
+ * session - so no operator-runnable idempotent lifecycle operation exists
63
+ * for this row. It needs a human act, not a backfill. */
64
+ | "organization_identity_mismatch"
65
+ /** The subject has workspace access in this organization but holds no
66
+ * owner-role workspace membership, which the lifecycle seam requires as
67
+ * proof of ownership. Promoting them would manufacture authority. */
68
+ | "missing_owner_workspace_membership"
69
+ /** An anchor exists but is suspended or revoked. Reactivation is an explicit
70
+ * owner-authorized 0263 lifecycle action; a backfill must never perform it. */
71
+ | "membership_terminal_status";
72
+
73
+ export type OrganizationMembershipBackfillCandidate = {
74
+ subjectId: string;
75
+ /** Stable UUID of the exact authority/source row that made this subject a
76
+ * backfill obligation. Existing anchors use their membership id; an
77
+ * anchorless subject uses the first persisted workspace-membership id.
78
+ * This is the content-free resource id written to migration 0300's ledger. */
79
+ evidenceResourceId: string;
80
+ resolution: "provisionable" | "already_anchored" | "unresolved";
81
+ reasonCode: OrganizationMembershipBackfillUnresolvedReason | null;
82
+ organizationMembershipId: string | null;
83
+ personalWorkspaceId: string | null;
84
+ };
85
+
86
+ export type OrganizationMembershipBackfillOutcome =
87
+ | {
88
+ subjectId: string;
89
+ outcome: "provisioned";
90
+ organizationMembershipId: string;
91
+ personalWorkspaceId: string;
92
+ }
93
+ | {
94
+ subjectId: string;
95
+ outcome: "already_anchored";
96
+ organizationMembershipId: string;
97
+ personalWorkspaceId: string | null;
98
+ }
99
+ | {
100
+ subjectId: string;
101
+ outcome: "unresolved";
102
+ reasonCode: OrganizationMembershipBackfillUnresolvedReason;
103
+ }
104
+ /** Another concurrent run holds this candidate's claim row. Deliberately not
105
+ * a failure: the next pass picks it up, which is what resumability means. */
106
+ | { subjectId: string; outcome: "contended" }
107
+ | { subjectId: string; outcome: "failed"; reasonCode: string };
108
+
109
+ export type OrganizationMembershipBackfillCounts = {
110
+ inspected: number;
111
+ provisioned: number;
112
+ alreadyAnchored: number;
113
+ unresolved: number;
114
+ contended: number;
115
+ failed: number;
116
+ };
117
+
118
+ export type OrganizationMembershipBackfillReport = {
119
+ organizationId: string;
120
+ dryRun: boolean;
121
+ /** The exclusive keyset cursor this pass started after (`null` = from the
122
+ * beginning of the ordered subject stream). */
123
+ startCursor: string | null;
124
+ /** Feed this back as `afterSubjectId` to continue. `null` means this pass
125
+ * reached the end of the ordered stream: the organization is drained. */
126
+ nextCursor: string | null;
127
+ candidates: OrganizationMembershipBackfillCandidate[];
128
+ results: OrganizationMembershipBackfillOutcome[];
129
+ counts: OrganizationMembershipBackfillCounts;
130
+ };
131
+
132
+ /** One full walk of an organization: repeated bounded passes, cursor-chained. */
133
+ export type OrganizationMembershipBackfillDrainReport = {
134
+ organizationId: string;
135
+ dryRun: boolean;
136
+ limit: number;
137
+ passes: number;
138
+ /** True when the walk reached the end of the ordered stream. False only when
139
+ * `maxPasses` stopped it first, and then `lastCursor` resumes it. */
140
+ drained: boolean;
141
+ lastCursor: string | null;
142
+ counts: OrganizationMembershipBackfillCounts;
143
+ unresolved: Array<{
144
+ subjectId: string;
145
+ resourceId: string;
146
+ reasonCode: OrganizationMembershipBackfillUnresolvedReason;
147
+ }>;
148
+ /** Claimed by a peer run during this walk. Not a failure and not skipped
149
+ * work: re-running the command from the start converges them. */
150
+ contended: string[];
151
+ failed: Array<{ subjectId: string; reasonCode: string }>;
152
+ /** Null when no run key was supplied or the walk was a dry run. */
153
+ receiptId: string | null;
154
+ receiptStatus: "completed" | "failed" | null;
155
+ };
156
+
157
+ export type OrganizationMembershipBackfillPage = {
158
+ candidates: OrganizationMembershipBackfillCandidate[];
159
+ nextCursor: string | null;
160
+ };
161
+
162
+ type AnchorRow = {
163
+ subjectId: string;
164
+ membershipId: string;
165
+ status: string;
166
+ personalWorkspaceId: string | null;
167
+ };
168
+
169
+ const SUBJECT_PREFIX = "user:";
170
+
171
+ function subjectUserId(subjectId: string): string {
172
+ return subjectId.slice(SUBJECT_PREFIX.length);
173
+ }
174
+
175
+ /** Drizzle expands a bare JS array into a parameter list, not a Postgres
176
+ * array, so every `= any(...)` / `text[]` argument is built explicitly. */
177
+ function textArray(values: readonly string[]) {
178
+ return values.length === 0
179
+ ? sql`ARRAY[]::text[]`
180
+ : sql`ARRAY[${sql.join(
181
+ values.map((value) => sql`${value}::text`),
182
+ sql`, `,
183
+ )}]::text[]`;
184
+ }
185
+
186
+ /**
187
+ * The single subject-id order this driver uses, on BOTH sides of the seam.
188
+ *
189
+ * Both source queries page with `COLLATE "C"`, which compares the UTF-8 bytes -
190
+ * i.e. code point order. This comparator reproduces exactly that in JavaScript.
191
+ * The default `Array#sort` comparator would very nearly do it, but it compares
192
+ * UTF-16 *code units*, so a supplementary-plane subject id (encoded as a
193
+ * surrogate pair, `0xD800..0xDFFF`) sorts below `U+E000..U+FFFF` in JavaScript
194
+ * and above it in the database. The guards here admit arbitrary 1024-character
195
+ * `user:` text, so that disagreement is reachable; a keyset cursor taken from a
196
+ * merged order that disagrees with the database silently skips subjects and
197
+ * still reports a drained population.
198
+ */
199
+ function compareSubjectIds(left: string, right: string): number {
200
+ const length = Math.min(left.length, right.length);
201
+ for (let index = 0; index < length;) {
202
+ const leftPoint = left.codePointAt(index)!;
203
+ const rightPoint = right.codePointAt(index)!;
204
+ if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1;
205
+ index += leftPoint > 0xffff ? 2 : 1;
206
+ }
207
+ if (left.length === right.length) return 0;
208
+ return left.length < right.length ? -1 : 1;
209
+ }
210
+
211
+ function assertCursor(afterSubjectId: string | null | undefined): string | null {
212
+ if (afterSubjectId === null || afterSubjectId === undefined) return null;
213
+ if (
214
+ afterSubjectId !== afterSubjectId.trim() ||
215
+ afterSubjectId.length < 1 ||
216
+ afterSubjectId.length > 1024 ||
217
+ !afterSubjectId.startsWith(SUBJECT_PREFIX)
218
+ ) {
219
+ throw new Error("Organization membership backfill cursor must be a 'user:' subject id");
220
+ }
221
+ return afterSubjectId;
222
+ }
223
+
224
+ /**
225
+ * Enumerate one bounded, deterministically ordered, keyset-paged backfill
226
+ * candidate window for one organization and classify each candidate. Strictly
227
+ * read-only: safe to call repeatedly, and the exact query `--dry-run` reports.
228
+ *
229
+ * The window is the first `limit` subjects strictly greater than
230
+ * `afterSubjectId` in the merge of both populations. `nextCursor` is the last
231
+ * subject of that window when more may remain, and `null` once the stream is
232
+ * exhausted - which is the only thing that makes repeated passes terminate on
233
+ * a drained organization instead of re-reading one window forever.
234
+ */
235
+ export async function listOrganizationMembershipBackfillCandidates(
236
+ db: Database,
237
+ input: { organizationId: string; limit: number; afterSubjectId?: string | null },
238
+ ): Promise<OrganizationMembershipBackfillPage> {
239
+ if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) {
240
+ throw new Error("Organization membership backfill limit must be an integer from 1 to 100");
241
+ }
242
+ const afterSubjectId = assertCursor(input.afterSubjectId);
243
+ return await withRlsContext(
244
+ db,
245
+ { accountId: input.organizationId, workspaceId: null },
246
+ async (scopedDb) => {
247
+ // Population 1: humans with persisted workspace access in this
248
+ // organization. Ordinary account-scoped application-role reads - no
249
+ // widened visibility, no organization-tenancy table touched.
250
+ const accessRows = await rawRows<{
251
+ subject_id: string;
252
+ owner_membership_id: string | null;
253
+ evidence_membership_id: string;
254
+ }>(
255
+ scopedDb,
256
+ sql`
257
+ select
258
+ access.subject_id as subject_id,
259
+ max(case when access.role = 'owner' then access.id::text else null end)
260
+ as owner_membership_id,
261
+ min(access.id::text) as evidence_membership_id
262
+ from workspace_memberships access
263
+ inner join workspaces workspace
264
+ on workspace.id = access.workspace_id
265
+ and workspace.account_id = access.account_id
266
+ where access.account_id = ${input.organizationId}
267
+ and access.subject_id like ${`${SUBJECT_PREFIX}%`}
268
+ and (
269
+ ${afterSubjectId}::text is null
270
+ or access.subject_id collate "C" > ${afterSubjectId}::text collate "C"
271
+ )
272
+ group by access.subject_id
273
+ order by access.subject_id collate "C"
274
+ limit ${input.limit}
275
+ `,
276
+ );
277
+ const ownerMembershipBySubject = new Map<string, string | null>(
278
+ accessRows.map((row) => [row.subject_id, row.owner_membership_id]),
279
+ );
280
+ const evidenceMembershipBySubject = new Map<string, string>(
281
+ accessRows.map((row) => [row.subject_id, row.evidence_membership_id]),
282
+ );
283
+
284
+ // Population 2: existing memberships that carry no personal workspace.
285
+ // organization_memberships is FORCE-RLS with zero direct application
286
+ // privileges, so this is the 0290 read-only definer seam.
287
+ const [pendingResult] = await rawRows<{ result: unknown }>(
288
+ scopedDb,
289
+ sql`
290
+ select list_organization_memberships_without_personal_workspace(
291
+ ${input.organizationId}, ${input.limit}, ${afterSubjectId}::text
292
+ ) as result
293
+ `,
294
+ );
295
+ const pendingRows = (pendingResult?.result ?? []) as Array<{
296
+ subjectId: string;
297
+ membershipId: string;
298
+ status: string;
299
+ }>;
300
+ for (const row of pendingRows) {
301
+ if (!ownerMembershipBySubject.has(row.subjectId)) {
302
+ ownerMembershipBySubject.set(row.subjectId, null);
303
+ }
304
+ if (!evidenceMembershipBySubject.has(row.subjectId)) {
305
+ evidenceMembershipBySubject.set(row.subjectId, row.membershipId);
306
+ }
307
+ }
308
+
309
+ // Both sources were read `limit`-deep from the SAME cursor, so the first
310
+ // `limit` of their sorted union is exactly the first `limit` of the
311
+ // merged ordered stream: anything the slice drops sorts strictly after
312
+ // the kept tail and is reached by the next pass under `nextCursor`.
313
+ // Neither population can therefore starve the other.
314
+ //
315
+ // That argument only holds while this merge order and the two source
316
+ // orders are the SAME order. Both sources page with `COLLATE "C"`; this
317
+ // reproduces it exactly. See `compareSubjectIds`.
318
+ const merged = [...ownerMembershipBySubject.keys()].sort(compareSubjectIds);
319
+ const subjectIds = merged.slice(0, input.limit);
320
+ // More may remain when the merge was truncated, or when either source
321
+ // filled its own window (there may be a `limit + 1`-th row behind it).
322
+ const mayHaveMore =
323
+ merged.length > input.limit ||
324
+ accessRows.length >= input.limit ||
325
+ pendingRows.length >= input.limit;
326
+ const nextCursor =
327
+ mayHaveMore && subjectIds.length > 0 ? subjectIds[subjectIds.length - 1]! : null;
328
+ if (subjectIds.length === 0) return { candidates: [], nextCursor: null };
329
+
330
+ // Owner-role proof for any subject that entered only through population 2.
331
+ const missingOwnerProof = subjectIds.filter(
332
+ (subjectId) => ownerMembershipBySubject.get(subjectId) == null,
333
+ );
334
+ if (missingOwnerProof.length > 0) {
335
+ const ownerRows = await rawRows<{ subject_id: string; id: string }>(
336
+ scopedDb,
337
+ sql`
338
+ select access.subject_id as subject_id, access.id::text as id
339
+ from workspace_memberships access
340
+ inner join workspaces workspace
341
+ on workspace.id = access.workspace_id
342
+ and workspace.account_id = access.account_id
343
+ where access.account_id = ${input.organizationId}
344
+ and access.role = 'owner'
345
+ and access.subject_id = any(${textArray(missingOwnerProof)})
346
+ `,
347
+ );
348
+ for (const row of ownerRows) ownerMembershipBySubject.set(row.subject_id, row.id);
349
+ }
350
+
351
+ const [anchorResult] = await rawRows<{ result: unknown }>(
352
+ scopedDb,
353
+ sql`
354
+ select list_organization_membership_backfill_anchors(
355
+ ${input.organizationId}, ${textArray(subjectIds)}
356
+ ) as result
357
+ `,
358
+ );
359
+ const anchorBySubject = new Map<string, AnchorRow>(
360
+ ((anchorResult?.result ?? []) as AnchorRow[]).map((row) => [row.subjectId, row]),
361
+ );
362
+
363
+ // Deterministic login-identity evidence. `auth_users` is the Better Auth
364
+ // user table; a subject with no row behind it is exactly the case Phase D
365
+ // says must be recorded, not guessed.
366
+ const userIds = subjectIds.map(subjectUserId);
367
+ const identityRows = await rawRows<{ id: string }>(
368
+ scopedDb,
369
+ sql`select id from auth_users where id = any(${textArray(userIds)})`,
370
+ );
371
+ const knownUserIds = new Set(identityRows.map((row) => row.id));
372
+
373
+ // The organization's own external identity. 0219's seam provisions only
374
+ // a managed human's self-owned organization.
375
+ const organizationRows = await rawRows<{
376
+ external_source: string | null;
377
+ external_id: string | null;
378
+ }>(
379
+ scopedDb,
380
+ sql`
381
+ select external_source, external_id
382
+ from managed_accounts
383
+ where id = ${input.organizationId}
384
+ `,
385
+ );
386
+ const organization = organizationRows[0] ?? null;
387
+
388
+ const candidates = subjectIds.map((subjectId): OrganizationMembershipBackfillCandidate => {
389
+ const anchor = anchorBySubject.get(subjectId) ?? null;
390
+ const evidenceResourceId =
391
+ anchor?.membershipId ?? evidenceMembershipBySubject.get(subjectId);
392
+ if (!evidenceResourceId) {
393
+ throw new Error("Organization membership backfill candidate has no durable evidence row");
394
+ }
395
+ if (anchor && (anchor.status === "suspended" || anchor.status === "revoked")) {
396
+ return {
397
+ subjectId,
398
+ evidenceResourceId,
399
+ resolution: "unresolved",
400
+ reasonCode: "membership_terminal_status",
401
+ organizationMembershipId: anchor.membershipId,
402
+ personalWorkspaceId: anchor.personalWorkspaceId,
403
+ };
404
+ }
405
+ if (anchor && anchor.personalWorkspaceId !== null) {
406
+ return {
407
+ subjectId,
408
+ evidenceResourceId,
409
+ resolution: "already_anchored",
410
+ reasonCode: null,
411
+ organizationMembershipId: anchor.membershipId,
412
+ personalWorkspaceId: anchor.personalWorkspaceId,
413
+ };
414
+ }
415
+ if (!knownUserIds.has(subjectUserId(subjectId))) {
416
+ return {
417
+ subjectId,
418
+ evidenceResourceId,
419
+ resolution: "unresolved",
420
+ reasonCode: "missing_login_identity",
421
+ organizationMembershipId: anchor?.membershipId ?? null,
422
+ personalWorkspaceId: null,
423
+ };
424
+ }
425
+ if (
426
+ !organization ||
427
+ organization.external_source !== "better-auth:user" ||
428
+ organization.external_id !== subjectUserId(subjectId)
429
+ ) {
430
+ return {
431
+ subjectId,
432
+ evidenceResourceId,
433
+ resolution: "unresolved",
434
+ reasonCode: "organization_identity_mismatch",
435
+ organizationMembershipId: anchor?.membershipId ?? null,
436
+ personalWorkspaceId: null,
437
+ };
438
+ }
439
+ if (ownerMembershipBySubject.get(subjectId) == null) {
440
+ return {
441
+ subjectId,
442
+ evidenceResourceId,
443
+ resolution: "unresolved",
444
+ reasonCode: "missing_owner_workspace_membership",
445
+ organizationMembershipId: anchor?.membershipId ?? null,
446
+ personalWorkspaceId: null,
447
+ };
448
+ }
449
+ return {
450
+ subjectId,
451
+ evidenceResourceId,
452
+ resolution: "provisionable",
453
+ reasonCode: null,
454
+ organizationMembershipId: anchor?.membershipId ?? null,
455
+ personalWorkspaceId: null,
456
+ };
457
+ });
458
+ return { candidates, nextCursor };
459
+ },
460
+ );
461
+ }
462
+
463
+ /**
464
+ * Provision exactly one candidate inside one transaction.
465
+ *
466
+ * The transaction first claims the candidate's owner workspace membership with
467
+ * `FOR UPDATE SKIP LOCKED`. That row is the natural claim: it is the exact
468
+ * evidence the lifecycle seam validates, so holding it for the duration also
469
+ * stops a concurrent workspace-membership removal from racing the anchor we are
470
+ * writing. A skipped claim means a peer run owns this candidate right now -
471
+ * reported as `contended`, picked up by the next pass.
472
+ */
473
+ export async function provisionOrganizationMembershipBackfillCandidate(
474
+ db: Database,
475
+ input: { organizationId: string; subjectId: string },
476
+ ): Promise<OrganizationMembershipBackfillOutcome> {
477
+ return await db.transaction(async (tx) => {
478
+ const scopedTx = tx as unknown as Database;
479
+ await setRlsContext(scopedTx, { accountId: input.organizationId, workspaceId: null });
480
+ const claimed = await rawRows<{ id: string }>(
481
+ scopedTx,
482
+ sql`
483
+ select access.id::text as id
484
+ from workspace_memberships access
485
+ inner join workspaces workspace
486
+ on workspace.id = access.workspace_id
487
+ and workspace.account_id = access.account_id
488
+ where access.account_id = ${input.organizationId}
489
+ and access.subject_id = ${input.subjectId}
490
+ and access.role = 'owner'
491
+ order by access.id
492
+ limit 1
493
+ for update of access skip locked
494
+ `,
495
+ );
496
+ if (claimed.length === 0) {
497
+ // Either a peer run holds the claim, or the owner membership vanished
498
+ // between enumeration and now. Both are safe to retry; neither may be
499
+ // resolved by relaxing the evidence requirement.
500
+ return { subjectId: input.subjectId, outcome: "contended" as const };
501
+ }
502
+ const provisioned = await ensureManagedHumanPersonalWorkspace(scopedTx, {
503
+ accountId: input.organizationId,
504
+ subjectId: input.subjectId,
505
+ });
506
+ return {
507
+ subjectId: input.subjectId,
508
+ outcome: "provisioned" as const,
509
+ organizationMembershipId: provisioned.organizationMembershipId,
510
+ personalWorkspaceId: provisioned.personalWorkspaceId,
511
+ };
512
+ });
513
+ }
514
+
515
+ /**
516
+ * Run one bounded backfill pass over an organization. Safe to run repeatedly
517
+ * and concurrently; `dryRun` inspects and classifies without writing anything.
518
+ *
519
+ * One pass is one keyset window. Chain `report.nextCursor` back in as
520
+ * `afterSubjectId` to walk the whole organization, or call
521
+ * `drainOrganizationMembershipBackfill` which does exactly that.
522
+ */
523
+ export async function runOrganizationMembershipBackfill(
524
+ db: Database,
525
+ input: {
526
+ organizationId: string;
527
+ limit: number;
528
+ dryRun: boolean;
529
+ afterSubjectId?: string | null;
530
+ },
531
+ ): Promise<OrganizationMembershipBackfillReport> {
532
+ const startCursor = assertCursor(input.afterSubjectId);
533
+ const { candidates, nextCursor } = await listOrganizationMembershipBackfillCandidates(db, {
534
+ organizationId: input.organizationId,
535
+ limit: input.limit,
536
+ afterSubjectId: startCursor,
537
+ });
538
+ const results: OrganizationMembershipBackfillOutcome[] = [];
539
+ for (const candidate of candidates) {
540
+ if (candidate.resolution === "unresolved") {
541
+ results.push({
542
+ subjectId: candidate.subjectId,
543
+ outcome: "unresolved",
544
+ reasonCode: candidate.reasonCode!,
545
+ });
546
+ continue;
547
+ }
548
+ if (candidate.resolution === "already_anchored") {
549
+ results.push({
550
+ subjectId: candidate.subjectId,
551
+ outcome: "already_anchored",
552
+ organizationMembershipId: candidate.organizationMembershipId!,
553
+ personalWorkspaceId: candidate.personalWorkspaceId,
554
+ });
555
+ continue;
556
+ }
557
+ if (input.dryRun) continue;
558
+ try {
559
+ results.push(
560
+ await provisionOrganizationMembershipBackfillCandidate(db, {
561
+ organizationId: input.organizationId,
562
+ subjectId: candidate.subjectId,
563
+ }),
564
+ );
565
+ } catch (error) {
566
+ // One candidate's failure must never roll back or hide the rest. The
567
+ // reason is a bounded code; the exact diagnostic stays in the operator's
568
+ // logs rather than becoming a stored blob.
569
+ results.push({
570
+ subjectId: candidate.subjectId,
571
+ outcome: "failed",
572
+ reasonCode:
573
+ error instanceof Error && error.message.length > 0
574
+ ? "lifecycle_seam_rejected"
575
+ : "unknown_failure",
576
+ });
577
+ }
578
+ }
579
+ const countOf = (outcome: OrganizationMembershipBackfillOutcome["outcome"]): number =>
580
+ results.filter((result) => result.outcome === outcome).length;
581
+ return {
582
+ organizationId: input.organizationId,
583
+ dryRun: input.dryRun,
584
+ startCursor,
585
+ nextCursor,
586
+ candidates,
587
+ results,
588
+ counts: {
589
+ inspected: candidates.length,
590
+ provisioned: countOf("provisioned"),
591
+ alreadyAnchored: countOf("already_anchored"),
592
+ unresolved: countOf("unresolved"),
593
+ contended: countOf("contended"),
594
+ failed: countOf("failed"),
595
+ },
596
+ };
597
+ }
598
+
599
+ /**
600
+ * Walk an entire organization by chaining bounded passes on the keyset cursor
601
+ * until the ordered stream is exhausted.
602
+ *
603
+ * `maxPasses` is a safety stop, not the termination condition: a walk that hits
604
+ * it reports `drained: false` and a `lastCursor` that resumes it exactly where
605
+ * it stopped. Because the cursor is strictly increasing, this terminates on
606
+ * every organization - including one whose first `limit` subjects are all
607
+ * permanently unresolvable, which is precisely the shape a fixed window could
608
+ * never get past.
609
+ */
610
+ export async function drainOrganizationMembershipBackfill(
611
+ db: Database,
612
+ input: {
613
+ organizationId: string;
614
+ limit: number;
615
+ dryRun: boolean;
616
+ maxPasses?: number;
617
+ afterSubjectId?: string | null;
618
+ /** Optional migration-0300 idempotency key. A non-dry walk settles one
619
+ * durable organization_memberships receipt after the bounded walk. */
620
+ runKey?: string | null;
621
+ },
622
+ ): Promise<OrganizationMembershipBackfillDrainReport> {
623
+ const maxPasses = input.maxPasses ?? 1000;
624
+ if (!Number.isInteger(maxPasses) || maxPasses < 1) {
625
+ throw new Error("Organization membership backfill maxPasses must be a positive integer");
626
+ }
627
+ const runKey = input.runKey ?? null;
628
+ if (runKey !== null && (runKey.trim().length === 0 || runKey.length > 200)) {
629
+ throw new Error("Organization membership backfill runKey must be 1..200 non-blank characters");
630
+ }
631
+ const counts: OrganizationMembershipBackfillCounts = {
632
+ inspected: 0,
633
+ provisioned: 0,
634
+ alreadyAnchored: 0,
635
+ unresolved: 0,
636
+ contended: 0,
637
+ failed: 0,
638
+ };
639
+ const unresolved: OrganizationMembershipBackfillDrainReport["unresolved"] = [];
640
+ const contended: string[] = [];
641
+ const failed: OrganizationMembershipBackfillDrainReport["failed"] = [];
642
+ let cursor = assertCursor(input.afterSubjectId);
643
+ let passes = 0;
644
+ let drained = false;
645
+ while (passes < maxPasses) {
646
+ const report = await runOrganizationMembershipBackfill(db, {
647
+ organizationId: input.organizationId,
648
+ limit: input.limit,
649
+ dryRun: input.dryRun,
650
+ afterSubjectId: cursor,
651
+ });
652
+ passes += 1;
653
+ for (const key of Object.keys(counts) as Array<keyof OrganizationMembershipBackfillCounts>) {
654
+ counts[key] += report.counts[key];
655
+ }
656
+ for (const result of report.results) {
657
+ if (result.outcome === "unresolved") {
658
+ const candidate = report.candidates.find((item) => item.subjectId === result.subjectId);
659
+ if (!candidate) {
660
+ throw new Error("Organization membership backfill result lost its evidence row");
661
+ }
662
+ unresolved.push({
663
+ subjectId: result.subjectId,
664
+ resourceId: candidate.evidenceResourceId,
665
+ reasonCode: result.reasonCode,
666
+ });
667
+ } else if (result.outcome === "contended") {
668
+ contended.push(result.subjectId);
669
+ } else if (result.outcome === "failed") {
670
+ failed.push({ subjectId: result.subjectId, reasonCode: result.reasonCode });
671
+ }
672
+ }
673
+ cursor = report.nextCursor;
674
+ if (cursor === null) {
675
+ drained = true;
676
+ break;
677
+ }
678
+ }
679
+ let receiptId: string | null = null;
680
+ let receiptStatus: "completed" | "failed" | null = null;
681
+ if (!input.dryRun && runKey !== null) {
682
+ receiptStatus =
683
+ assertCursor(input.afterSubjectId) === null &&
684
+ drained &&
685
+ counts.contended === 0 &&
686
+ counts.failed === 0
687
+ ? "completed"
688
+ : "failed";
689
+ receiptId = await withRlsContext(
690
+ db,
691
+ { accountId: input.organizationId, workspaceId: null },
692
+ async (scopedDb) => {
693
+ const [opened] = await rawRows<{ receipt_id: string }>(
694
+ scopedDb,
695
+ sql`select open_tenancy_backfill_receipt(
696
+ ${input.organizationId}, 'organization_memberships', ${runKey}
697
+ ) as receipt_id`,
698
+ );
699
+ if (!opened) throw new Error("Organization membership backfill receipt did not open");
700
+ for (const row of unresolved) {
701
+ await rawRows(
702
+ scopedDb,
703
+ sql`select record_tenancy_backfill_unresolved(
704
+ ${opened.receipt_id}::uuid, ${row.resourceId}::uuid, ${row.reasonCode}
705
+ )`,
706
+ );
707
+ }
708
+ await rawRows(
709
+ scopedDb,
710
+ sql`select complete_tenancy_backfill_receipt(
711
+ ${opened.receipt_id}::uuid,
712
+ ${counts.provisioned}::bigint,
713
+ ${counts.alreadyAnchored + counts.contended + counts.failed}::bigint,
714
+ ${receiptStatus}
715
+ )`,
716
+ );
717
+ return opened.receipt_id;
718
+ },
719
+ );
720
+ }
721
+ return {
722
+ organizationId: input.organizationId,
723
+ dryRun: input.dryRun,
724
+ limit: input.limit,
725
+ passes,
726
+ drained,
727
+ lastCursor: cursor,
728
+ counts,
729
+ unresolved,
730
+ contended,
731
+ failed,
732
+ receiptId,
733
+ receiptStatus,
734
+ };
735
+ }