create-ailk 0.4.0 → 0.6.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 (396) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +12248 -0
  3. package/component-catalog.md +193 -68
  4. package/dist/cli.js +29 -1
  5. package/dist/derive-architecture.js +5 -2
  6. package/dist/lib/api-boot-assertion.d.ts +46 -0
  7. package/dist/lib/api-boot-assertion.js +70 -0
  8. package/dist/lib/extract-module-bundle.d.ts +50 -2
  9. package/dist/lib/extract-module-bundle.js +223 -15
  10. package/dist/lib/fetch-guard.d.ts +28 -0
  11. package/dist/lib/fetch-guard.js +101 -0
  12. package/dist/lib/fetch-module.js +4 -0
  13. package/dist/lib/fetch-template.js +4 -0
  14. package/dist/lib/import-graph.d.ts +29 -0
  15. package/dist/lib/import-graph.js +140 -0
  16. package/dist/module-architecture.d.ts +60 -8
  17. package/dist/module-architecture.js +587 -242
  18. package/dist/parse-args.js +6 -2
  19. package/dist/programmatic.d.ts +10 -0
  20. package/dist/programmatic.js +134 -19
  21. package/dist/site-chrome.d.ts +80 -0
  22. package/dist/site-chrome.js +135 -0
  23. package/dist/surfaces.d.ts +21 -6
  24. package/dist/surfaces.js +209 -36
  25. package/dist/template-manifest.d.ts +88 -3
  26. package/dist/template-manifest.js +281 -29
  27. package/dist/template-names.d.ts +1 -1
  28. package/dist/template-names.js +2 -0
  29. package/package.json +10 -2
  30. package/site-templates/_base/ailk-template.json +6 -0
  31. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
  34. package/site-templates/marketing-site/ailk-template.json +11 -0
  35. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  36. package/site-templates/marketing-site/provenance.json +4 -0
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
  39. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
  41. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
  42. package/site-templates/minimalism-site/ailk-template.json +22 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  44. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  49. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  50. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  51. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  53. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  54. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  55. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  56. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  57. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
  58. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  60. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  65. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  68. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  69. package/site-templates/minimalism-site/provenance.json +4 -0
  70. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  71. package/site-templates/saas-product/ailk-template.json +16 -0
  72. package/site-templates/saas-product/provenance.json +10 -0
  73. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  74. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  75. package/site-templates/simple-service/ailk-template.json +10 -0
  76. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  77. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  78. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  79. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  80. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  81. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  82. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  83. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  84. package/site-templates/simple-service/provenance.json +4 -0
  85. package/templates/.claude/skills/README.md +16 -25
  86. package/templates/.env.example +102 -12
  87. package/templates/.github/workflows/ci.yml +48 -0
  88. package/templates/README.md +3 -1
  89. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  90. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  91. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  92. package/templates/apps/api/.env.example +13 -0
  93. package/templates/apps/api/CLAUDE.md +29 -2
  94. package/templates/apps/api/eslint.config.mjs +69 -0
  95. package/templates/apps/api/jest.config.cjs +3 -0
  96. package/templates/apps/api/package.json +7 -2
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  98. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
  99. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  100. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  101. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  102. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
  103. package/templates/apps/api/src/config/index.ts +62 -0
  104. package/templates/apps/api/src/config/modules.ts +7 -3
  105. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
  106. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  107. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  108. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  109. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  110. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  111. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  112. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  113. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  114. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  115. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  116. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  117. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  118. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  119. package/templates/apps/api/src/lib/site.ts +208 -9
  120. package/templates/apps/api/src/lib/stripe.ts +66 -0
  121. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
  122. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
  123. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  124. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  125. package/templates/apps/api/src/openapi/spec.ts +543 -2
  126. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  127. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  128. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  129. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  130. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  131. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  132. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  133. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  134. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  135. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  136. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  137. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  138. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  139. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  140. package/templates/apps/api/src/routes/content/index.ts +13 -73
  141. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  142. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  143. package/templates/apps/api/src/routes/content/search.ts +129 -0
  144. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  145. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  146. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  147. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  148. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  149. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  150. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  151. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  152. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  153. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  154. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  155. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  156. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  157. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  158. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  159. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  160. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  161. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  162. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  163. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  164. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  165. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  166. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  167. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  168. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  169. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  170. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  171. package/templates/apps/api/src/server.ts +521 -55
  172. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  173. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  174. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  175. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  176. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  177. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  178. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  179. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  180. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  181. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  182. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  183. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  184. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  185. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  186. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  187. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  188. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  189. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  190. package/templates/apps/api/src/services/audit-log.ts +668 -0
  191. package/templates/apps/api/src/services/consent.ts +18 -0
  192. package/templates/apps/api/src/services/default-tenant.ts +300 -0
  193. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  194. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  195. package/templates/apps/api/src/services/entitlements.ts +80 -0
  196. package/templates/apps/api/src/services/lead-promotion.ts +160 -0
  197. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  198. package/templates/apps/api/src/services/leads-inbox.ts +284 -0
  199. package/templates/apps/api/src/services/listing-config.ts +27 -9
  200. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  201. package/templates/apps/api/src/services/project-listings.ts +201 -6
  202. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  203. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  204. package/templates/apps/api/src/services/revalidation.ts +173 -0
  205. package/templates/apps/api/src/services/verified-email.ts +71 -0
  206. package/templates/apps/api/src/vercel-handler.ts +75 -35
  207. package/templates/apps/mcp/.env.example +17 -0
  208. package/templates/apps/mcp/CLAUDE.md +58 -25
  209. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  210. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  211. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  212. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  213. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  214. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  215. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  216. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  217. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  218. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  219. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  220. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  221. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  222. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  223. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  224. package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
  225. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  226. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  227. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  228. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  229. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  230. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  231. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  232. package/templates/apps/mcp/eslint.config.mjs +69 -0
  233. package/templates/apps/mcp/jest.config.cjs +13 -0
  234. package/templates/apps/mcp/package.json +8 -2
  235. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  236. package/templates/apps/mcp/src/config/modules.ts +10 -5
  237. package/templates/apps/mcp/src/http-transport.ts +102 -29
  238. package/templates/apps/mcp/src/index.ts +73 -11
  239. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  240. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  241. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  242. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  243. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  244. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  245. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  246. package/templates/apps/mcp/src/server.ts +61 -37
  247. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  248. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  249. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  250. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  251. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  252. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  253. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  254. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  255. package/templates/apps/mcp/src/tools/index.ts +9 -7
  256. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  257. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  258. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  259. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  260. package/templates/apps/web/.env.example +74 -8
  261. package/templates/apps/web/CLAUDE.md +46 -0
  262. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  263. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  264. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  265. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  266. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  267. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  268. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  269. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  270. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  271. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  272. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  273. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  274. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  275. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  276. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  277. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  278. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  279. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  280. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  281. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
  282. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
  283. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  284. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  285. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  286. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  287. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  288. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  289. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  290. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  291. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  292. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  293. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  294. package/templates/apps/web/app/[locale]/layout.tsx +308 -65
  295. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  296. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  297. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  298. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  299. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  300. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  301. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  302. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  303. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  304. package/templates/apps/web/auth.ts +407 -8
  305. package/templates/apps/web/components/google-analytics.tsx +18 -1
  306. package/templates/apps/web/eslint.config.mjs +69 -0
  307. package/templates/apps/web/jest.config.cjs +15 -19
  308. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  309. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  310. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  311. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
  312. package/templates/apps/web/lib/auth-email.ts +318 -0
  313. package/templates/apps/web/lib/auth-env.ts +85 -0
  314. package/templates/apps/web/lib/auth-routes.ts +55 -0
  315. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  316. package/templates/apps/web/lib/nav-config.ts +27 -7
  317. package/templates/apps/web/lib/server-api.ts +1 -1
  318. package/templates/apps/web/lib/site-chrome.ts +29 -0
  319. package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
  320. package/templates/apps/web/messages/en.json +216 -2
  321. package/templates/apps/web/next.config.mjs +20 -0
  322. package/templates/apps/web/package.json +8 -3
  323. package/templates/apps/web/sentry.client.config.ts +131 -0
  324. package/templates/apps/web/sentry.edge.config.ts +72 -0
  325. package/templates/apps/web/sentry.server.config.ts +72 -0
  326. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  327. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  328. package/templates/apps/web/src/lib/routes.ts +13 -18
  329. package/templates/brand_assets/README.md +24 -0
  330. package/templates/content/en/AboutPage/about.mdx +160 -15
  331. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  332. package/templates/content/en/ContactPage/contact.mdx +90 -13
  333. package/templates/content/en/FAQPage/faq.mdx +62 -22
  334. package/templates/content/en/HomePage/home.mdx +244 -20
  335. package/templates/content/en/LandingPage/blog.mdx +83 -0
  336. package/templates/content/en/LandingPage/features.mdx +103 -0
  337. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  338. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  339. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  340. package/templates/database/CHANGELOG.md +149 -0
  341. package/templates/database/README.md +10 -3
  342. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  343. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  344. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  345. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  346. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  347. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  348. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  349. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  350. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  351. package/templates/database/__tests__/seed.test.ts +1 -1
  352. package/templates/database/__tests__/tenancy.test.ts +141 -0
  353. package/templates/database/config/schema.prisma +79 -2
  354. package/templates/database/content/schema.prisma +87 -11
  355. package/templates/database/eslint.config.mjs +69 -0
  356. package/templates/database/inbox/schema.prisma +452 -9
  357. package/templates/database/jest.config.cjs +5 -3
  358. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  359. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  360. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  361. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  362. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  363. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  364. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  365. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  366. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  367. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  368. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  369. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  370. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  371. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  372. package/templates/database/package.json +7 -3
  373. package/templates/database/seed.ts +1 -1
  374. package/templates/database/tenancy.ts +86 -1
  375. package/templates/database/tsconfig.json +1 -0
  376. package/templates/package.json +1 -14
  377. package/versions.json +52 -0
  378. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  379. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  380. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  381. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  382. package/templates/apps/api/src/routes/content/create.ts +0 -115
  383. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  384. package/templates/apps/api/src/routes/content/update.ts +0 -112
  385. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  386. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  387. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  388. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  389. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  390. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  391. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  392. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  393. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  394. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  395. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
  396. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,1067 @@
1
+ -- #6254 — the tenancy fence keystone. THE ONE schema migration M32 emits
2
+ -- (AC-1): #6256 #6257 #6258 #6259 #6260 #6171 #6314 land their model
3
+ -- changes INTO this file rather than each adding their own (issue #6254
4
+ -- comment, Sam, 2026-09-20; spec docs/specs/6254-the-composite-fk-pattern-
5
+ -- workspacesiteidentity-spec.md D4).
6
+ --
7
+ -- ── What this file does ─────────────────────────────────────────────────────
8
+ -- 1. `WorkspaceSiteIdentity` gains `@@unique([id, workspaceId])` (AC-2) — a
9
+ -- named Postgres unique constraint, redundant today (`id` is already the
10
+ -- PK) but the target a future composite FK can bind to (spec D3).
11
+ -- 2. Four ALREADY-KEYED-but-nullable models — `leads`, `project_listings`,
12
+ -- `flow_definitions`, `flow_sessions` — are retrofitted to NOT NULL
13
+ -- `workspace_id` plus a `CHECK (workspace_id = site_id)` (spec D1b).
14
+ -- 3. #6374 — the fence fan-out. ELEVEN `site_id` tables gain `workspace_id`
15
+ -- and take the same five steps in the same order: `subscribers`,
16
+ -- `callbacks`, `consent_grants`, `appointments`, `deliverable_assets`,
17
+ -- `flow_checkouts`, `waitlist_experiments`, `waitlist_scores`,
18
+ -- `waitlist_followup_sends`, `playbook_instances`, `site_listing_configs`.
19
+ -- 4. #6374 — `listing_csv_sends` has NO `site_id`, so it gains `workspace_id`
20
+ -- backfilled from its parent `project_listings` row, NOT NULL and the
21
+ -- format CHECK, and NO equality CHECK (spec D2, operator 2026-09-20).
22
+ -- 5. #6374 — `site_settings` has NO `site_id` either. It gains `workspace_id`
23
+ -- backfilled to the sentinel workspace — but ONLY after an abort-not-guess
24
+ -- guard (AC-14) refuses any unattributed row whose `key` already embeds a
25
+ -- NON-sentinel workspace id, which the sentinel assignment would misfile —
26
+ -- NOT NULL and the format CHECK, and
27
+ -- its UNIQUE INDEX moves from `(key)` to `(workspace_id, key)` so two
28
+ -- sellers may each hold a `feature_flags` row (spec D3, operator
29
+ -- 2026-09-20). The `key` TEXT is left verbatim.
30
+ -- 6. #6374 — `deliverable_grants` gains NO column and NO row is touched: its
31
+ -- `workspace_id` stays nullable (NULL is the serve path's fail-closed
32
+ -- sentinel). It gains only the NULL-TOLERANT
33
+ -- `CHECK (workspace_id IS NULL OR workspace_id = site_id)`, so a STAMPED
34
+ -- grant can never disagree with its site while a legacy one stays
35
+ -- storable (spec D4).
36
+ --
37
+ -- ── Why a CHECK and not a foreign key (spec §0, §1.3, §1.4) ────────────────
38
+ -- The issue's literal ask — a composite FK from these four models to
39
+ -- `WorkspaceSiteIdentity` — is not buildable against this tree: (F1) the
40
+ -- relation crosses the inbox -> config namespace boundary ADR-0009
41
+ -- invariant #1 forbids, and `tools/lint-architecture.ts`'s
42
+ -- cross-namespace-prisma-relation rule lints it; (F2) `site_id` never holds
43
+ -- a `WorkspaceSiteIdentity.id` (`siteid_<uuidv7>`) — it holds the resolved
44
+ -- WORKSPACE id (`wksp_<uuidv7>`) or the legacy alias `"default"`, so
45
+ -- `references: [id, workspaceId]` would reject 100% of existing rows and
46
+ -- 100% of writes from every current path; (F3) `WorkspaceSiteIdentity` is a
47
+ -- per-workspace config blob written lazily by an upsert, not a site
48
+ -- registry — a required FK parent would make anonymous lead capture (a
49
+ -- never-gate surface) depend on a config row that need not exist.
50
+ --
51
+ -- AC-4's stated GOAL — "Postgres rejects a row whose workspaceId disagrees
52
+ -- with its site's workspace" — is delivered without a FK: since `site_id`
53
+ -- IS the workspace id (F2), the disagreement is exactly
54
+ -- `workspace_id <> site_id`, and an equality CHECK makes it unstorable. This
55
+ -- is strictly TIGHTER than the FK the issue asked for (the FK would admit
56
+ -- any pair present in `workspace_site_identities`; the CHECK admits exactly
57
+ -- one value per row). Disposed by the operator 2026-09-20, spec §10.1 Q1 ->
58
+ -- Option A' (docs/internal/decisions/tenancy-fence-model-classification.md
59
+ -- D1 amendment, same commit).
60
+ --
61
+ -- `DeliverableGrant` (the fifth already-keyed model) is DELIBERATELY NOT
62
+ -- touched here. Its `workspace_id` has no source column to derive from (its
63
+ -- own migration, 20260921120000_deliverable_grant_workspace_key, says so
64
+ -- explicitly) and NULL is the serve path's fail-closed sentinel
65
+ -- (`deliverable-asset-store.ts` `readDeliverableAsset`) — retrofitting it
66
+ -- here would hand a legacy grant an authorization scope no mint granted, on
67
+ -- a bearer-token download path. Carved out of this unit (spec §10.1 Q2 ->
68
+ -- carve out); #6257 fences it at mint grain, where the workspace is known.
69
+ --
70
+ -- ── The CHECK requires `site_id` normalized FIRST (spec D1a, SEC-01) ───────
71
+ -- A bare `CHECK (workspace_id = site_id)` is unsatisfiable for the shipped
72
+ -- default: `site_id = 'default'` is the canonical seeded row, and both
73
+ -- precedent backfills (20260919140000, 20260919120000) already resolve
74
+ -- `'default'` to the sentinel workspace id in `workspace_id` while leaving
75
+ -- `site_id` untouched. So this migration rewrites `site_id = 'default'` to
76
+ -- the same sentinel on every fenced table BEFORE the CHECK lands — the
77
+ -- application-code half of D1a (the write paths that still stamp the raw
78
+ -- alias) is fixed in the same PR (apps/api/src/lib/site.ts,
79
+ -- routes/flows/index.ts, routes/flows/recommender.ts, bin/seed-presets.ts,
80
+ -- services/listing-config.ts, services/flow-engine.ts,
81
+ -- services/project-listings.ts, services/recommender-capture.ts).
82
+ --
83
+ -- ── Ordering (spec D2) — never guess, never delete, abort if unresolvable ──
84
+ -- 1. Normalize site_id ('default' -> sentinel).
85
+ -- 2. Resolve any remaining NULL workspace_id via an INNER JOIN to
86
+ -- "workspaces" only (never an assignment) — a row whose site_id resolves
87
+ -- to nothing is left NULL, never guessed. This is mostly a no-op today:
88
+ -- 20260919140000/20260919120000 already backfilled these four tables:
89
+ -- kept here so this migration proves its OWN correctness re-run against
90
+ -- fixture rows (NFR3, § 8.3) rather than depending on migration order.
91
+ -- 3. Post-assert: nothing lost, nothing guessed, nothing missed — carried
92
+ -- forward from the precedent — PLUS a NEW assertion this migration adds
93
+ -- and the precedent could not: zero rows remain NULL. Unlike the
94
+ -- precedent (which only RAISE NOTICEs on a remaining NULL, because it
95
+ -- never enforces NOT NULL), THIS migration is about to enforce NOT NULL,
96
+ -- so an unresolvable row must abort the transaction rather than pass
97
+ -- through silently — "the same silent data loss AC-7 exists to close"
98
+ -- (spec §0), never a delete, never a guess.
99
+ -- 4. THEN `SET NOT NULL`, then the CHECKs.
100
+ --
101
+ -- ── Re-runnability (NFR3) ───────────────────────────────────────────────────
102
+ -- `ADD COLUMN`/`CREATE INDEX` are not needed here (the columns/indexes
103
+ -- already exist from the precedent migrations). The equality CHECKs use
104
+ -- `DROP CONSTRAINT IF EXISTS` then `ADD CONSTRAINT` — NOT the precedent's
105
+ -- existence-guarded `DO $$` block, which would silently no-op the
106
+ -- TIGHTENING this migration performs on any database that already carries a
107
+ -- same-named constraint (spec D5, SEC-06: a false-green control). The
108
+ -- `WorkspaceSiteIdentity` composite unique is a pure ADD (nothing to
109
+ -- tighten), so it keeps the existence-guarded idiom.
110
+ --
111
+ -- ── Rollback (NFR5) ─────────────────────────────────────────────────────────
112
+ -- Reverses the CONSTRAINTS only, not the tenant attribution:
113
+ -- ALTER TABLE "leads" DROP CONSTRAINT "leads_workspace_site_agree", ALTER COLUMN "workspace_id" DROP NOT NULL;
114
+ -- ALTER TABLE "project_listings" DROP CONSTRAINT "project_listings_workspace_site_agree", ALTER COLUMN "workspace_id" DROP NOT NULL;
115
+ -- ALTER TABLE "flow_definitions" DROP CONSTRAINT "flow_definitions_workspace_site_agree", ALTER COLUMN "workspace_id" DROP NOT NULL;
116
+ -- ALTER TABLE "flow_sessions" DROP CONSTRAINT "flow_sessions_workspace_site_agree", ALTER COLUMN "workspace_id" DROP NOT NULL;
117
+ -- ALTER TABLE "workspace_site_identities" DROP CONSTRAINT "workspace_site_identities_id_workspace_id_key";
118
+ -- #6374's own reversal, same posture — CONSTRAINTS only, never the tenant
119
+ -- attribution: for each of the twelve tables it fences
120
+ -- ALTER TABLE "<table>" DROP CONSTRAINT "<table>_workspace_site_agree",
121
+ -- DROP CONSTRAINT "<table>_workspace_id_format",
122
+ -- ALTER COLUMN "workspace_id" DROP NOT NULL;
123
+ -- (`listing_csv_sends` and `site_settings` have no `_workspace_site_agree`;
124
+ -- `site_settings` additionally needs its old `site_settings_key_key` unique
125
+ -- index recreated, which is only possible while no two workspaces hold the
126
+ -- same key.) `deliverable_grants` reverses by dropping its one new CHECK.
127
+ -- Unlike the four keystone tables, `workspace_id` does NOT predate this
128
+ -- migration on the twelve, so a full DOWN would also DROP COLUMN it — which
129
+ -- DOES discard tenant attribution. Stated so it is not read as a safe revert.
130
+ -- `workspace_id` predates this migration on all four tables (§1.6), and the
131
+ -- `site_id` normalization (D1a) is one-way — a DOWN does not un-normalize
132
+ -- `site_id`, because the pre-normalization alias value is not recoverable
133
+ -- from the row. NOT a full revert; stated so it is not read as one.
134
+
135
+ -- The census the post-assertions compare against. ON COMMIT DROP: `prisma
136
+ -- migrate` runs each migration in one transaction, so this disappears with it.
137
+ CREATE TEMP TABLE "_6254_fence_census" ON COMMIT DROP AS
138
+ SELECT (SELECT count(*) FROM "leads") AS l,
139
+ (SELECT count(*) FROM "project_listings") AS p,
140
+ (SELECT count(*) FROM "flow_definitions") AS d,
141
+ (SELECT count(*) FROM "flow_sessions") AS s;
142
+
143
+ -- ── AC-2 — WorkspaceSiteIdentity composite unique ──────────────────────────
144
+ DO $$ BEGIN
145
+ IF NOT EXISTS (
146
+ SELECT 1 FROM pg_constraint WHERE conname = 'workspace_site_identities_id_workspace_id_key'
147
+ ) THEN
148
+ ALTER TABLE "workspace_site_identities"
149
+ ADD CONSTRAINT "workspace_site_identities_id_workspace_id_key" UNIQUE ("id", "workspace_id");
150
+ END IF;
151
+ END $$;
152
+
153
+ -- ── Step 1 (D1a) — normalize site_id: 'default' -> the sentinel workspace id ─
154
+ -- The sentinel id is a literal because SQL cannot import database/tenancy.ts
155
+ -- (spec §5.3 step 5); database/__tests__/composite-fence.db.test.ts pins the
156
+ -- same literal against the exported DEFAULT_WORKSPACE_ID constant, mirroring
157
+ -- the #6155/#6203 precedents' own technique.
158
+ UPDATE "leads"
159
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
160
+ WHERE "site_id" = 'default';
161
+
162
+ UPDATE "project_listings"
163
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
164
+ WHERE "site_id" = 'default';
165
+
166
+ UPDATE "flow_definitions"
167
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
168
+ WHERE "site_id" = 'default';
169
+
170
+ UPDATE "flow_sessions"
171
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
172
+ WHERE "site_id" = 'default';
173
+
174
+ -- ── Step 2 (D2) — resolve any remaining NULL workspace_id via INNER JOIN ────
175
+ -- site_id is already normalized (step 1), so no CASE/alias branch is needed
176
+ -- here, unlike the precedent migrations this runs after. A row whose site_id
177
+ -- resolves to nothing is simply not touched and stays NULL — never guessed.
178
+ UPDATE "leads" l
179
+ SET "workspace_id" = w."id"
180
+ FROM "workspaces" w
181
+ WHERE l."workspace_id" IS NULL
182
+ AND w."id" = l."site_id";
183
+
184
+ UPDATE "project_listings" p
185
+ SET "workspace_id" = w."id"
186
+ FROM "workspaces" w
187
+ WHERE p."workspace_id" IS NULL
188
+ AND w."id" = p."site_id";
189
+
190
+ UPDATE "flow_definitions" d
191
+ SET "workspace_id" = w."id"
192
+ FROM "workspaces" w
193
+ WHERE d."workspace_id" IS NULL
194
+ AND w."id" = d."site_id";
195
+
196
+ UPDATE "flow_sessions" s
197
+ SET "workspace_id" = w."id"
198
+ FROM "workspaces" w
199
+ WHERE s."workspace_id" IS NULL
200
+ AND w."id" = s."site_id";
201
+
202
+ -- ── Step 3 — POST-ASSERTIONS ─────────────────────────────────────────────────
203
+ DO $$
204
+ DECLARE
205
+ before_l bigint; before_p bigint; before_d bigint; before_s bigint;
206
+ after_l bigint; after_p bigint; after_d bigint; after_s bigint;
207
+ bad bigint;
208
+ BEGIN
209
+ SELECT l, p, d, s INTO before_l, before_p, before_d, before_s FROM "_6254_fence_census";
210
+ SELECT count(*) INTO after_l FROM "leads";
211
+ SELECT count(*) INTO after_p FROM "project_listings";
212
+ SELECT count(*) INTO after_d FROM "flow_definitions";
213
+ SELECT count(*) INTO after_s FROM "flow_sessions";
214
+
215
+ -- 1. Nothing lost: row counts unchanged.
216
+ IF before_l <> after_l OR before_p <> after_p OR before_d <> after_d OR before_s <> after_s THEN
217
+ RAISE EXCEPTION
218
+ '#6254 post-assertion: row count changed across a purely additive migration (leads % -> %, project_listings % -> %, flow_definitions % -> %, flow_sessions % -> %)',
219
+ before_l, after_l, before_p, after_p, before_d, after_d, before_s, after_s;
220
+ END IF;
221
+
222
+ -- 2. Nothing guessed: no row's workspace_id names a workspace absent from "workspaces".
223
+ SELECT count(*) INTO bad FROM "leads" l
224
+ WHERE l."workspace_id" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = l."workspace_id");
225
+ IF bad <> 0 THEN
226
+ RAISE EXCEPTION '#6254 post-assertion: % leads row(s) carry a workspace_id absent from workspaces', bad;
227
+ END IF;
228
+ SELECT count(*) INTO bad FROM "project_listings" p
229
+ WHERE p."workspace_id" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = p."workspace_id");
230
+ IF bad <> 0 THEN
231
+ RAISE EXCEPTION '#6254 post-assertion: % project_listings row(s) carry a workspace_id absent from workspaces', bad;
232
+ END IF;
233
+ SELECT count(*) INTO bad FROM "flow_definitions" d
234
+ WHERE d."workspace_id" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = d."workspace_id");
235
+ IF bad <> 0 THEN
236
+ RAISE EXCEPTION '#6254 post-assertion: % flow_definitions row(s) carry a workspace_id absent from workspaces', bad;
237
+ END IF;
238
+ SELECT count(*) INTO bad FROM "flow_sessions" s
239
+ WHERE s."workspace_id" IS NOT NULL AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = s."workspace_id");
240
+ IF bad <> 0 THEN
241
+ RAISE EXCEPTION '#6254 post-assertion: % flow_sessions row(s) carry a workspace_id absent from workspaces', bad;
242
+ END IF;
243
+
244
+ -- 3. Nothing missed: no row whose (normalized) site_id names a real workspace
245
+ -- came out of the backfill with workspace_id still NULL.
246
+ SELECT count(*) INTO bad FROM "leads" l
247
+ WHERE l."workspace_id" IS NULL AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = l."site_id");
248
+ IF bad <> 0 THEN
249
+ RAISE EXCEPTION '#6254 post-assertion: % leads row(s) had a resolvable site_id but came out of the backfill NULL', bad;
250
+ END IF;
251
+ SELECT count(*) INTO bad FROM "project_listings" p
252
+ WHERE p."workspace_id" IS NULL AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = p."site_id");
253
+ IF bad <> 0 THEN
254
+ RAISE EXCEPTION '#6254 post-assertion: % project_listings row(s) had a resolvable site_id but came out of the backfill NULL', bad;
255
+ END IF;
256
+ SELECT count(*) INTO bad FROM "flow_definitions" d
257
+ WHERE d."workspace_id" IS NULL AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = d."site_id");
258
+ IF bad <> 0 THEN
259
+ RAISE EXCEPTION '#6254 post-assertion: % flow_definitions row(s) had a resolvable site_id but came out of the backfill NULL', bad;
260
+ END IF;
261
+ SELECT count(*) INTO bad FROM "flow_sessions" s
262
+ WHERE s."workspace_id" IS NULL AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = s."site_id");
263
+ IF bad <> 0 THEN
264
+ RAISE EXCEPTION '#6254 post-assertion: % flow_sessions row(s) had a resolvable site_id but came out of the backfill NULL', bad;
265
+ END IF;
266
+
267
+ -- 4. AC-7 / spec D2 point 4 — an unresolvable row must ABORT, never pass
268
+ -- through as a silent NULL: this migration is about to SET NOT NULL, so
269
+ -- (unlike the precedent's NOTICE-only posture) a remaining NULL here
270
+ -- means a row's site_id names no workspace this database holds, and
271
+ -- there is no correct value to write. Never deleted, never guessed.
272
+ SELECT count(*) INTO bad FROM "leads" WHERE "workspace_id" IS NULL;
273
+ IF bad <> 0 THEN
274
+ RAISE EXCEPTION '#6254: % leads row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
275
+ END IF;
276
+ SELECT count(*) INTO bad FROM "project_listings" WHERE "workspace_id" IS NULL;
277
+ IF bad <> 0 THEN
278
+ RAISE EXCEPTION '#6254: % project_listings row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
279
+ END IF;
280
+ SELECT count(*) INTO bad FROM "flow_definitions" WHERE "workspace_id" IS NULL;
281
+ IF bad <> 0 THEN
282
+ RAISE EXCEPTION '#6254: % flow_definitions row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
283
+ END IF;
284
+ SELECT count(*) INTO bad FROM "flow_sessions" WHERE "workspace_id" IS NULL;
285
+ IF bad <> 0 THEN
286
+ RAISE EXCEPTION '#6254: % flow_sessions row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
287
+ END IF;
288
+ END $$;
289
+
290
+ -- ── Step 4 — SET NOT NULL (AC-3, AC-4, AC-7) ────────────────────────────────
291
+ ALTER TABLE "leads" ALTER COLUMN "workspace_id" SET NOT NULL;
292
+ ALTER TABLE "project_listings" ALTER COLUMN "workspace_id" SET NOT NULL;
293
+ ALTER TABLE "flow_definitions" ALTER COLUMN "workspace_id" SET NOT NULL;
294
+ ALTER TABLE "flow_sessions" ALTER COLUMN "workspace_id" SET NOT NULL;
295
+
296
+ -- ── Step 5 — equality CHECKs (AC-3, AC-4, AC-5) — DROP then ADD, not guarded ─
297
+ ALTER TABLE "leads" DROP CONSTRAINT IF EXISTS "leads_workspace_site_agree";
298
+ ALTER TABLE "leads" ADD CONSTRAINT "leads_workspace_site_agree" CHECK ("workspace_id" = "site_id");
299
+
300
+ ALTER TABLE "project_listings" DROP CONSTRAINT IF EXISTS "project_listings_workspace_site_agree";
301
+ ALTER TABLE "project_listings" ADD CONSTRAINT "project_listings_workspace_site_agree" CHECK ("workspace_id" = "site_id");
302
+
303
+ ALTER TABLE "flow_definitions" DROP CONSTRAINT IF EXISTS "flow_definitions_workspace_site_agree";
304
+ ALTER TABLE "flow_definitions" ADD CONSTRAINT "flow_definitions_workspace_site_agree" CHECK ("workspace_id" = "site_id");
305
+
306
+ ALTER TABLE "flow_sessions" DROP CONSTRAINT IF EXISTS "flow_sessions_workspace_site_agree";
307
+ ALTER TABLE "flow_sessions" ADD CONSTRAINT "flow_sessions_workspace_site_agree" CHECK ("workspace_id" = "site_id");
308
+
309
+ -- ════════════════════════════════════════════════════════════════════════════
310
+ -- #6374 — the fence fan-out. THIRTEEN more models join the fence, amended INTO
311
+ -- this file rather than added as a new migration, because #6254 AC-1 makes
312
+ -- this THE ONE schema migration M32 emits. Amending a merged migration is a
313
+ -- silent no-op on a database that already applied it; the operator disposed
314
+ -- the assumption that makes that safe on 2026-09-20 (spec § 1.5 — no
315
+ -- persistent database carries state that must survive; every site is
316
+ -- rebuildable). If that assumption ever stops holding, everything below moves
317
+ -- verbatim into a new migration directory and nothing else changes.
318
+ --
319
+ -- Eleven tables carry `site_id` and take the keystone shape above unchanged,
320
+ -- in the keystone's own five-step order: normalize -> backfill by INNER JOIN
321
+ -- -> four post-assertions -> SET NOT NULL -> the two CHECKs. Two do not carry
322
+ -- `site_id` and take the operator-disposed shapes (spec § 5 D2/D3), and
323
+ -- `deliverable_grants` gains a NULL-TOLERANT equality CHECK only (D4).
324
+ -- ════════════════════════════════════════════════════════════════════════════
325
+
326
+ -- The #6374 census, the twin of the #6254 one above. ON COMMIT DROP for the
327
+ -- same reason: `prisma migrate` runs each migration in one transaction.
328
+ CREATE TEMP TABLE "_6374_fence_census" ON COMMIT DROP AS
329
+ SELECT (SELECT count(*) FROM "subscribers") AS "subscribers"
330
+ , (SELECT count(*) FROM "callbacks") AS "callbacks"
331
+ , (SELECT count(*) FROM "consent_grants") AS "consent_grants"
332
+ , (SELECT count(*) FROM "appointments") AS "appointments"
333
+ , (SELECT count(*) FROM "deliverable_assets") AS "deliverable_assets"
334
+ , (SELECT count(*) FROM "flow_checkouts") AS "flow_checkouts"
335
+ , (SELECT count(*) FROM "waitlist_experiments") AS "waitlist_experiments"
336
+ , (SELECT count(*) FROM "waitlist_scores") AS "waitlist_scores"
337
+ , (SELECT count(*) FROM "waitlist_followup_sends") AS "waitlist_followup_sends"
338
+ , (SELECT count(*) FROM "playbook_instances") AS "playbook_instances"
339
+ , (SELECT count(*) FROM "site_listing_configs") AS "site_listing_configs"
340
+ ;
341
+
342
+ -- ── Step 1 (D1 step 1) — the column and its index ─────────────────────────
343
+ -- Unlike the four keystone tables, these eleven have no `workspace_id`
344
+ -- column yet, so it is ADDED nullable here and tightened in step 4. ONE
345
+ -- index per table and nothing query-shaped (D6): a read-side unit that turns
346
+ -- out to need a composite gets a post-M32 migration, filed with the query
347
+ -- that justifies it.
348
+ ALTER TABLE "subscribers" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
349
+ CREATE INDEX IF NOT EXISTS "subscribers_workspace_id_idx" ON "subscribers" ("workspace_id");
350
+ ALTER TABLE "callbacks" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
351
+ CREATE INDEX IF NOT EXISTS "callbacks_workspace_id_idx" ON "callbacks" ("workspace_id");
352
+ ALTER TABLE "consent_grants" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
353
+ CREATE INDEX IF NOT EXISTS "consent_grants_workspace_id_idx" ON "consent_grants" ("workspace_id");
354
+ ALTER TABLE "appointments" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
355
+ CREATE INDEX IF NOT EXISTS "appointments_workspace_id_idx" ON "appointments" ("workspace_id");
356
+ ALTER TABLE "deliverable_assets" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
357
+ CREATE INDEX IF NOT EXISTS "deliverable_assets_workspace_id_idx" ON "deliverable_assets" ("workspace_id");
358
+ ALTER TABLE "flow_checkouts" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
359
+ CREATE INDEX IF NOT EXISTS "flow_checkouts_workspace_id_idx" ON "flow_checkouts" ("workspace_id");
360
+ ALTER TABLE "waitlist_experiments" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
361
+ CREATE INDEX IF NOT EXISTS "waitlist_experiments_workspace_id_idx" ON "waitlist_experiments" ("workspace_id");
362
+ ALTER TABLE "waitlist_scores" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
363
+ CREATE INDEX IF NOT EXISTS "waitlist_scores_workspace_id_idx" ON "waitlist_scores" ("workspace_id");
364
+ ALTER TABLE "waitlist_followup_sends" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
365
+ CREATE INDEX IF NOT EXISTS "waitlist_followup_sends_workspace_id_idx" ON "waitlist_followup_sends" ("workspace_id");
366
+ ALTER TABLE "playbook_instances" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
367
+ CREATE INDEX IF NOT EXISTS "playbook_instances_workspace_id_idx" ON "playbook_instances" ("workspace_id");
368
+ ALTER TABLE "site_listing_configs" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
369
+ CREATE INDEX IF NOT EXISTS "site_listing_configs_workspace_id_idx" ON "site_listing_configs" ("workspace_id");
370
+
371
+ -- ── Step 2 (D1 step 2 / keystone D1a) — normalize site_id -> the sentinel ──
372
+ -- The same literal, and the same one-way rewrite, the four keystone tables
373
+ -- above already took. database/__tests__/composite-fence.db.test.ts pins
374
+ -- EVERY `wksp_` literal in this file against the exported
375
+ -- DEFAULT_WORKSPACE_ID, so these eleven cannot drift from it either.
376
+ UPDATE "subscribers"
377
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
378
+ WHERE "site_id" = 'default';
379
+
380
+ UPDATE "callbacks"
381
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
382
+ WHERE "site_id" = 'default';
383
+
384
+ UPDATE "consent_grants"
385
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
386
+ WHERE "site_id" = 'default';
387
+
388
+ UPDATE "appointments"
389
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
390
+ WHERE "site_id" = 'default';
391
+
392
+ UPDATE "deliverable_assets"
393
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
394
+ WHERE "site_id" = 'default';
395
+
396
+ UPDATE "flow_checkouts"
397
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
398
+ WHERE "site_id" = 'default';
399
+
400
+ UPDATE "waitlist_experiments"
401
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
402
+ WHERE "site_id" = 'default';
403
+
404
+ UPDATE "waitlist_scores"
405
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
406
+ WHERE "site_id" = 'default';
407
+
408
+ UPDATE "waitlist_followup_sends"
409
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
410
+ WHERE "site_id" = 'default';
411
+
412
+ UPDATE "playbook_instances"
413
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
414
+ WHERE "site_id" = 'default';
415
+
416
+ UPDATE "site_listing_configs"
417
+ SET "site_id" = 'wksp_00000000-0000-7000-8000-000000000001'
418
+ WHERE "site_id" = 'default';
419
+
420
+ -- ── Step 3 (D1 step 3 / keystone D2) — backfill by INNER JOIN, never by assignment ─
421
+ -- site_id is already normalized (step 1), so no CASE/alias branch is needed.
422
+ -- A row whose site_id resolves to nothing is simply not touched and stays
423
+ -- NULL — never guessed. Step 3's assertion 4 then aborts on it.
424
+ UPDATE "subscribers" x
425
+ SET "workspace_id" = w."id"
426
+ FROM "workspaces" w
427
+ WHERE x."workspace_id" IS NULL
428
+ AND w."id" = x."site_id";
429
+
430
+ UPDATE "callbacks" x
431
+ SET "workspace_id" = w."id"
432
+ FROM "workspaces" w
433
+ WHERE x."workspace_id" IS NULL
434
+ AND w."id" = x."site_id";
435
+
436
+ UPDATE "consent_grants" x
437
+ SET "workspace_id" = w."id"
438
+ FROM "workspaces" w
439
+ WHERE x."workspace_id" IS NULL
440
+ AND w."id" = x."site_id";
441
+
442
+ UPDATE "appointments" x
443
+ SET "workspace_id" = w."id"
444
+ FROM "workspaces" w
445
+ WHERE x."workspace_id" IS NULL
446
+ AND w."id" = x."site_id";
447
+
448
+ UPDATE "deliverable_assets" x
449
+ SET "workspace_id" = w."id"
450
+ FROM "workspaces" w
451
+ WHERE x."workspace_id" IS NULL
452
+ AND w."id" = x."site_id";
453
+
454
+ UPDATE "flow_checkouts" x
455
+ SET "workspace_id" = w."id"
456
+ FROM "workspaces" w
457
+ WHERE x."workspace_id" IS NULL
458
+ AND w."id" = x."site_id";
459
+
460
+ UPDATE "waitlist_experiments" x
461
+ SET "workspace_id" = w."id"
462
+ FROM "workspaces" w
463
+ WHERE x."workspace_id" IS NULL
464
+ AND w."id" = x."site_id";
465
+
466
+ UPDATE "waitlist_scores" x
467
+ SET "workspace_id" = w."id"
468
+ FROM "workspaces" w
469
+ WHERE x."workspace_id" IS NULL
470
+ AND w."id" = x."site_id";
471
+
472
+ UPDATE "waitlist_followup_sends" x
473
+ SET "workspace_id" = w."id"
474
+ FROM "workspaces" w
475
+ WHERE x."workspace_id" IS NULL
476
+ AND w."id" = x."site_id";
477
+
478
+ UPDATE "playbook_instances" x
479
+ SET "workspace_id" = w."id"
480
+ FROM "workspaces" w
481
+ WHERE x."workspace_id" IS NULL
482
+ AND w."id" = x."site_id";
483
+
484
+ UPDATE "site_listing_configs" x
485
+ SET "workspace_id" = w."id"
486
+ FROM "workspaces" w
487
+ WHERE x."workspace_id" IS NULL
488
+ AND w."id" = x."site_id";
489
+
490
+ -- ── Step 4 (D1 step 4) — POST-ASSERTIONS, four per table ──────────────────
491
+ -- Nothing lost (row census unchanged), nothing guessed (no workspace_id
492
+ -- absent from "workspaces"), nothing missed (no resolvable site_id left
493
+ -- NULL), and ABORT on any remaining NULL — this migration is about to
494
+ -- SET NOT NULL, so an unresolvable row has no correct value to write. Never
495
+ -- deleted, never guessed (NFR3).
496
+ DO $$
497
+ DECLARE
498
+ before_n bigint; after_n bigint; bad bigint;
499
+ BEGIN
500
+ -- 1. Nothing lost — "subscribers"
501
+ SELECT "subscribers" INTO before_n FROM "_6374_fence_census";
502
+ SELECT count(*) INTO after_n FROM "subscribers";
503
+ IF before_n <> after_n THEN
504
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (subscribers % -> %)', before_n, after_n;
505
+ END IF;
506
+
507
+ -- 2. Nothing guessed — "subscribers"
508
+ SELECT count(*) INTO bad FROM "subscribers" x
509
+ WHERE x."workspace_id" IS NOT NULL
510
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
511
+ IF bad <> 0 THEN
512
+ RAISE EXCEPTION '#6374 post-assertion: % subscribers row(s) carry a workspace_id absent from workspaces', bad;
513
+ END IF;
514
+
515
+ -- 3. Nothing missed — "subscribers"
516
+ SELECT count(*) INTO bad FROM "subscribers" x
517
+ WHERE x."workspace_id" IS NULL
518
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
519
+ IF bad <> 0 THEN
520
+ RAISE EXCEPTION '#6374 post-assertion: % subscribers row(s) had a resolvable site_id but came out of the backfill NULL', bad;
521
+ END IF;
522
+
523
+ -- 4. Abort on any remaining NULL — "subscribers"
524
+ SELECT count(*) INTO bad FROM "subscribers" WHERE "workspace_id" IS NULL;
525
+ IF bad <> 0 THEN
526
+ RAISE EXCEPTION '#6374: % subscribers row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
527
+ END IF;
528
+
529
+ -- 1. Nothing lost — "callbacks"
530
+ SELECT "callbacks" INTO before_n FROM "_6374_fence_census";
531
+ SELECT count(*) INTO after_n FROM "callbacks";
532
+ IF before_n <> after_n THEN
533
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (callbacks % -> %)', before_n, after_n;
534
+ END IF;
535
+
536
+ -- 2. Nothing guessed — "callbacks"
537
+ SELECT count(*) INTO bad FROM "callbacks" x
538
+ WHERE x."workspace_id" IS NOT NULL
539
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
540
+ IF bad <> 0 THEN
541
+ RAISE EXCEPTION '#6374 post-assertion: % callbacks row(s) carry a workspace_id absent from workspaces', bad;
542
+ END IF;
543
+
544
+ -- 3. Nothing missed — "callbacks"
545
+ SELECT count(*) INTO bad FROM "callbacks" x
546
+ WHERE x."workspace_id" IS NULL
547
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
548
+ IF bad <> 0 THEN
549
+ RAISE EXCEPTION '#6374 post-assertion: % callbacks row(s) had a resolvable site_id but came out of the backfill NULL', bad;
550
+ END IF;
551
+
552
+ -- 4. Abort on any remaining NULL — "callbacks"
553
+ SELECT count(*) INTO bad FROM "callbacks" WHERE "workspace_id" IS NULL;
554
+ IF bad <> 0 THEN
555
+ RAISE EXCEPTION '#6374: % callbacks row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
556
+ END IF;
557
+
558
+ -- 1. Nothing lost — "consent_grants"
559
+ SELECT "consent_grants" INTO before_n FROM "_6374_fence_census";
560
+ SELECT count(*) INTO after_n FROM "consent_grants";
561
+ IF before_n <> after_n THEN
562
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (consent_grants % -> %)', before_n, after_n;
563
+ END IF;
564
+
565
+ -- 2. Nothing guessed — "consent_grants"
566
+ SELECT count(*) INTO bad FROM "consent_grants" x
567
+ WHERE x."workspace_id" IS NOT NULL
568
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
569
+ IF bad <> 0 THEN
570
+ RAISE EXCEPTION '#6374 post-assertion: % consent_grants row(s) carry a workspace_id absent from workspaces', bad;
571
+ END IF;
572
+
573
+ -- 3. Nothing missed — "consent_grants"
574
+ SELECT count(*) INTO bad FROM "consent_grants" x
575
+ WHERE x."workspace_id" IS NULL
576
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
577
+ IF bad <> 0 THEN
578
+ RAISE EXCEPTION '#6374 post-assertion: % consent_grants row(s) had a resolvable site_id but came out of the backfill NULL', bad;
579
+ END IF;
580
+
581
+ -- 4. Abort on any remaining NULL — "consent_grants"
582
+ SELECT count(*) INTO bad FROM "consent_grants" WHERE "workspace_id" IS NULL;
583
+ IF bad <> 0 THEN
584
+ RAISE EXCEPTION '#6374: % consent_grants row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
585
+ END IF;
586
+
587
+ -- 1. Nothing lost — "appointments"
588
+ SELECT "appointments" INTO before_n FROM "_6374_fence_census";
589
+ SELECT count(*) INTO after_n FROM "appointments";
590
+ IF before_n <> after_n THEN
591
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (appointments % -> %)', before_n, after_n;
592
+ END IF;
593
+
594
+ -- 2. Nothing guessed — "appointments"
595
+ SELECT count(*) INTO bad FROM "appointments" x
596
+ WHERE x."workspace_id" IS NOT NULL
597
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
598
+ IF bad <> 0 THEN
599
+ RAISE EXCEPTION '#6374 post-assertion: % appointments row(s) carry a workspace_id absent from workspaces', bad;
600
+ END IF;
601
+
602
+ -- 3. Nothing missed — "appointments"
603
+ SELECT count(*) INTO bad FROM "appointments" x
604
+ WHERE x."workspace_id" IS NULL
605
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
606
+ IF bad <> 0 THEN
607
+ RAISE EXCEPTION '#6374 post-assertion: % appointments row(s) had a resolvable site_id but came out of the backfill NULL', bad;
608
+ END IF;
609
+
610
+ -- 4. Abort on any remaining NULL — "appointments"
611
+ SELECT count(*) INTO bad FROM "appointments" WHERE "workspace_id" IS NULL;
612
+ IF bad <> 0 THEN
613
+ RAISE EXCEPTION '#6374: % appointments row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
614
+ END IF;
615
+
616
+ -- 1. Nothing lost — "deliverable_assets"
617
+ SELECT "deliverable_assets" INTO before_n FROM "_6374_fence_census";
618
+ SELECT count(*) INTO after_n FROM "deliverable_assets";
619
+ IF before_n <> after_n THEN
620
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (deliverable_assets % -> %)', before_n, after_n;
621
+ END IF;
622
+
623
+ -- 2. Nothing guessed — "deliverable_assets"
624
+ SELECT count(*) INTO bad FROM "deliverable_assets" x
625
+ WHERE x."workspace_id" IS NOT NULL
626
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
627
+ IF bad <> 0 THEN
628
+ RAISE EXCEPTION '#6374 post-assertion: % deliverable_assets row(s) carry a workspace_id absent from workspaces', bad;
629
+ END IF;
630
+
631
+ -- 3. Nothing missed — "deliverable_assets"
632
+ SELECT count(*) INTO bad FROM "deliverable_assets" x
633
+ WHERE x."workspace_id" IS NULL
634
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
635
+ IF bad <> 0 THEN
636
+ RAISE EXCEPTION '#6374 post-assertion: % deliverable_assets row(s) had a resolvable site_id but came out of the backfill NULL', bad;
637
+ END IF;
638
+
639
+ -- 4. Abort on any remaining NULL — "deliverable_assets"
640
+ SELECT count(*) INTO bad FROM "deliverable_assets" WHERE "workspace_id" IS NULL;
641
+ IF bad <> 0 THEN
642
+ RAISE EXCEPTION '#6374: % deliverable_assets row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
643
+ END IF;
644
+
645
+ -- 1. Nothing lost — "flow_checkouts"
646
+ SELECT "flow_checkouts" INTO before_n FROM "_6374_fence_census";
647
+ SELECT count(*) INTO after_n FROM "flow_checkouts";
648
+ IF before_n <> after_n THEN
649
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (flow_checkouts % -> %)', before_n, after_n;
650
+ END IF;
651
+
652
+ -- 2. Nothing guessed — "flow_checkouts"
653
+ SELECT count(*) INTO bad FROM "flow_checkouts" x
654
+ WHERE x."workspace_id" IS NOT NULL
655
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
656
+ IF bad <> 0 THEN
657
+ RAISE EXCEPTION '#6374 post-assertion: % flow_checkouts row(s) carry a workspace_id absent from workspaces', bad;
658
+ END IF;
659
+
660
+ -- 3. Nothing missed — "flow_checkouts"
661
+ SELECT count(*) INTO bad FROM "flow_checkouts" x
662
+ WHERE x."workspace_id" IS NULL
663
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
664
+ IF bad <> 0 THEN
665
+ RAISE EXCEPTION '#6374 post-assertion: % flow_checkouts row(s) had a resolvable site_id but came out of the backfill NULL', bad;
666
+ END IF;
667
+
668
+ -- 4. Abort on any remaining NULL — "flow_checkouts"
669
+ SELECT count(*) INTO bad FROM "flow_checkouts" WHERE "workspace_id" IS NULL;
670
+ IF bad <> 0 THEN
671
+ RAISE EXCEPTION '#6374: % flow_checkouts row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
672
+ END IF;
673
+
674
+ -- 1. Nothing lost — "waitlist_experiments"
675
+ SELECT "waitlist_experiments" INTO before_n FROM "_6374_fence_census";
676
+ SELECT count(*) INTO after_n FROM "waitlist_experiments";
677
+ IF before_n <> after_n THEN
678
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (waitlist_experiments % -> %)', before_n, after_n;
679
+ END IF;
680
+
681
+ -- 2. Nothing guessed — "waitlist_experiments"
682
+ SELECT count(*) INTO bad FROM "waitlist_experiments" x
683
+ WHERE x."workspace_id" IS NOT NULL
684
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
685
+ IF bad <> 0 THEN
686
+ RAISE EXCEPTION '#6374 post-assertion: % waitlist_experiments row(s) carry a workspace_id absent from workspaces', bad;
687
+ END IF;
688
+
689
+ -- 3. Nothing missed — "waitlist_experiments"
690
+ SELECT count(*) INTO bad FROM "waitlist_experiments" x
691
+ WHERE x."workspace_id" IS NULL
692
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
693
+ IF bad <> 0 THEN
694
+ RAISE EXCEPTION '#6374 post-assertion: % waitlist_experiments row(s) had a resolvable site_id but came out of the backfill NULL', bad;
695
+ END IF;
696
+
697
+ -- 4. Abort on any remaining NULL — "waitlist_experiments"
698
+ SELECT count(*) INTO bad FROM "waitlist_experiments" WHERE "workspace_id" IS NULL;
699
+ IF bad <> 0 THEN
700
+ RAISE EXCEPTION '#6374: % waitlist_experiments row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
701
+ END IF;
702
+
703
+ -- 1. Nothing lost — "waitlist_scores"
704
+ SELECT "waitlist_scores" INTO before_n FROM "_6374_fence_census";
705
+ SELECT count(*) INTO after_n FROM "waitlist_scores";
706
+ IF before_n <> after_n THEN
707
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (waitlist_scores % -> %)', before_n, after_n;
708
+ END IF;
709
+
710
+ -- 2. Nothing guessed — "waitlist_scores"
711
+ SELECT count(*) INTO bad FROM "waitlist_scores" x
712
+ WHERE x."workspace_id" IS NOT NULL
713
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
714
+ IF bad <> 0 THEN
715
+ RAISE EXCEPTION '#6374 post-assertion: % waitlist_scores row(s) carry a workspace_id absent from workspaces', bad;
716
+ END IF;
717
+
718
+ -- 3. Nothing missed — "waitlist_scores"
719
+ SELECT count(*) INTO bad FROM "waitlist_scores" x
720
+ WHERE x."workspace_id" IS NULL
721
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
722
+ IF bad <> 0 THEN
723
+ RAISE EXCEPTION '#6374 post-assertion: % waitlist_scores row(s) had a resolvable site_id but came out of the backfill NULL', bad;
724
+ END IF;
725
+
726
+ -- 4. Abort on any remaining NULL — "waitlist_scores"
727
+ SELECT count(*) INTO bad FROM "waitlist_scores" WHERE "workspace_id" IS NULL;
728
+ IF bad <> 0 THEN
729
+ RAISE EXCEPTION '#6374: % waitlist_scores row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
730
+ END IF;
731
+
732
+ -- 1. Nothing lost — "waitlist_followup_sends"
733
+ SELECT "waitlist_followup_sends" INTO before_n FROM "_6374_fence_census";
734
+ SELECT count(*) INTO after_n FROM "waitlist_followup_sends";
735
+ IF before_n <> after_n THEN
736
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (waitlist_followup_sends % -> %)', before_n, after_n;
737
+ END IF;
738
+
739
+ -- 2. Nothing guessed — "waitlist_followup_sends"
740
+ SELECT count(*) INTO bad FROM "waitlist_followup_sends" x
741
+ WHERE x."workspace_id" IS NOT NULL
742
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
743
+ IF bad <> 0 THEN
744
+ RAISE EXCEPTION '#6374 post-assertion: % waitlist_followup_sends row(s) carry a workspace_id absent from workspaces', bad;
745
+ END IF;
746
+
747
+ -- 3. Nothing missed — "waitlist_followup_sends"
748
+ SELECT count(*) INTO bad FROM "waitlist_followup_sends" x
749
+ WHERE x."workspace_id" IS NULL
750
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
751
+ IF bad <> 0 THEN
752
+ RAISE EXCEPTION '#6374 post-assertion: % waitlist_followup_sends row(s) had a resolvable site_id but came out of the backfill NULL', bad;
753
+ END IF;
754
+
755
+ -- 4. Abort on any remaining NULL — "waitlist_followup_sends"
756
+ SELECT count(*) INTO bad FROM "waitlist_followup_sends" WHERE "workspace_id" IS NULL;
757
+ IF bad <> 0 THEN
758
+ RAISE EXCEPTION '#6374: % waitlist_followup_sends row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
759
+ END IF;
760
+
761
+ -- 1. Nothing lost — "playbook_instances"
762
+ SELECT "playbook_instances" INTO before_n FROM "_6374_fence_census";
763
+ SELECT count(*) INTO after_n FROM "playbook_instances";
764
+ IF before_n <> after_n THEN
765
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (playbook_instances % -> %)', before_n, after_n;
766
+ END IF;
767
+
768
+ -- 2. Nothing guessed — "playbook_instances"
769
+ SELECT count(*) INTO bad FROM "playbook_instances" x
770
+ WHERE x."workspace_id" IS NOT NULL
771
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
772
+ IF bad <> 0 THEN
773
+ RAISE EXCEPTION '#6374 post-assertion: % playbook_instances row(s) carry a workspace_id absent from workspaces', bad;
774
+ END IF;
775
+
776
+ -- 3. Nothing missed — "playbook_instances"
777
+ SELECT count(*) INTO bad FROM "playbook_instances" x
778
+ WHERE x."workspace_id" IS NULL
779
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
780
+ IF bad <> 0 THEN
781
+ RAISE EXCEPTION '#6374 post-assertion: % playbook_instances row(s) had a resolvable site_id but came out of the backfill NULL', bad;
782
+ END IF;
783
+
784
+ -- 4. Abort on any remaining NULL — "playbook_instances"
785
+ SELECT count(*) INTO bad FROM "playbook_instances" WHERE "workspace_id" IS NULL;
786
+ IF bad <> 0 THEN
787
+ RAISE EXCEPTION '#6374: % playbook_instances row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
788
+ END IF;
789
+
790
+ -- 1. Nothing lost — "site_listing_configs"
791
+ SELECT "site_listing_configs" INTO before_n FROM "_6374_fence_census";
792
+ SELECT count(*) INTO after_n FROM "site_listing_configs";
793
+ IF before_n <> after_n THEN
794
+ RAISE EXCEPTION '#6374 post-assertion: row count changed across a purely additive migration (site_listing_configs % -> %)', before_n, after_n;
795
+ END IF;
796
+
797
+ -- 2. Nothing guessed — "site_listing_configs"
798
+ SELECT count(*) INTO bad FROM "site_listing_configs" x
799
+ WHERE x."workspace_id" IS NOT NULL
800
+ AND NOT EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."workspace_id");
801
+ IF bad <> 0 THEN
802
+ RAISE EXCEPTION '#6374 post-assertion: % site_listing_configs row(s) carry a workspace_id absent from workspaces', bad;
803
+ END IF;
804
+
805
+ -- 3. Nothing missed — "site_listing_configs"
806
+ SELECT count(*) INTO bad FROM "site_listing_configs" x
807
+ WHERE x."workspace_id" IS NULL
808
+ AND EXISTS (SELECT 1 FROM "workspaces" w WHERE w."id" = x."site_id");
809
+ IF bad <> 0 THEN
810
+ RAISE EXCEPTION '#6374 post-assertion: % site_listing_configs row(s) had a resolvable site_id but came out of the backfill NULL', bad;
811
+ END IF;
812
+
813
+ -- 4. Abort on any remaining NULL — "site_listing_configs"
814
+ SELECT count(*) INTO bad FROM "site_listing_configs" WHERE "workspace_id" IS NULL;
815
+ IF bad <> 0 THEN
816
+ RAISE EXCEPTION '#6374: % site_listing_configs row(s) have a site_id that resolves to no workspace this database holds — resolve or remove them before re-running this migration', bad;
817
+ END IF;
818
+
819
+ END $$;
820
+
821
+ -- ── Step 5a (D1 step 5) — SET NOT NULL (spec FR1, AC-1) ───────────────────
822
+ ALTER TABLE "subscribers" ALTER COLUMN "workspace_id" SET NOT NULL;
823
+ ALTER TABLE "callbacks" ALTER COLUMN "workspace_id" SET NOT NULL;
824
+ ALTER TABLE "consent_grants" ALTER COLUMN "workspace_id" SET NOT NULL;
825
+ ALTER TABLE "appointments" ALTER COLUMN "workspace_id" SET NOT NULL;
826
+ ALTER TABLE "deliverable_assets" ALTER COLUMN "workspace_id" SET NOT NULL;
827
+ ALTER TABLE "flow_checkouts" ALTER COLUMN "workspace_id" SET NOT NULL;
828
+ ALTER TABLE "waitlist_experiments" ALTER COLUMN "workspace_id" SET NOT NULL;
829
+ ALTER TABLE "waitlist_scores" ALTER COLUMN "workspace_id" SET NOT NULL;
830
+ ALTER TABLE "waitlist_followup_sends" ALTER COLUMN "workspace_id" SET NOT NULL;
831
+ ALTER TABLE "playbook_instances" ALTER COLUMN "workspace_id" SET NOT NULL;
832
+ ALTER TABLE "site_listing_configs" ALTER COLUMN "workspace_id" SET NOT NULL;
833
+
834
+ -- ── Step 5b (D1 step 5) — the two CHECKs, DROP then ADD, never the guarded DO block ─
835
+ -- keystone D5 / SEC-06: an existence guard would silently no-op a TIGHTENING
836
+ -- on any database that already carries a same-named constraint — a
837
+ -- false-green control. The format CHECK carries NO `IS NULL OR` arm (the
838
+ -- precedent's did, because the precedent never enforced NOT NULL; step 4
839
+ -- just did).
840
+ ALTER TABLE "subscribers" DROP CONSTRAINT IF EXISTS "subscribers_workspace_id_format";
841
+ ALTER TABLE "subscribers" ADD CONSTRAINT "subscribers_workspace_id_format"
842
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
843
+ ALTER TABLE "subscribers" DROP CONSTRAINT IF EXISTS "subscribers_workspace_site_agree";
844
+ ALTER TABLE "subscribers" ADD CONSTRAINT "subscribers_workspace_site_agree"
845
+ CHECK ("workspace_id" = "site_id");
846
+
847
+ ALTER TABLE "callbacks" DROP CONSTRAINT IF EXISTS "callbacks_workspace_id_format";
848
+ ALTER TABLE "callbacks" ADD CONSTRAINT "callbacks_workspace_id_format"
849
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
850
+ ALTER TABLE "callbacks" DROP CONSTRAINT IF EXISTS "callbacks_workspace_site_agree";
851
+ ALTER TABLE "callbacks" ADD CONSTRAINT "callbacks_workspace_site_agree"
852
+ CHECK ("workspace_id" = "site_id");
853
+
854
+ ALTER TABLE "consent_grants" DROP CONSTRAINT IF EXISTS "consent_grants_workspace_id_format";
855
+ ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_workspace_id_format"
856
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
857
+ ALTER TABLE "consent_grants" DROP CONSTRAINT IF EXISTS "consent_grants_workspace_site_agree";
858
+ ALTER TABLE "consent_grants" ADD CONSTRAINT "consent_grants_workspace_site_agree"
859
+ CHECK ("workspace_id" = "site_id");
860
+
861
+ ALTER TABLE "appointments" DROP CONSTRAINT IF EXISTS "appointments_workspace_id_format";
862
+ ALTER TABLE "appointments" ADD CONSTRAINT "appointments_workspace_id_format"
863
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
864
+ ALTER TABLE "appointments" DROP CONSTRAINT IF EXISTS "appointments_workspace_site_agree";
865
+ ALTER TABLE "appointments" ADD CONSTRAINT "appointments_workspace_site_agree"
866
+ CHECK ("workspace_id" = "site_id");
867
+
868
+ ALTER TABLE "deliverable_assets" DROP CONSTRAINT IF EXISTS "deliverable_assets_workspace_id_format";
869
+ ALTER TABLE "deliverable_assets" ADD CONSTRAINT "deliverable_assets_workspace_id_format"
870
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
871
+ ALTER TABLE "deliverable_assets" DROP CONSTRAINT IF EXISTS "deliverable_assets_workspace_site_agree";
872
+ ALTER TABLE "deliverable_assets" ADD CONSTRAINT "deliverable_assets_workspace_site_agree"
873
+ CHECK ("workspace_id" = "site_id");
874
+
875
+ ALTER TABLE "flow_checkouts" DROP CONSTRAINT IF EXISTS "flow_checkouts_workspace_id_format";
876
+ ALTER TABLE "flow_checkouts" ADD CONSTRAINT "flow_checkouts_workspace_id_format"
877
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
878
+ ALTER TABLE "flow_checkouts" DROP CONSTRAINT IF EXISTS "flow_checkouts_workspace_site_agree";
879
+ ALTER TABLE "flow_checkouts" ADD CONSTRAINT "flow_checkouts_workspace_site_agree"
880
+ CHECK ("workspace_id" = "site_id");
881
+
882
+ ALTER TABLE "waitlist_experiments" DROP CONSTRAINT IF EXISTS "waitlist_experiments_workspace_id_format";
883
+ ALTER TABLE "waitlist_experiments" ADD CONSTRAINT "waitlist_experiments_workspace_id_format"
884
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
885
+ ALTER TABLE "waitlist_experiments" DROP CONSTRAINT IF EXISTS "waitlist_experiments_workspace_site_agree";
886
+ ALTER TABLE "waitlist_experiments" ADD CONSTRAINT "waitlist_experiments_workspace_site_agree"
887
+ CHECK ("workspace_id" = "site_id");
888
+
889
+ ALTER TABLE "waitlist_scores" DROP CONSTRAINT IF EXISTS "waitlist_scores_workspace_id_format";
890
+ ALTER TABLE "waitlist_scores" ADD CONSTRAINT "waitlist_scores_workspace_id_format"
891
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
892
+ ALTER TABLE "waitlist_scores" DROP CONSTRAINT IF EXISTS "waitlist_scores_workspace_site_agree";
893
+ ALTER TABLE "waitlist_scores" ADD CONSTRAINT "waitlist_scores_workspace_site_agree"
894
+ CHECK ("workspace_id" = "site_id");
895
+
896
+ ALTER TABLE "waitlist_followup_sends" DROP CONSTRAINT IF EXISTS "waitlist_followup_sends_workspace_id_format";
897
+ ALTER TABLE "waitlist_followup_sends" ADD CONSTRAINT "waitlist_followup_sends_workspace_id_format"
898
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
899
+ ALTER TABLE "waitlist_followup_sends" DROP CONSTRAINT IF EXISTS "waitlist_followup_sends_workspace_site_agree";
900
+ ALTER TABLE "waitlist_followup_sends" ADD CONSTRAINT "waitlist_followup_sends_workspace_site_agree"
901
+ CHECK ("workspace_id" = "site_id");
902
+
903
+ ALTER TABLE "playbook_instances" DROP CONSTRAINT IF EXISTS "playbook_instances_workspace_id_format";
904
+ ALTER TABLE "playbook_instances" ADD CONSTRAINT "playbook_instances_workspace_id_format"
905
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
906
+ ALTER TABLE "playbook_instances" DROP CONSTRAINT IF EXISTS "playbook_instances_workspace_site_agree";
907
+ ALTER TABLE "playbook_instances" ADD CONSTRAINT "playbook_instances_workspace_site_agree"
908
+ CHECK ("workspace_id" = "site_id");
909
+
910
+ ALTER TABLE "site_listing_configs" DROP CONSTRAINT IF EXISTS "site_listing_configs_workspace_id_format";
911
+ ALTER TABLE "site_listing_configs" ADD CONSTRAINT "site_listing_configs_workspace_id_format"
912
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
913
+ ALTER TABLE "site_listing_configs" DROP CONSTRAINT IF EXISTS "site_listing_configs_workspace_site_agree";
914
+ ALTER TABLE "site_listing_configs" ADD CONSTRAINT "site_listing_configs_workspace_site_agree"
915
+ CHECK ("workspace_id" = "site_id");
916
+
917
+
918
+ -- ── D2 — `listing_csv_sends`: NOT NULL + format CHECK, no equality CHECK ────
919
+ -- It has no `site_id`, so an equality CHECK has nothing to compare. The
920
+ -- backfill takes the workspace from the PARENT listing row, which the
921
+ -- keystone above already fenced. A composite FK to `project_listings` is
922
+ -- legal here (both models are `inbox`) and was offered; the operator chose
923
+ -- ONE fence shape over a second, stricter one for an audit-log table
924
+ -- (2026-09-20, spec § 8 Q1 -> option (a)).
925
+ ALTER TABLE "listing_csv_sends" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
926
+ CREATE INDEX IF NOT EXISTS "listing_csv_sends_workspace_id_idx"
927
+ ON "listing_csv_sends" ("workspace_id");
928
+
929
+ UPDATE "listing_csv_sends" s
930
+ SET "workspace_id" = p."workspace_id"
931
+ FROM "project_listings" p
932
+ WHERE s."workspace_id" IS NULL
933
+ AND p."id" = s."listing_id";
934
+
935
+ DO $$
936
+ DECLARE
937
+ bad bigint;
938
+ BEGIN
939
+ -- The same abort-on-NULL rule the eleven take: a send row whose listing is
940
+ -- gone (or was never fenced) has no correct workspace to write, and this
941
+ -- is about to SET NOT NULL. Never deleted, never guessed (NFR3).
942
+ SELECT count(*) INTO bad FROM "listing_csv_sends" WHERE "workspace_id" IS NULL;
943
+ IF bad <> 0 THEN
944
+ RAISE EXCEPTION '#6374: % listing_csv_sends row(s) have a listing_id that resolves to no project_listings row — resolve or remove them before re-running this migration', bad;
945
+ END IF;
946
+ END $$;
947
+
948
+ ALTER TABLE "listing_csv_sends" ALTER COLUMN "workspace_id" SET NOT NULL;
949
+
950
+ ALTER TABLE "listing_csv_sends" DROP CONSTRAINT IF EXISTS "listing_csv_sends_workspace_id_format";
951
+ ALTER TABLE "listing_csv_sends" ADD CONSTRAINT "listing_csv_sends_workspace_id_format"
952
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
953
+
954
+ -- ── D3 — `site_settings`: keyed BY the workspace, read through the alias ────
955
+ -- It has no `site_id` either, and its one read
956
+ -- (apps/api/src/services/dunning-policy.ts) arrives with a site ALIAS. Every
957
+ -- existing row backfills to the SENTINEL workspace: a pre-fence row belonged
958
+ -- to "the site", which is exactly what the alias `default` resolves to — the
959
+ -- same rule step 1 applies to `site_id` on the eleven.
960
+ --
961
+ -- This is the one block that moves a UNIQUE INDEX, which is why index DDL
962
+ -- appears here and in none of the five steps above: `key` was globally
963
+ -- unique, so two sellers could not both hold `feature_flags`. The `key` TEXT
964
+ -- itself is left VERBATIM — `dunning-policy.ts` embeds the site alias in the
965
+ -- key string, and rebuilding it from the resolved id would match no row and
966
+ -- silently hand every seller the built-in default policy (spec D3).
967
+ ALTER TABLE "site_settings" ADD COLUMN IF NOT EXISTS "workspace_id" TEXT;
968
+ CREATE INDEX IF NOT EXISTS "site_settings_workspace_id_idx"
969
+ ON "site_settings" ("workspace_id");
970
+
971
+ -- ── ABORT, NEVER GUESS — before the sentinel assignment below (AC-14) ──────
972
+ -- NFR3 ("an unresolvable row aborts the migration; it is never deleted and
973
+ -- never guessed") applied to the ONE table whose tenant lives in its key TEXT
974
+ -- rather than in a column. The assignment below attributes every unattributed
975
+ -- row to the sentinel, which is right for a row that belonged to "the site" —
976
+ -- and WRONG for a row whose key already names a DIFFERENT workspace. Such a
977
+ -- row would be silently misfiled under the sentinel, and
978
+ -- `dunning-policy.ts`'s read (keyed on the resolved workspace AND the verbatim
979
+ -- key) would then miss it forever, falling back to the built-in default
980
+ -- policy — MORE permissive than a configured one, on a delinquent-
981
+ -- subscription path. Found by this unit's own security review; disposed by
982
+ -- the operator 2026-09-20 as abort-not-derive.
983
+ --
984
+ -- This DETECTS; it never DERIVES. The embedded id is matched and counted, and
985
+ -- is never read back out and used as the backfill value — key parsing is
986
+ -- exactly what spec D3 forbids, because the alias in the key is load-bearing
987
+ -- for the read. An operator whose database trips this resolves or removes the
988
+ -- rows by hand and re-runs.
989
+ --
990
+ -- The negative lookahead is what makes this precise: it matches a well-formed
991
+ -- `wksp_`+UUIDv7 that is NOT the sentinel literal. A key carrying the sentinel
992
+ -- id, a bare alias (`…:default`), or no id at all is left for the sentinel
993
+ -- assignment below, exactly as before.
994
+ --
995
+ -- Guarded on `workspace_id IS NULL`, so on an already-migrated database every
996
+ -- row is attributed, the count is zero, and a verbatim re-run stays the no-op
997
+ -- NFR2 requires.
998
+ DO $$
999
+ DECLARE
1000
+ bad bigint;
1001
+ BEGIN
1002
+ SELECT count(*) INTO bad
1003
+ FROM "site_settings"
1004
+ WHERE "workspace_id" IS NULL
1005
+ AND "key" ~ 'wksp_(?!00000000-0000-7000-8000-000000000001)[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
1006
+ IF bad <> 0 THEN
1007
+ RAISE EXCEPTION '#6374: % site_settings row(s) carry a NON-SENTINEL workspace id embedded in their key and no workspace_id of their own — assigning them to the sentinel would misfile them and make their read miss forever; resolve or remove them before re-running this migration', bad;
1008
+ END IF;
1009
+ END $$;
1010
+
1011
+ UPDATE "site_settings"
1012
+ SET "workspace_id" = 'wksp_00000000-0000-7000-8000-000000000001'
1013
+ WHERE "workspace_id" IS NULL
1014
+ AND EXISTS (
1015
+ SELECT 1 FROM "workspaces" w
1016
+ WHERE w."id" = 'wksp_00000000-0000-7000-8000-000000000001'
1017
+ );
1018
+
1019
+ DO $$
1020
+ DECLARE
1021
+ bad bigint;
1022
+ BEGIN
1023
+ -- Abort rather than guess: a settings row on a database with no sentinel
1024
+ -- workspace has no workspace to belong to, and this is about to
1025
+ -- SET NOT NULL.
1026
+ SELECT count(*) INTO bad FROM "site_settings" WHERE "workspace_id" IS NULL;
1027
+ IF bad <> 0 THEN
1028
+ RAISE EXCEPTION '#6374: % site_settings row(s) remain unattributed — this database holds no sentinel workspace to backfill them to; resolve or remove them before re-running this migration', bad;
1029
+ END IF;
1030
+ END $$;
1031
+
1032
+ ALTER TABLE "site_settings" ALTER COLUMN "workspace_id" SET NOT NULL;
1033
+
1034
+ ALTER TABLE "site_settings" DROP CONSTRAINT IF EXISTS "site_settings_workspace_id_format";
1035
+ ALTER TABLE "site_settings" ADD CONSTRAINT "site_settings_workspace_id_format"
1036
+ CHECK ("workspace_id" ~ '^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
1037
+
1038
+ -- The unique key moves from (key) to (workspace_id, key).
1039
+ --
1040
+ -- DROP-then-CREATE for the REPLACEMENT too, never `CREATE UNIQUE INDEX IF NOT
1041
+ -- EXISTS` — the same keystone D5 / SEC-06 rule the CHECKs above follow, and
1042
+ -- this is the one place in this file where a constraint REPLACES a dropped
1043
+ -- one, so it is the one place the false-green would actually cost something.
1044
+ -- `IF NOT EXISTS` matches on NAME ALONE: an index of this name that was
1045
+ -- non-unique, or over different columns, would silently satisfy the guard and
1046
+ -- leave `site_settings` with NO uniqueness at all — having already dropped the
1047
+ -- old one unconditionally on the line above. The old index is a plain index,
1048
+ -- not a table constraint (20260508000000_add_inbox_namespace created it with
1049
+ -- `CREATE UNIQUE INDEX`), so `DROP INDEX` is the correct verb for both.
1050
+ DROP INDEX IF EXISTS "site_settings_key_key";
1051
+ DROP INDEX IF EXISTS "site_settings_workspace_id_key_key";
1052
+ CREATE UNIQUE INDEX "site_settings_workspace_id_key_key"
1053
+ ON "site_settings" ("workspace_id", "key");
1054
+
1055
+ -- ── D4 — `deliverable_grants`: stamped at mint, and STILL nullable ──────────
1056
+ -- No column is added and no existing row is touched. NULL remains the serve
1057
+ -- path's fail-closed sentinel (#6254 § 10.1 Q2): a legacy `dasset_` grant and
1058
+ -- a `playbook:` virtual one keep their NULL and keep meaning "serve nothing".
1059
+ -- `deliverable_grants.site_id` is deliberately NOT normalized — a legacy row
1060
+ -- keeps its alias, which with a NULL workspace still means serve nothing.
1061
+ --
1062
+ -- What IS added is the NULL-TOLERANT equality CHECK, so a STAMPED grant can
1063
+ -- never disagree with its site while a legacy grant stays storable. Both mint
1064
+ -- sites stamp the workspace they already hold (AC-8).
1065
+ ALTER TABLE "deliverable_grants" DROP CONSTRAINT IF EXISTS "deliverable_grants_workspace_site_agree";
1066
+ ALTER TABLE "deliverable_grants" ADD CONSTRAINT "deliverable_grants_workspace_site_agree"
1067
+ CHECK ("workspace_id" IS NULL OR "workspace_id" = "site_id");