@rubytech/create-maxy-code 0.1.280 → 0.1.282

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 (234) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/mcp-spawn-tee/dist/index.d.ts +31 -43
  3. package/payload/platform/lib/mcp-spawn-tee/dist/index.d.ts.map +1 -1
  4. package/payload/platform/lib/mcp-spawn-tee/dist/index.js +112 -85
  5. package/payload/platform/lib/mcp-spawn-tee/dist/index.js.map +1 -1
  6. package/payload/platform/lib/mcp-spawn-tee/src/__tests__/spawn-tee.test.ts +111 -0
  7. package/payload/platform/lib/mcp-spawn-tee/src/index.ts +113 -84
  8. package/payload/platform/lib/mcp-spawn-tee/tsconfig.json +2 -1
  9. package/payload/platform/plugins/admin/PLUGIN.md +2 -1
  10. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +154 -0
  11. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +24 -0
  12. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +174 -0
  13. package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +22 -2
  14. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -2
  15. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +378 -56
  16. package/payload/platform/plugins/cloudflare/references/api.md +35 -1
  17. package/payload/platform/plugins/cloudflare/references/d1-data-capture.md +2 -0
  18. package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
  19. package/payload/platform/plugins/docs/references/platform.md +2 -0
  20. package/payload/platform/plugins/email/PLUGIN.md +1 -1
  21. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-resolve.test.d.ts +2 -0
  22. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-resolve.test.d.ts.map +1 -0
  23. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-resolve.test.js +92 -0
  24. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-resolve.test.js.map +1 -0
  25. package/payload/platform/plugins/email/mcp/dist/__tests__/email-setup.test.d.ts +2 -0
  26. package/payload/platform/plugins/email/mcp/dist/__tests__/email-setup.test.d.ts.map +1 -0
  27. package/payload/platform/plugins/email/mcp/dist/__tests__/email-setup.test.js +55 -0
  28. package/payload/platform/plugins/email/mcp/dist/__tests__/email-setup.test.js.map +1 -0
  29. package/payload/platform/plugins/email/mcp/dist/__tests__/providers.test.d.ts +2 -0
  30. package/payload/platform/plugins/email/mcp/dist/__tests__/providers.test.d.ts.map +1 -0
  31. package/payload/platform/plugins/email/mcp/dist/__tests__/providers.test.js +110 -0
  32. package/payload/platform/plugins/email/mcp/dist/__tests__/providers.test.js.map +1 -0
  33. package/payload/platform/plugins/email/mcp/dist/__tests__/recipient-filter.test.d.ts +2 -0
  34. package/payload/platform/plugins/email/mcp/dist/__tests__/recipient-filter.test.d.ts.map +1 -0
  35. package/payload/platform/plugins/email/mcp/dist/__tests__/recipient-filter.test.js +57 -0
  36. package/payload/platform/plugins/email/mcp/dist/__tests__/recipient-filter.test.js.map +1 -0
  37. package/payload/platform/plugins/email/mcp/dist/index.js +8 -1
  38. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  39. package/payload/platform/plugins/email/mcp/dist/lib/attachment-resolve.d.ts +12 -0
  40. package/payload/platform/plugins/email/mcp/dist/lib/attachment-resolve.d.ts.map +1 -1
  41. package/payload/platform/plugins/email/mcp/dist/lib/attachment-resolve.js +44 -5
  42. package/payload/platform/plugins/email/mcp/dist/lib/attachment-resolve.js.map +1 -1
  43. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +13 -8
  44. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  45. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +26 -15
  46. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  47. package/payload/platform/plugins/email/mcp/dist/lib/ingest-batch.d.ts +8 -0
  48. package/payload/platform/plugins/email/mcp/dist/lib/ingest-batch.d.ts.map +1 -1
  49. package/payload/platform/plugins/email/mcp/dist/lib/ingest-batch.js.map +1 -1
  50. package/payload/platform/plugins/email/mcp/dist/lib/providers.d.ts +16 -5
  51. package/payload/platform/plugins/email/mcp/dist/lib/providers.d.ts.map +1 -1
  52. package/payload/platform/plugins/email/mcp/dist/lib/providers.js +120 -13
  53. package/payload/platform/plugins/email/mcp/dist/lib/providers.js.map +1 -1
  54. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch.js +3 -3
  55. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch.js.map +1 -1
  56. package/payload/platform/plugins/email/mcp/dist/tools/email-ingest.d.ts.map +1 -1
  57. package/payload/platform/plugins/email/mcp/dist/tools/email-ingest.js +6 -2
  58. package/payload/platform/plugins/email/mcp/dist/tools/email-ingest.js.map +1 -1
  59. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.d.ts.map +1 -1
  60. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js +5 -3
  61. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js.map +1 -1
  62. package/payload/platform/plugins/email/mcp/dist/tools/email-send.d.ts.map +1 -1
  63. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js +6 -3
  64. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js.map +1 -1
  65. package/payload/platform/plugins/email/mcp/dist/tools/email-setup.d.ts.map +1 -1
  66. package/payload/platform/plugins/email/mcp/dist/tools/email-setup.js +22 -4
  67. package/payload/platform/plugins/email/mcp/dist/tools/email-setup.js.map +1 -1
  68. package/payload/platform/plugins/email/mcp/package.json +4 -2
  69. package/payload/platform/plugins/email/mcp/vitest.config.ts +9 -0
  70. package/payload/platform/plugins/email/references/email-reference.md +22 -5
  71. package/payload/platform/plugins/email/skills/email-composition/SKILL.md +1 -1
  72. package/payload/platform/plugins/graph/PLUGIN.md +2 -0
  73. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/kd-classify-account-dir.test.d.ts +2 -0
  74. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/kd-classify-account-dir.test.d.ts.map +1 -0
  75. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/kd-classify-account-dir.test.js +31 -0
  76. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/kd-classify-account-dir.test.js.map +1 -0
  77. package/payload/platform/plugins/memory/mcp/dist/tools/kd-classify.d.ts +5 -0
  78. package/payload/platform/plugins/memory/mcp/dist/tools/kd-classify.d.ts.map +1 -1
  79. package/payload/platform/plugins/memory/mcp/dist/tools/kd-classify.js +21 -2
  80. package/payload/platform/plugins/memory/mcp/dist/tools/kd-classify.js.map +1 -1
  81. package/payload/platform/plugins/prompt-optimiser/skills/prompt-optimiser/SKILL.md +1 -0
  82. package/payload/platform/plugins/url-get/mcp/dist/tools/url-get.js +5 -5
  83. package/payload/platform/plugins/url-get/mcp/dist/tools/url-get.js.map +1 -1
  84. package/payload/platform/plugins/whatsapp/.claude-plugin/plugin.json +1 -1
  85. package/payload/platform/plugins/whatsapp/PLUGIN.md +7 -11
  86. package/payload/platform/plugins/whatsapp/mcp/dist/index.js +10 -56
  87. package/payload/platform/plugins/whatsapp/mcp/dist/index.js.map +1 -1
  88. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +26 -2
  89. package/payload/platform/plugins/whatsapp/skills/manage-whatsapp-config/SKILL.md +9 -14
  90. package/payload/platform/scripts/setup-account.sh +7 -0
  91. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  92. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +0 -2
  93. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  94. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  95. package/payload/platform/services/claude-session-manager/dist/http-server.js +63 -3
  96. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  97. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  98. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +7 -1
  99. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  100. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +6 -0
  101. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  102. package/payload/platform/services/whatsapp-channel/dist/notification.js +14 -0
  103. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  104. package/payload/platform/services/whatsapp-channel/dist/server.js +5 -3
  105. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  106. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  107. package/payload/server/public/assets/AdminShell-FO766lOW.js +1 -0
  108. package/payload/server/public/assets/{Checkbox-2reCESkb.js → Checkbox-syHoU9nY.js} +1 -1
  109. package/payload/server/public/assets/{admin-BpnsTEa3.js → admin-0_VxffD5.js} +1 -1
  110. package/payload/server/public/assets/{arc-BgelqC_3.js → arc-BW1WhwmV.js} +1 -1
  111. package/payload/server/public/assets/architecture-YZFGNWBL-xHbVjatf.js +1 -0
  112. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BabnAZJm.js → architectureDiagram-Q4EWVU46-DsionCio.js} +1 -1
  113. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-BJzZqNsy.js → blockDiagram-DXYQGD6D-NOG5yVZk.js} +1 -1
  114. package/payload/server/public/assets/{browser-fgBRZ5gK.js → browser-Cxjkhtsk.js} +1 -1
  115. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-By-xuh9F.js → c4Diagram-AHTNJAMY-DwFV1k9I.js} +1 -1
  116. package/payload/server/public/assets/channel-D8Go_zJk.js +1 -0
  117. package/payload/server/public/assets/{chunk-2KRD3SAO-Gj2dSpBl.js → chunk-2KRD3SAO-CKn-bEkI.js} +1 -1
  118. package/payload/server/public/assets/{chunk-336JU56O-2PXVPCUA.js → chunk-336JU56O-iJ4ISPCi.js} +2 -2
  119. package/payload/server/public/assets/chunk-426QAEUC-sWe0Iexz.js +1 -0
  120. package/payload/server/public/assets/{chunk-4BX2VUAB-9XIBlnC2.js → chunk-4BX2VUAB-B2mSmC97.js} +1 -1
  121. package/payload/server/public/assets/{chunk-4TB4RGXK-Cr3mXPnI.js → chunk-4TB4RGXK-kKNYi2_i.js} +1 -1
  122. package/payload/server/public/assets/{chunk-55IACEB6-Ccg46J3A.js → chunk-55IACEB6-JLT9m7Hd.js} +1 -1
  123. package/payload/server/public/assets/{chunk-5FUZZQ4R-Cz9MvPKc.js → chunk-5FUZZQ4R-Car3r0he.js} +1 -1
  124. package/payload/server/public/assets/{chunk-5PVQY5BW-BleOdlgO.js → chunk-5PVQY5BW-DR9EfLvj.js} +1 -1
  125. package/payload/server/public/assets/{chunk-67CJDMHE-CyCYCQWy.js → chunk-67CJDMHE-CCdP3Pdx.js} +1 -1
  126. package/payload/server/public/assets/{chunk-7N4EOEYR-KpT3uHzH.js → chunk-7N4EOEYR-bXgVzdM9.js} +1 -1
  127. package/payload/server/public/assets/{chunk-AA7GKIK3-BfOzbuxq.js → chunk-AA7GKIK3-CYeDvY8B.js} +1 -1
  128. package/payload/server/public/assets/{chunk-BSJP7CBP-Bq0M3wlv.js → chunk-BSJP7CBP-U0Fi-Ers.js} +1 -1
  129. package/payload/server/public/assets/{chunk-CIAEETIT-CR-2dNaL.js → chunk-CIAEETIT-C8QrV6Gg.js} +1 -1
  130. package/payload/server/public/assets/{chunk-EDXVE4YY-egUGMTOS.js → chunk-EDXVE4YY-DLUWVRZV.js} +1 -1
  131. package/payload/server/public/assets/{chunk-ENJZ2VHE-CQohBEFw.js → chunk-ENJZ2VHE-B4bNJo6C.js} +1 -1
  132. package/payload/server/public/assets/{chunk-FMBD7UC4-DIxBUbLP.js → chunk-FMBD7UC4-DxAQS3UB.js} +1 -1
  133. package/payload/server/public/assets/{chunk-FOC6F5B3-C2694Zoq.js → chunk-FOC6F5B3-CyIK3zeY.js} +1 -1
  134. package/payload/server/public/assets/{chunk-ICPOFSXX-DpXZKHyn.js → chunk-ICPOFSXX-Qzl8Ki0l.js} +2 -2
  135. package/payload/server/public/assets/{chunk-K5T4RW27-CUicG0Rp.js → chunk-K5T4RW27-7GcDzjyJ.js} +1 -1
  136. package/payload/server/public/assets/{chunk-KGLVRYIC-B5mPJhOr.js → chunk-KGLVRYIC-B7pAr9hf.js} +1 -1
  137. package/payload/server/public/assets/{chunk-LIHQZDEY-q81QhuMs.js → chunk-LIHQZDEY-DEoU2jrz.js} +1 -1
  138. package/payload/server/public/assets/{chunk-ORNJ4GCN-DgurdFZs.js → chunk-ORNJ4GCN-BwMFuN24.js} +1 -1
  139. package/payload/server/public/assets/{chunk-OYMX7WX6-CxqMRu2d.js → chunk-OYMX7WX6-BenBO6O_.js} +1 -1
  140. package/payload/server/public/assets/chunk-QZHKN3VN-CHE_iZO7.js +1 -0
  141. package/payload/server/public/assets/{chunk-U2HBQHQK-BSLctJC9.js → chunk-U2HBQHQK-DewM8R0D.js} +1 -1
  142. package/payload/server/public/assets/{chunk-X2U36JSP--0AUeeFl.js → chunk-X2U36JSP-C22Q6Ea4.js} +1 -1
  143. package/payload/server/public/assets/{chunk-XPW4576I-D-hf9Bcn.js → chunk-XPW4576I-BA0a8Ygs.js} +1 -1
  144. package/payload/server/public/assets/{chunk-YZCP3GAM-PvOhSwI9.js → chunk-YZCP3GAM-DfUUNTnA.js} +1 -1
  145. package/payload/server/public/assets/{chunk-ZZ45TVLE-1zPUlNWz.js → chunk-ZZ45TVLE-CUT6zma2.js} +1 -1
  146. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BLb1mzrT.js +1 -0
  147. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-Ctj5qYTS.js +1 -0
  148. package/payload/server/public/assets/clone-DGZ2vydA.js +1 -0
  149. package/payload/server/public/assets/{cose-bilkent-S5V4N54A-Cvo-gkBd.js → cose-bilkent-S5V4N54A-Df_JmB8E.js} +1 -1
  150. package/payload/server/public/assets/{dagre-CMHsNfjP.js → dagre-CYXRIdv0.js} +1 -1
  151. package/payload/server/public/assets/{dagre-KV5264BT-CM1DSx4E.js → dagre-KV5264BT-D4EGb60h.js} +1 -1
  152. package/payload/server/public/assets/{data-Cuynp6Y6.js → data-BOgwOWnw.js} +1 -1
  153. package/payload/server/public/assets/{diagram-5BDNPKRD-VDHiHwEI.js → diagram-5BDNPKRD-BzieCEfT.js} +1 -1
  154. package/payload/server/public/assets/{diagram-G4DWMVQ6-OKdufoeW.js → diagram-G4DWMVQ6-DDbO71XD.js} +1 -1
  155. package/payload/server/public/assets/{diagram-MMDJMWI5-Yg6QdCA1.js → diagram-MMDJMWI5-DqQ_UXDA.js} +1 -1
  156. package/payload/server/public/assets/{diagram-TYMM5635-CpEMZQDu.js → diagram-TYMM5635-CFyxA1aZ.js} +1 -1
  157. package/payload/server/public/assets/{dist-811BIK0R.js → dist-BmvZ8OaX.js} +1 -1
  158. package/payload/server/public/assets/{erDiagram-SMLLAGMA-Dw5gAkFM.js → erDiagram-SMLLAGMA-BMaEBbOK.js} +1 -1
  159. package/payload/server/public/assets/{flatten-BsWEYbBB.js → flatten-Bo6YRmWl.js} +1 -1
  160. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-Cy_R1XHz.js → flowDiagram-DWJPFMVM-De5ChZQP.js} +1 -1
  161. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-BYARP_eH.js → ganttDiagram-T4ZO3ILL-CXEMPVaK.js} +1 -1
  162. package/payload/server/public/assets/gitGraph-7Q5UKJZL-CmHdoS27.js +1 -0
  163. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BGOe0unY.js → gitGraphDiagram-UUTBAWPF-OZedr_6y.js} +1 -1
  164. package/payload/server/public/assets/{graph-Ddg24bjy.js → graph-BqD2GjQd.js} +2 -2
  165. package/payload/server/public/assets/{graph-labels-eOrWYy0R.js → graph-labels-DSDTSBfM.js} +1 -1
  166. package/payload/server/public/assets/{graphlib-DaefxCga.js → graphlib-CN4nhD2U.js} +1 -1
  167. package/payload/server/public/assets/info-OMHHGYJF-DTA9msO-.js +1 -0
  168. package/payload/server/public/assets/infoDiagram-42DDH7IO-CSqg4nRL.js +2 -0
  169. package/payload/server/public/assets/{isEmpty-BWl67LAZ.js → isEmpty-D6Kr-M1M.js} +1 -1
  170. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-YrGuzpiI.js → ishikawaDiagram-UXIWVN3A-ChlzrZyx.js} +1 -1
  171. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-EIykOcqg.js → journeyDiagram-VCZTEJTY-DsrK55ZO.js} +1 -1
  172. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-BR0TC6Je.js → kanban-definition-6JOO6SKY-DYF-_Rym.js} +1 -1
  173. package/payload/server/public/assets/{line-DPGcT5IM.js → line-3zmwI8XK.js} +1 -1
  174. package/payload/server/public/assets/{linear-CPN03uSh.js → linear-COm19-p7.js} +1 -1
  175. package/payload/server/public/assets/{mermaid-parser.core-D7Iu4c9M.js → mermaid-parser.core-BrcX8-cl.js} +2 -2
  176. package/payload/server/public/assets/{mermaid.core-krXRpXn9.js → mermaid.core-CBdz3b1N.js} +3 -3
  177. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-HwiCtPzN.js → mindmap-definition-QFDTVHPH-BqCqlYZb.js} +1 -1
  178. package/payload/server/public/assets/{ordinal-krseTxxN.js → ordinal-BDi6f4xk.js} +1 -1
  179. package/payload/server/public/assets/packet-4T2RLAQJ-6GjqIKgu.js +1 -0
  180. package/payload/server/public/assets/pie-ZZUOXDRM-T72DJ5JR.js +1 -0
  181. package/payload/server/public/assets/{pieDiagram-DEJITSTG-xodxyWLe.js → pieDiagram-DEJITSTG-D5LiILuM.js} +1 -1
  182. package/payload/server/public/assets/{public-xVaPcg6i.js → public-udi_Z7la.js} +3 -3
  183. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-DuYYY4ZZ.js → quadrantDiagram-34T5L4WZ-C3iuI5l_.js} +1 -1
  184. package/payload/server/public/assets/radar-PYXPWWZC-x-6xW1dE.js +1 -0
  185. package/payload/server/public/assets/{reduce-tk-xY6Fv.js → reduce-CGi9ik8i.js} +1 -1
  186. package/payload/server/public/assets/{requirementDiagram-MS252O5E-D0ta3kru.js → requirementDiagram-MS252O5E-CTPOw5qQ.js} +1 -1
  187. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-KdHdlzvT.js → sankeyDiagram-XADWPNL6-D_5aVlYp.js} +1 -1
  188. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DoSFUTQZ.js → sequenceDiagram-FGHM5R23-D7h5vocM.js} +1 -1
  189. package/payload/server/public/assets/{src-Cpl1JzME.js → src-DLEIsjER.js} +1 -1
  190. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-BLW0KiSA.js → stateDiagram-FHFEXIEX-DyZjuwPj.js} +1 -1
  191. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BiW9pGXy.js +1 -0
  192. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-8QAzhYz1.js → timeline-definition-GMOUNBTQ-BqpVYJz_.js} +1 -1
  193. package/payload/server/public/assets/treeView-SZITEDCU-5-6wEryJ.js +1 -0
  194. package/payload/server/public/assets/treemap-W4RFUUIX-DF48Cc86.js +1 -0
  195. package/payload/server/public/assets/{useSelectionMode-DlJsX-_X.js → useSelectionMode-BPzPjKP_.js} +1 -1
  196. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BKvfScOi.js → vennDiagram-DHZGUBPP-Czflml0e.js} +1 -1
  197. package/payload/server/public/assets/wardley-RL74JXVD-duKIa6Hq.js +1 -0
  198. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DaMfDpJ3.js → wardleyDiagram-NUSXRM2D-Cqz3pSTK.js} +1 -1
  199. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-BIkNQlLa.js → xychartDiagram-5P7HB3ND-CS5dEuUO.js} +1 -1
  200. package/payload/server/public/browser.html +4 -4
  201. package/payload/server/public/data.html +5 -5
  202. package/payload/server/public/graph.html +6 -6
  203. package/payload/server/public/index.html +5 -5
  204. package/payload/server/public/public.html +4 -4
  205. package/payload/server/server.js +74 -275
  206. package/payload/server/public/assets/AdminShell-C6_5h1cC.js +0 -1
  207. package/payload/server/public/assets/architecture-YZFGNWBL-YM-TtgPY.js +0 -1
  208. package/payload/server/public/assets/channel-BRPH0atd.js +0 -1
  209. package/payload/server/public/assets/chunk-426QAEUC-QhauxjvK.js +0 -1
  210. package/payload/server/public/assets/chunk-QZHKN3VN-BDAQvtxz.js +0 -1
  211. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BJv89PB6.js +0 -1
  212. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DNUszH5M.js +0 -1
  213. package/payload/server/public/assets/clone-CREg9jLM.js +0 -1
  214. package/payload/server/public/assets/gitGraph-7Q5UKJZL-CtOPqykB.js +0 -1
  215. package/payload/server/public/assets/info-OMHHGYJF-Jba5enA8.js +0 -1
  216. package/payload/server/public/assets/infoDiagram-42DDH7IO-Df9BlkPA.js +0 -2
  217. package/payload/server/public/assets/packet-4T2RLAQJ-D_eWzeAP.js +0 -1
  218. package/payload/server/public/assets/pie-ZZUOXDRM-BDiy1Ob2.js +0 -1
  219. package/payload/server/public/assets/radar-PYXPWWZC-C5mlfmtg.js +0 -1
  220. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-C5zlCV4X.js +0 -1
  221. package/payload/server/public/assets/treeView-SZITEDCU-CM-9m-cS.js +0 -1
  222. package/payload/server/public/assets/treemap-W4RFUUIX-DDHPB-Xh.js +0 -1
  223. package/payload/server/public/assets/wardley-RL74JXVD-CUb3Br4q.js +0 -1
  224. package/payload/server/public/assets/whatsapp-BE79pNh8.js +0 -1
  225. package/payload/server/public/whatsapp.html +0 -18
  226. /package/payload/server/public/assets/{_baseFor-BHtDrjIo.js → _baseFor-Cam2PbSt.js} +0 -0
  227. /package/payload/server/public/assets/{array-DetWRiSa.js → array-DYRGGQae.js} +0 -0
  228. /package/payload/server/public/assets/{chunk-CWOGyPgy.js → chunk-CnGqDkHZ.js} +0 -0
  229. /package/payload/server/public/assets/{cytoscape.esm-C9yNhe1u.js → cytoscape.esm-nWsJMTNI.js} +0 -0
  230. /package/payload/server/public/assets/{defaultLocale-_WRwicXn.js → defaultLocale-Du1XY3Dp.js} +0 -0
  231. /package/payload/server/public/assets/{init-sTEcj9YX.js → init-B5BXBRcm.js} +0 -0
  232. /package/payload/server/public/assets/{katex-s61Rgv6l.js → katex-HOUACuRw.js} +0 -0
  233. /package/payload/server/public/assets/{path-B0Ik7Tu9.js → path-CNO468J-.js} +0 -0
  234. /package/payload/server/public/assets/{rough.esm-DKRO8IF-.js → rough.esm-DRO6hWPh.js} +0 -0
@@ -1,28 +1,30 @@
1
1
  ---
2
2
  name: e-sign
3
- description: "Put a legally-recordable signature on any hosted document — a quote, contract, proposal, or set of terms — without DocuSign. A Cloudflare Pages signing form captures the acceptance to D1; the agent then sweeps the row, renders the signed content to PDF, and emails both parties. Triggers: 'sign this proposal', 'get this contract signed', 'put a signature on this', 'e-signature', 'send for signature', 'accept a proposal online', 'signature on a document'."
3
+ description: "Put a legally-recordable signature on any hosted document — a quote, contract, proposal, or set of terms — without DocuSign. A Cloudflare Pages signing form captures the acceptance to D1; the agent renders the document to PDF once at deploy, then stamps a per-signer copy carrying an embedded Electronic Acceptance Certificate and emails both parties. One acceptance per document, then the signing page closes. Triggers: 'sign this proposal', 'get this contract signed', 'put a signature on this', 'e-signature', 'send for signature', 'accept a proposal online', 'signature on a document'."
4
4
  ---
5
5
 
6
6
  # E-signatures — signing any hosted document (form → D1 → PDF + email)
7
7
 
8
8
  **Invoked by the business-assistant (or admin) directly** when a built document — a quote, contract, proposal, or set of terms — needs a recordable signature.
9
9
 
10
- How to put a legally-recordable signature on **any** document the business hosts — a quote, a contract, a proposal, a set of terms — without DocuSign. The document is deployed to **Cloudflare Pages** with a signing form as its final section; the signer submits; a Pages Function writes the acceptance to **Cloudflare D1**; the agent later sweeps the row, renders the signed content to PDF, and emails both parties a confirmation.
10
+ How to put a legally-recordable signature on **any** document the business hosts — a quote, a contract, a proposal, a set of terms — without DocuSign. The document is deployed to **Cloudflare Pages** with a signing form as its final section; the signer submits; a Pages Function writes the acceptance to **Cloudflare D1**; the agent later sweeps the row, stamps a per-signer copy of the deploy-time base PDF with an embedded acceptance certificate, and emails both parties a confirmation.
11
11
 
12
12
  This skill owns **only the signature**: the form, the capture, the PDF, and the dispatch. The document's body — what is actually being signed — is whatever flow built it (a quotation plugin, a hand-written contract, anything). This begins at *"a built document needs a signature."*
13
13
 
14
+ **Legal basis (state it, do not just assert it).** This produces a **simple electronic signature**: the signer's *intention to authenticate* the document, captured as a deliberate, consented act (the ticked intent-and-consent box of § 2 plus the recorded identity and timestamp). In the UK such a signature is admissible under the **Electronic Communications Act 2000 s.7** and is the simple-signature tier recognised under **UK eIDAS**; its legal weight rests on the *evidentiary record* — who signed, that they intended to sign electronically, when, and what they signed. That record is exactly what the embedded Electronic Acceptance Certificate (§ 6) and the signer's own copy carry, which is why "legally-recordable" is substantiated here rather than claimed. The basis above is stated for the **UK**; each account should confirm its own governing law, and a higher-assurance tier (advanced/qualified signatures, identity verification, server-side attribution) is a separate capability, not this skill.
15
+
14
16
  It composes two patterns you must read alongside it:
15
17
 
16
- - **`cloudflare/references/d1-data-capture.md`** — the form → Pages Function → D1 → sweep mechanics, and the token-scope breakage (§ 0). Everything about minting the Pages-+-D1 token, `wrangler.toml`, and `wrangler d1 execute --remote` lives there; this skill does not repeat it.
17
- - **`business-assistant/references/invoicing.md`** — PDF generation via `browser-navigate` + `browser-pdf-save` (its step 4). The signed-content PDF uses that exact pattern.
18
+ - **`cloudflare/references/d1-data-capture.md`** — the form → Pages Function → D1 → sweep mechanics, and the token-scope breakage (§ 0). Everything about minting the Pages-+-D1 token, `wrangler.toml`, and `wrangler d1 execute --remote` lives there; this skill does not repeat it. Note its rule that the D1 **query** endpoint rejects a D1-Read token — every `wrangler d1 execute` here uses a **D1-Edit** token, reads included.
19
+ - **`business-assistant/references/invoicing.md`** — PDF generation via `browser-navigate` + `browser-pdf-save` (its step 4). The **deploy-time base render** (§ 5) uses that exact pattern. Dispatch (§ 6) does **not** — it stamps the persisted base with a PDF library, no browser and no network.
18
20
 
19
- Auth for every `wrangler`/API call is a minted narrow token per `cloudflare/references/api.md`. Deploy mechanics are `cloudflare/references/hosting-sites.md`.
21
+ Auth for every `wrangler`/API call is a minted narrow token per `cloudflare/references/api.md` — detect the master's type (`cfat_…` account-scoped vs `cfut_…` user-scoped) and route endpoints by prefix per that reference. Deploy mechanics are `cloudflare/references/hosting-sites.md`.
20
22
 
21
23
  ---
22
24
 
23
- ## 0. Pre-flight — block on either prerequisite
25
+ ## 0. Pre-flight — block on each prerequisite
24
26
 
25
- Do **not** deploy until both pass. Each failure has one exact remediation; stop and give it.
27
+ Do **not** deploy until all three pass. Each failure has one exact remediation; stop and give it.
26
28
 
27
29
  1. **Email must be configured.** Call `email-status`. If it does not report a configured inbox, stop:
28
30
  > "Email isn't set up yet. Run `email-setup` first, then ask me again."
@@ -39,6 +41,18 @@ Do **not** deploy until both pass. Each failure has one exact remediation; stop
39
41
  On `missing`, stop:
40
42
  > "There's no Cloudflare master token. Provision it once per `cloudflare/references/api.md` § Provisioning the master token, then ask me again."
41
43
 
44
+ 3. **The account directory must be set (attachment capability).** `email-send` attaches the signed PDF by absolute path under the account directory, and § 5 persists the base PDF there. If `ACCOUNT_DIR` is unset, the attachment silently degrades to a **text-only** send — neither party receives the PDF — and there is nowhere to persist the base render. Assert it before any deploy or sweep:
45
+
46
+ ```bash
47
+ ( test -n "${ACCOUNT_DIR}" && test -d "${ACCOUNT_DIR}" && test -w "${ACCOUNT_DIR}" ) \
48
+ && echo "ok" || echo "missing"
49
+ ```
50
+
51
+ On `missing`, stop:
52
+ > "The account directory isn't set, so I can't attach the signed PDF or persist the base render. `ACCOUNT_DIR` is normally set at spawn; if it's missing, this session was started without an account context — restart the account's session so the spawn seam sets it, then ask me again."
53
+
54
+ (`ACCOUNT_DIR` is the spawn-seam guarantee of Task 714; this pre-flight only asserts it, it does not set it.)
55
+
42
56
  ---
43
57
 
44
58
  ## 1. The signable document
@@ -47,9 +61,76 @@ A self-contained HTML document — all styles inline, no external resources —
47
61
 
48
62
  - **Slug carries a random hex suffix** so the URL is unguessable: `quote-lakeside-7f3a9c.html` (or a project whose name carries the suffix). Mint the suffix once: `openssl rand -hex 3`.
49
63
  - **Page carries `<meta name="robots" content="noindex,nofollow">`** in `<head>` — a signable document is private, never indexed.
50
- - **Assign a `DOC_REF`** — a short stable identifier for this one document, e.g. `QUOTE-LAKESIDE`. It keys the acceptance row and seeds the token. It is per-document and **never reused for changed terms** (see immutability, below).
64
+ - **Assign a `DOC_REF`** — a short stable identifier for this one document, e.g. `QUOTE-LAKESIDE`. It keys the acceptance row, seeds the token, and keys the persisted base PDF. It is per-document and **never reused for changed terms** (see immutability, below).
51
65
 
52
- **Immutability signed content must not change.** Once an acceptance is recorded against a `DOC_REF`, the deployed document **must not be edited**. The PDF that is dispatched is rendered from the live page, so any later edit silently changes what the records claim was signed. Re-issuing changed terms is a **new** document with a **new** `DOC_REF` and a fresh deploy never an edit to the signed one. (Failure-mode detail: § 8.3.)
66
+ **Render-once base PDF, keyed by `DOC_REF`.** The body being signed is identical for every signer, so it is rendered to PDF **once at deploy** 5) and persisted under the account directory, keyed by `DOC_REF`:
67
+
68
+ ```
69
+ ${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf # the rendered document
70
+ ${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.sha256 # its content hash (drift check, § 8.3)
71
+ ```
72
+
73
+ Per-signer stamped copies (§ 6) are written alongside, in the same `<DOC_REF>` directory. A new `DOC_REF` ⇒ a new render; an existing `DOC_REF` reuses its persisted base and is **never re-rendered**.
74
+
75
+ **Immutability — signed content must not change.** Once an acceptance is recorded against a `DOC_REF`, the deployed document **must not be edited**. The dispatched PDF is the persisted base captured at deploy, so any later edit to the live page diverges from what the records claim was signed. Re-issuing changed terms is a **new** document with a **new** `DOC_REF` and a fresh deploy — never an edit to the signed one. (Failure-mode detail: § 8.3.)
76
+
77
+ ---
78
+
79
+ ## 1a. Access-gating (optional — only named recipients may load the page)
80
+
81
+ The § 1 default — an unguessable hex slug plus `noindex` — keeps a low-sensitivity document private by obscurity. For a **confidential** proposal to a **named** recipient, add a **Cloudflare Access** gate in front of the page so only allowlisted emails can load it at all. Gating is **additive**: the slug and `noindex` stay; the gate sits in front of them. It is never the default and never replaces them.
82
+
83
+ **One-Time PIN is the only correct identity provider here — and why.** The signer is an external party who belongs to **no** Cloudflare organization, so SSO, Google, GitHub, SAML and every other org-membership IdP cannot authenticate them. Cloudflare's built-in **One-Time PIN** emails a login code to **any allowlisted address** with no org membership required — exactly the external-signer case. Prescribe `onetimepin`; do not reach for another IdP type for an external signer (a higher-assurance provider is a separate capability, not this skill).
84
+
85
+ **Load-bearing pre-flight — a gate requires an enabled login method.** Creating an Access application and policy does **not** enable a login method. If the org has **zero** identity providers when the gate goes live, the login page offers **nothing** and locks out *everyone, including the operator*. Before activating any Access app, list the org's IdPs and confirm the list is non-empty:
86
+
87
+ ```bash
88
+ # Empty result here + an active Access app = total lockout. Create the IdP FIRST.
89
+ CLOUDFLARE_API_TOKEN="${MINTED_ACCESS}" curl -sS \
90
+ "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/access/identity_providers" \
91
+ | jq '.result | length'
92
+ ```
93
+
94
+ **Token scope.** Gating needs one minted narrow token (per `api.md` § Minting a narrow token, routed by prefix with mint→verify backoff as § 8.8 notes) carrying **Access application + policy write** *and* **`Access: Identity Providers Write`**. Resolve those permission-group ids the same way `d1-data-capture.md` § 0 resolves the Pages/D1 ids — one `GET …/tokens/permission_groups`. Token *creation* stays an account-level concern; this skill names the scopes and consumes whatever token type the account supplies. `${MINTED_ACCESS}` below is that token.
95
+
96
+ **Create IdP → app → policy, in that order:**
97
+
98
+ 1. **Create the One-Time PIN IdP**, then re-list to confirm the list is now non-empty (the pre-flight above, run again):
99
+
100
+ ```bash
101
+ CLOUDFLARE_API_TOKEN="${MINTED_ACCESS}" curl -sS -X POST -H "Content-Type: application/json" \
102
+ "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/access/identity_providers" \
103
+ --data '{"name":"One-time PIN","type":"onetimepin"}' | jq '{ok:.success, type:.result.type}'
104
+ ```
105
+
106
+ 2. **Create the Access application** scoped to the deployed page's hostname **and** path (the `<project>.pages.dev` host and the signing document's path), so the gate covers exactly that document.
107
+ 3. **Attach a policy** whose `include` allowlist names the **signer's email** (and the business owner's). Only those addresses can authenticate and view the page.
108
+
109
+ The IdP call shapes are given inline above; the Access **app** and **policy** endpoints live in `api.md`'s endpoint map, and the permission-group ids are resolved by the `api.md` method (`GET …/tokens/permission_groups`).
110
+
111
+ **Verify on live.** Loading the gated page **302-redirects to a Cloudflare login page that offers One-Time PIN** — not an empty dead-end. An allowlisted email receives a code, enters it, and reaches the document; a non-allowlisted email is refused after entering its code. Reproduction-before: with **zero** IdPs the same login page offers no method at all — that is the lockout (§ 8.9).
112
+
113
+ **Dispatch is unaffected (§ 6).** The sweep stamps the **persisted base** (`base.pdf`, § 1), never by fetching the gated page, so a gate never turns a dispatched "PDF" into an Access login screen — § 8.3 makes that structural. Keep it in mind whenever a gated document is swept.
114
+
115
+ **Root landing page — a neutral portal, required for every gated site.** The § 1 project ships only the document page(s), so there is **no `/` page**. Cloudflare Access routinely lands a visitor on the **bare root `/`**: after login it does not always deep-link back to the requested path, and its logout endpoint (`/cdn-cgi/access/logout`) returns the user to `/?__cf_access_message=logged_out`. With no `/` page both land on a **404** — a client-facing portal showing a raw 404 the moment a signer logs out. So a gated site **must** ship a root `index.html`. It is a **neutral portal**, not a redirect: a single host can carry **many** gated documents (each its own slug/path/`DOC_REF` — § 3's D1 table is shared across a brand's documents, and § 1a scopes the Access app to host **and** path), and they all share one `/`. The root **cannot know which document** a visitor came from — Access provides **no logout `returnTo`/redirect parameter**, so the root cannot route by origin — so it must name no document at all.
116
+
117
+ ```html
118
+ <!doctype html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="utf-8">
122
+ <meta name="robots" content="noindex,nofollow">
123
+ <meta name="viewport" content="width=device-width, initial-scale=1">
124
+ <title>Private signing portal</title>
125
+ <style>body{font:16px system-ui,sans-serif;margin:2rem;color:#444;max-width:34rem}</style>
126
+ </head>
127
+ <body>This is a private signing portal. Use the link you were sent to open your document.</body>
128
+ </html>
129
+ ```
130
+
131
+ The portal **names no document**: no redirect to, link to, or mention of any slug/path/`DOC_REF`, and no proposal/quote/contract content. Adding a second document to the host requires **zero** change to the root — that is the test of its genericity. It carries at most the brand-neutral one-liner above, inline styles only (no external resources), and `noindex,nofollow`. The initial-open flow is unaffected and already scales: a signer clicks their unique slug link → Access challenges → returns them to that exact path; the root is never involved. **Only** the post-logout (and occasional post-login) landing hits the root.
132
+
133
+ **The root must make no auth-state claim and must never branch on `__cf_access_message` (or any Access query param).** Cloudflare **preserves `__cf_access_message=logged_out` on the URL through the entire re-login redirect** — so a user who clicks "sign back in", enters a fresh code, and is genuinely authenticated is landed back on `/` with the **stale** param still attached. A page that showed a "you've been signed out" interstitial when the param is present would show it to a logged-in user. A static page cannot tell "just logged out" from "just logged back in" from the URL alone — the only reliable signal is auth state, which a static page cannot read. The neutral portal sidesteps this entirely: it shows the **same** content in every state, so it is correct whether served to a logged-out or a re-authenticated browser. Log out of any document → `/` (with or without the stale param) → the neutral portal, never a logged-out screen and never another document's content; bare `/` (no session) → the same neutral portal; return is via the signer's original unique link → Access OTP → their document. (A graceful sign-out acknowledgement, or a real menu of the host's documents, would each need a signal a static public root cannot carry — both out of scope.)
53
134
 
54
135
  ---
55
136
 
@@ -59,14 +140,31 @@ The form is the document's last section. It carries three fixed controls plus wh
59
140
 
60
141
  - **`name`** — required.
61
142
  - **`email`** — required (this is where the signer's confirmation is sent).
62
- - **An agreement checkbox** — required; the signer cannot submit without ticking it.
143
+ - **An intent-and-consent checkbox** — required; the signer cannot submit without ticking it. Its wording captures **intent to sign electronically and consent to transact electronically** — not bare assent to terms — because that intention is what gives a simple electronic signature legal effect (intro; § 8.7).
63
144
  - **Operator-defined fields** — any extra inputs the specific document needs (company, project address, a quoted figure to confirm). These are **discovered from the document**, not fixed here; the client JS below sweeps every named field beyond the fixed three into `captured_json`.
64
145
 
65
- On submit, client JS mints an **acceptance token** and POSTs JSON to the Pages Function. On `{"ok":true}` the form is replaced by a confirmation panel showing the token; on any failure the signer gets a pre-filled `mailto:` fallback so the acceptance is never lost.
146
+ **Two faces, one HTML wet-ink prints, the form does not.** A signable document carries two mutually-exclusive surfaces: the digital `#sign` form (how it is actually signed) and, optionally, a wet-ink signature block (for a printed counterpart). Scope each so it never appears on the other surface this is what prevents the "two signatures on one page" bug:
147
+
148
+ ```css
149
+ @media screen { .wet-ink { display: none } } /* wet-ink never shows on screen */
150
+ @media print { #sign { display: none } } /* the form never prints */
151
+ ```
152
+
153
+ The dispatched PDF therefore carries the agreed content and (if present) the wet-ink block, never an empty live form; the on-screen page carries only the digital form.
154
+
155
+ **Print pagination — verify pages, not dimensions.** A screen-first document paginates badly to PDF (a full-bleed footer split across two pages, a near-blank trailing page). In the document's styles, force the page model rather than trusting the default flow:
156
+
157
+ - `@page { size: A4; margin: … }` and explicit page-breaks (`break-before`/`break-after: page`) around full-bleed sections;
158
+ - `break-inside: avoid` on logical blocks (a pricing table, a signature block, a certificate) so they never split;
159
+ - `break-after: avoid` on headings so a heading never lands alone at a page foot.
66
160
 
67
- **Acceptance token** `<DOC_REF>-<5-char base36 timestamp>-<6-char djb2 hash>`. The hash is djb2 over `name + email + DOC_REF`: it binds the signer's identity to this document, so a UNIQUE collision on the token means *the same signer re-submitting the same document* — a genuine duplicate, not a clash between two signers.
161
+ Confirming A4 *dimensions* is not proof of fit **read every rendered page** of the base PDF 5) before it is persisted.
68
162
 
69
- The confirmation and fallback panels are built with `textContent` and DOM nodes, never `innerHTML` the signer's own name and email flow back into the page, and `innerHTML` would make that an injection surface.
163
+ **The signing flow.** On load, the page asks the status Function whether this `DOC_REF` is already accepted (below); if so it shows a closed state instead of the form. On submit, client JS mints an **acceptance token** and POSTs JSON to the accept Function. On `{"ok":true}` the form is replaced by a confirmation panel showing the token; if the Function reports the document **already accepted** the signer sees a closed-state message; on any other failure the signer gets a pre-filled `mailto:` fallback so the acceptance is never lost.
164
+
165
+ **Acceptance token** — `<DOC_REF>-<5-char base36 timestamp>-<6-char djb2 hash>`. The hash is djb2 over `name + email + DOC_REF`: it binds the signer's identity to this document, so a UNIQUE collision on the token means *the same signer re-submitting the same document* — a genuine same-signer duplicate, distinct from a *different* signer hitting an already-accepted document (which the `doc_ref` guard catches; § 4).
166
+
167
+ All confirmation, closed-state, and fallback panels are built with `textContent` and DOM nodes, never `innerHTML` — the signer's own name and email flow back into the page, and `innerHTML` would make that an injection surface.
70
168
 
71
169
  ```html
72
170
  <section id="sign">
@@ -78,7 +176,8 @@ The confirmation and fallback panels are built with `textContent` and DOM nodes,
78
176
  <input name="company" placeholder="Company">
79
177
  <input name="project_address" placeholder="Project address"> -->
80
178
  <label><input type="checkbox" name="agree" required>
81
- I have read and agree to the terms above.</label>
179
+ I have read and agree to the terms above; I intend this to be my electronic
180
+ signature and I agree to sign and transact electronically.</label>
82
181
  <button type="submit">Sign</button>
83
182
  </form>
84
183
  <div id="confirm" hidden></div>
@@ -87,11 +186,36 @@ The confirmation and fallback panels are built with `textContent` and DOM nodes,
87
186
  <script>
88
187
  const DOC_REF = "QUOTE-LAKESIDE"; // unique per document; never reused for changed terms
89
188
  const ACCEPT_URL = "/api/accept";
189
+ const STATUS_URL = "/api/status";
90
190
  const OWNER_MAILTO = "OWNER@EXAMPLE.COM"; // fallback recipient = the business's configured address
91
191
 
92
192
  // 6-char base36 djb2 — deterministic over signer identity + document.
93
193
  function djb2(s){let h=5381;for(let i=0;i<s.length;i++)h=((h<<5)+h+s.charCodeAt(i))>>>0;return h.toString(36).slice(-6).padStart(6,"0");}
94
194
 
195
+ // Render the "already accepted" closed state in place of the form. textContent only.
196
+ function showClosed(msg) {
197
+ const f = document.getElementById("esign");
198
+ const c = document.getElementById("confirm");
199
+ if (f) f.hidden = true;
200
+ c.textContent = "";
201
+ c.hidden = false;
202
+ const p = document.createElement("p");
203
+ p.textContent = msg;
204
+ c.append(p);
205
+ }
206
+
207
+ // On load, ask the status Function whether this DOC_REF is already accepted.
208
+ // Fail OPEN: if the status call errors, show the form — the accept Function (§ 4)
209
+ // remains the authoritative close, so a degraded status read never lets a second
210
+ // acceptance through.
211
+ (async () => {
212
+ try {
213
+ const r = await fetch(`${STATUS_URL}?doc_ref=${encodeURIComponent(DOC_REF)}`);
214
+ const j = await r.json();
215
+ if (j && j.accepted) showClosed("This proposal has already been accepted.");
216
+ } catch (_) { /* fail open — leave the form visible */ }
217
+ })();
218
+
95
219
  document.getElementById("esign").addEventListener("submit", async (e) => {
96
220
  e.preventDefault();
97
221
  const f = e.target;
@@ -115,6 +239,9 @@ document.getElementById("esign").addEventListener("submit", async (e) => {
115
239
  method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
116
240
  });
117
241
  const j = await r.json();
242
+ // Already closed by an earlier acceptance (a different signer, or a stale link).
243
+ // Checked BEFORE the generic !ok throw so it never falls through to the mailto fallback.
244
+ if (j.alreadyAccepted) { showClosed("This proposal has already been accepted."); return; }
118
245
  if (!j.ok) throw new Error(j.error || "rejected");
119
246
  f.hidden = true; c.hidden = false;
120
247
  const p = document.createElement("p");
@@ -140,8 +267,6 @@ document.getElementById("esign").addEventListener("submit", async (e) => {
140
267
 
141
268
  `OWNER_MAILTO` is the **business's own configured address** — read it from configured business identity, never invent one. It is the fallback path only; the primary path is the Function.
142
269
 
143
- **PDF tip:** add `@media print { #sign { display: none } }` to the document's styles so the dispatched PDF carries the agreed content, not an empty form.
144
-
145
270
  ---
146
271
 
147
272
  ## 3. D1 table — one per brand, shared across that brand's documents
@@ -152,7 +277,7 @@ Follow `d1-data-capture.md` for creating the database, the `wrangler.toml` bindi
152
277
  CLOUDFLARE_API_TOKEN="${MINTED_PAGES_D1}" wrangler d1 execute <db-name> --remote --command \
153
278
  "CREATE TABLE IF NOT EXISTS acceptances (
154
279
  id INTEGER PRIMARY KEY AUTOINCREMENT,
155
- doc_ref TEXT NOT NULL,
280
+ doc_ref TEXT NOT NULL UNIQUE,
156
281
  name TEXT NOT NULL,
157
282
  email TEXT NOT NULL,
158
283
  token TEXT NOT NULL UNIQUE,
@@ -162,13 +287,31 @@ CLOUDFLARE_API_TOKEN="${MINTED_PAGES_D1}" wrangler d1 execute <db-name> --remote
162
287
  );"
163
288
  ```
164
289
 
165
- One `acceptances` table serves every document the brand signs; rows are separated by `doc_ref`. `token UNIQUE` is what makes a re-submit idempotent. `swept` is the dispatch cursor (§ 6).
290
+ Two uniqueness guards, two distinct purposes:
291
+
292
+ - **`token UNIQUE`** makes the **same signer** re-submitting the **same** document idempotent (same name+email+`DOC_REF` ⇒ same token ⇒ collision ⇒ no new row).
293
+ - **`doc_ref UNIQUE`** caps the document at **one acceptance, ever** — a *different* signer (different token) cannot insert a second row for a `DOC_REF` that already holds one. This is the lifecycle lock: a signed document is closed. The database, not application logic, is the backstop, so two near-simultaneous submits (a double-click, two tabs) cannot race two rows in — exactly one wins the `doc_ref` constraint and the other is rejected.
294
+
295
+ **Keep `doc_ref` defined before `token`.** A same-signer re-submit collides on **both** unique columns at once — the same identity yields the same `token` *and* the same `doc_ref`. D1/SQLite reports the **last-defined** unique index first, so with `doc_ref` declared before `token` the dual collision surfaces as `UNIQUE constraint failed: acceptances.token`, which is exactly what routes a genuine same-signer re-submit to the idempotent `ok:true` branch (§ 4) rather than the 409. Reordering these two columns silently inverts that — the same signer would then get "already accepted". The order above is correct; do not swap it.
296
+
297
+ `swept` is the dispatch cursor (§ 6).
298
+
299
+ **The guard applies to tables created or recreated after this change.** A table that already holds more than one row for some `doc_ref` will not accept the `UNIQUE` constraint until those rows are reconciled; migrating such an existing table is out of scope here (a co-signer / multi-party model is a separate capability). For a fresh deployment the `CREATE TABLE` above is the whole story.
166
300
 
167
301
  ---
168
302
 
169
- ## 4. The Pages Function — `functions/api/accept.ts`
303
+ ## 4. The Pages Functions — `functions/api/accept.ts` and `functions/api/status.ts`
304
+
305
+ Two file-routed Functions: `POST /api/accept` records an acceptance; `GET /api/status` reports whether a `DOC_REF` is already accepted. No secret lives in either file; the `DB` binding is injected at runtime.
170
306
 
171
- Served at `POST /api/accept` by file path. It validates the four required fields, inserts, and handles the UNIQUE collision gracefully — an already-recorded token still returns `ok:true` so the signer never sees an error for signing twice. No secret lives in this file; the `DB` binding is injected at runtime.
307
+ ### `functions/api/accept.ts`
308
+
309
+ Validates the four required fields, inserts, and discriminates the two UNIQUE collisions:
310
+
311
+ - a **`token`** collision is the **same signer** re-submitting — idempotent, still `ok:true` (existing behaviour, preserved);
312
+ - a **`doc_ref`** collision is a **second acceptance** for an already-signed document — rejected with a distinct **already-accepted** response at HTTP **409**, no new row.
313
+
314
+ D1 surfaces the offending column in the constraint text (`UNIQUE constraint failed: acceptances.<col>`), which is what lets one catch branch tell the two apart.
172
315
 
173
316
  ```ts
174
317
  interface Env { DB: D1Database }
@@ -204,43 +347,127 @@ export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
204
347
  .bind(docRef, name, email, token, capturedJson)
205
348
  .run();
206
349
  } catch (err) {
207
- // UNIQUE(token) collision = same signer, same document, already recorded. Idempotent: still ok.
208
- // D1 surfaces the constraint as the stable text "UNIQUE constraint failed"; match the phrase,
209
- // not the bare word, so an unrelated error never gets swallowed as a duplicate.
210
- if (String(err).includes("UNIQUE constraint failed")) return json({ ok: true, token, duplicate: true });
350
+ const msg = String(err);
351
+ // D1 surfaces the column in the stable text "UNIQUE constraint failed: acceptances.<col>".
352
+ // token collision SAME signer, same document, already recorded. Idempotent: still ok.
353
+ if (msg.includes("UNIQUE constraint failed: acceptances.token"))
354
+ return json({ ok: true, token, duplicate: true });
355
+ // doc_ref collision → a SECOND acceptance for an already-signed document. Closed: reject, no new row.
356
+ if (msg.includes("UNIQUE constraint failed: acceptances.doc_ref"))
357
+ return json({ ok: false, error: "already accepted", alreadyAccepted: true }, 409);
358
+ // Anything else is a real failure (e.g. token missing D1 Edit — § 8.1).
211
359
  return json({ ok: false, error: "insert failed" }, 500);
212
360
  }
213
361
  return json({ ok: true, token });
214
362
  };
215
363
  ```
216
364
 
365
+ ### `functions/api/status.ts`
366
+
367
+ A read-only `GET /api/status?doc_ref=…` that reports **only** whether the document is accepted — a single boolean, **no signer PII**. The signing page (§ 2) may be reachable without authentication, so the response must never leak `name`, `email`, or `captured_json`.
368
+
369
+ ```ts
370
+ interface Env { DB: D1Database }
371
+
372
+ const cors = {
373
+ "Access-Control-Allow-Origin": "*",
374
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
375
+ "Access-Control-Allow-Headers": "Content-Type",
376
+ };
377
+ const json = (body: unknown, status = 200) =>
378
+ new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...cors } });
379
+
380
+ export const onRequestOptions: PagesFunction = async () => new Response(null, { status: 204, headers: cors });
381
+
382
+ export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
383
+ const docRef = new URL(request.url).searchParams.get("doc_ref")?.trim();
384
+ if (!docRef) return json({ ok: false, error: "doc_ref required" }, 400);
385
+
386
+ // COUNT only — never SELECT name/email/captured_json. The body carries one boolean.
387
+ const row = await env.DB
388
+ .prepare("SELECT count(*) AS c FROM acceptances WHERE doc_ref = ?")
389
+ .bind(docRef)
390
+ .first<{ c: number }>();
391
+
392
+ return json({ accepted: (row?.c ?? 0) > 0 });
393
+ };
394
+ ```
395
+
217
396
  **The single most common breakage — token scope.** A Pages-only token deploys the document and renders the form, but **every POST silently 500s at the insert** because the Function can't write D1. The minted token must carry **both Pages Edit and D1 Edit** (`d1-data-capture.md` § 0). When acceptances stop arriving, this is the first thing to check.
218
397
 
219
398
  ---
220
399
 
221
- ## 5. Deploy
400
+ ## 5. Deploy — and render the base PDF once
401
+
402
+ Deploy the document + both Functions via `hosting-sites.md`, with the both-Pages-Edit-and-D1-Edit token. The `[[d1_databases]]` binding in `wrangler.toml` is what wires the deployed Functions to the `acceptances` database.
403
+
404
+ **Gated sites also ship the neutral root `index.html` (§ 1a).** When the document is Access-gated, the neutral portal goes in the **same Pages output dir** as the document(s), so the production deploy serves it at `/` in the same shot — no separate deploy. Redeploying *only* the root portal needs a **Pages-Write-only** token: the portal touches no database, so no D1 scope is minted for it.
405
+
406
+ **Render the base PDF once, here, keyed by `DOC_REF`.** Immediately after the deploy, render the live document to PDF a single time and persist it under the account directory (§ 1). This is the *only* place the browser is used — `browser-navigate` to the deployed document URL, then `browser-pdf-save`, the `invoicing.md` step-4 pattern:
222
407
 
223
- Deploy the document + Function via `hosting-sites.md`, with the both-Pages-Edit-and-D1-Edit token. The `[[d1_databases]]` binding in `wrangler.toml` is what wires the deployed Function to the `acceptances` database.
408
+ ```bash
409
+ mkdir -p "${ACCOUNT_DIR}/e-sign/<DOC_REF>"
410
+ # browser-navigate <deployed-url> ; browser-pdf-save -> ${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf
411
+ shasum -a 256 "${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf" | awk '{print $1}' \
412
+ > "${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.sha256"
413
+ ```
414
+
415
+ Before persisting, **read every rendered page** of `base.pdf` — full-bleed pages bleed correctly, no footer split across pages, no near-blank trailing page (§ 2 pagination; A4 dimensions are not proof of fit). If the deployed page is behind Cloudflare Access, do this base render in a session that can authenticate to Access — it is a one-time, operator-driven step, unlike dispatch (§ 6), which must never fetch the page at all. A new `DOC_REF` triggers a fresh render; an existing one keeps its persisted base.
224
416
 
225
417
  ---
226
418
 
227
419
  ## 6. Sweep + dispatch
228
420
 
229
- When the operator asks for new acceptances (there is no standing cron — see § 8.2 for the reconciliation a scheduled task would run). Two tokens are minted here, both per `api.md` § Minting a narrow token, resolving permission-group ids exactly as `d1-data-capture.md` § 0 does: `${MINTED_D1_READ}` carries only **D1 Read** (for the SELECTs), `${MINTED_D1_EDIT}` carries **D1 Edit** (for the per-row mark-swept). Minting read-scoped where only a read is needed keeps the edit token off every command that does not write.
421
+ When the durable routine (§ 8.2) runs, or when the operator asks for new acceptances. Mint one **D1-Edit** token here `${MINTED_D1_EDIT}` — per `api.md` § Minting a narrow token, resolving the permission-group id as `d1-data-capture.md` § 0 does. Reads use the Edit token too: the D1 **query** endpoint rejects a D1-Read token (`d1-data-capture.md`), so a separate read-scoped token is not minted.
230
422
 
231
- 1. **Read the unswept rows** with `${MINTED_D1_READ}`:
423
+ 1. **Read the unswept rows** with `${MINTED_D1_EDIT}`:
232
424
 
233
425
  ```bash
234
- CLOUDFLARE_API_TOKEN="${MINTED_D1_READ}" wrangler d1 execute <db-name> --remote --json --command \
426
+ CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --json --command \
235
427
  "SELECT id, doc_ref, name, email, token, captured_json, accepted_at
236
428
  FROM acceptances WHERE swept = 0 ORDER BY id;"
237
429
  ```
238
430
 
239
- 2. **For each unswept row, in order — dispatch, then mark that one row swept:**
240
- - **Render the signed content to PDF.** `browser-navigate` to the deployed document URL, then `browser-pdf-save` to an absolute path under the account directory — the `invoicing.md` step-4 pattern. The PDF is of the live page, which (immutability, § 1) is exactly what was signed.
241
- - **Send twice via `email-send`**, each carrying `doc_ref`, `name`, `token`, `accepted_at`, and the PDF as an attachment:
431
+ 2. **For each unswept row, in order — stamp, send, then mark that one row swept:**
432
+
433
+ - **Stamp a per-signer copy from the persisted base — no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it — all with a pure PDF library (**`pdf-lib`**, run as a small Node script; pick another pure library if the account standardises on one, but it must add no browser and no network call):
434
+
435
+ ```js
436
+ // stamp.mjs — node, offline. Reads the persisted base, appends a certificate page,
437
+ // writes ${ACCOUNT_DIR}/e-sign/<DOC_REF>/signed-<token>.pdf. No network, no browser.
438
+ import { readFileSync, writeFileSync } from "node:fs";
439
+ import { PDFDocument, StandardFonts } from "pdf-lib";
440
+
441
+ const dir = process.env.DIR; // ${ACCOUNT_DIR}/e-sign/<DOC_REF>
442
+ const cert = JSON.parse(process.env.CERT); // {name,title,token,doc_ref,accepted_at,method,assertion}
443
+ const pdf = await PDFDocument.load(readFileSync(`${dir}/base.pdf`));
444
+ const font = await pdf.embedFont(StandardFonts.Helvetica);
445
+ const page = pdf.addPage();
446
+ const lines = [
447
+ "Electronic Acceptance Certificate",
448
+ `Name: ${cert.name}`,
449
+ `Title: ${cert.title ?? "-"}`,
450
+ `Doc ref: ${cert.doc_ref}`,
451
+ `Token: ${cert.token}`,
452
+ `Accepted: ${cert.accepted_at}`,
453
+ `Method: ${cert.method}`, // e.g. "simple electronic signature (UK ECA 2000 s.7 / UK eIDAS)"
454
+ "",
455
+ "Assertion the signer ticked:",
456
+ cert.assertion, // the verbatim intent/consent text from § 2
457
+ ];
458
+ let y = page.getHeight() - 60;
459
+ for (const ln of lines) { page.drawText(ln, { x: 50, y, size: 11, font }); y -= 20; }
460
+ writeFileSync(`${dir}/signed-${cert.token}.pdf`, await pdf.save());
461
+ ```
462
+
463
+ The certificate fields come **from the D1 row**, not a re-fetch: `name`, `title` (an operator-defined field if present), `token`, `doc_ref`, `accepted_at`, the signing `method`, and the **verbatim intent/consent assertion** the signer ticked (§ 2). That makes the artifact self-contained evidence of who accepted, when, and on what stated basis.
464
+
465
+ - **Send twice via `email-send`**, each carrying `doc_ref`, `name`, `token`, `accepted_at`, and the **stamped** PDF (`signed-<token>.pdf`) as an attachment:
242
466
  - once to the **business owner's configured address** (read from configured business identity — never hard-code a recipient in this workflow);
243
- - once to the **signer's captured `email`**.
467
+ - once to the **signer's captured `email`** — this copy is the **statutory record provided to the signer**, the evidentiary copy the legal basis (intro) relies on.
468
+
469
+ **Owner-copy visibility.** The owner copy only *arrives* if the agent's mailbox actually receives mail addressed to the business address. A recipient-filter that drops mail to the business address (the bug fixed in Task 700 / `rubytech-shared-catchall-mailbox`) makes the owner copy vanish while the signer copy arrives — check that filter first if the owner reports a missing confirmation.
470
+
244
471
  - **Mark this row — and only this row — swept, once both sends return a verified result**, keyed by its `id`:
245
472
 
246
473
  ```bash
@@ -248,29 +475,41 @@ When the operator asks for new acceptances (there is no standing cron — see §
248
475
  "UPDATE acceptances SET swept = 1 WHERE id = <row-id>;"
249
476
  ```
250
477
 
251
- Never flip `swept` on assumption. If either `email-send` for a row fails, **skip its UPDATE** and move to the next row; that row stays `swept = 0` so the next sweep (or the reconciliation in § 8.2) re-surfaces it. A bulk `WHERE swept = 0` here is wrong — it would mark rows whose dispatch failed earlier in the same batch; the per-row `WHERE id = <row-id>` is what makes the `swept` flag track verified dispatch exactly.
478
+ Never flip `swept` on assumption. If either `email-send` for a row fails (including a silent degrade to text-only when the attachment is missing — § 0 item 3), **skip its UPDATE** and move to the next row; that row stays `swept = 0` so the next sweep (or the reconciliation in § 8.2) re-surfaces it. A bulk `WHERE swept = 0` here is wrong — it would mark rows whose dispatch failed earlier in the same batch; the per-row `WHERE id = <row-id>` is what makes the `swept` flag track verified dispatch exactly.
252
479
 
253
480
  ---
254
481
 
255
482
  ## 7. Outcome contract
256
483
 
257
- The acceptance flow is proven end-to-end on the **live** Pages deployment — the D1 row, not the POST status, is the contract:
484
+ The acceptance flow is proven end-to-end on the **live** Pages deployment — the D1 row, not the POST status, is the contract. Every `wrangler d1 execute` uses the **D1-Edit** token (reads included; the query endpoint rejects D1 Read):
258
485
 
259
486
  ```bash
260
- # 1. POST a test acceptance to the live Function.
487
+ # 1. POST a first acceptance to the live Function.
261
488
  curl -sS -X POST -H "Content-Type: application/json" \
262
489
  -d '{"name":"verify","email":"test@example.com","token":"QUOTE-LAKESIDE-abcde-zzzzzz","doc_ref":"QUOTE-LAKESIDE"}' \
263
490
  "https://<project>.pages.dev/api/accept"
264
491
  # expect: {"ok":true,"token":"QUOTE-LAKESIDE-abcde-zzzzzz"}
265
492
 
266
493
  # 2. The row exists in the unswept set. (A 200 with no row here is a FAILURE — see § 8.1.)
267
- CLOUDFLARE_API_TOKEN="${MINTED_D1_READ}" wrangler d1 execute <db-name> --remote --command \
494
+ CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
268
495
  "SELECT token FROM acceptances WHERE swept = 0;"
269
496
 
270
- # 3. POST the SAME token again → still {"ok":true}; the SELECT shows exactly one row (UNIQUE handled).
497
+ # 3. SAME signer idempotency: POST the SAME token again → still {"ok":true}; the SELECT shows exactly one row.
498
+
499
+ # 4. LOCK: POST a DIFFERENT name/email for the SAME doc_ref → 409 {"ok":false,"error":"already accepted","alreadyAccepted":true}
500
+ curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H "Content-Type: application/json" \
501
+ -d '{"name":"other","email":"other@example.com","token":"QUOTE-LAKESIDE-fghij-yyyyyy","doc_ref":"QUOTE-LAKESIDE"}' \
502
+ "https://<project>.pages.dev/api/accept" # expect: 409
503
+ CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
504
+ "SELECT count(*) c FROM acceptances WHERE doc_ref = 'QUOTE-LAKESIDE';" # expect: 1 (no second row)
271
505
 
272
- # 4. Run the sweep 6): both the owner address and test@example.com hold the confirmation email,
273
- # each with the signed-content PDF attached; re-running the SELECT WHERE swept = 0 no longer returns the row.
506
+ # 5. STATUS: the read-only endpoint flips false true and carries NO signer PII.
507
+ curl -sS "https://<project>.pages.dev/api/status?doc_ref=QUOTE-LAKESIDE"
508
+ # expect: {"accepted":true} — and no name/email/captured_json anywhere in the body.
509
+
510
+ # 6. Run the sweep (§ 6): both the owner address and test@example.com hold the confirmation email,
511
+ # each with the stamped certificate-bearing PDF attached; re-running the SELECT WHERE swept = 0
512
+ # no longer returns the row.
274
513
  ```
275
514
 
276
515
  Surface every step as verb + target ("inserting a test acceptance", "sweeping unswept rows from `<db-name>`"). **Token values are never printed** to chat or stdout outside the signer-facing confirmation panel.
@@ -279,31 +518,114 @@ Surface every step as verb + target ("inserting a test acceptance", "sweeping un
279
518
 
280
519
  ## 8. Failure modes and the signal for each
281
520
 
282
- 1. **POST silently 500s at the insert (token missing D1 Edit).** Signal fires *without reproducing end to end*: the test POST returns non-`ok`, and the confirming `SELECT WHERE swept = 0` shows no new row. Fix: re-mint the token confirming **both** Pages Edit and D1 Edit (`d1-data-capture.md` § 0). This is the first thing to check when acceptances stop arriving.
521
+ The flow has no application logging the signal surface is the **D1 row state** plus the persisted-artifact filesystem, reconciled by standing checks. Every `wrangler d1 execute` below uses the **D1-Edit** token. The failure modes that emit no event are the ones this section exists to cover.
283
522
 
284
- 2. **Acceptance recorded but never dispatched (no-event failure).** The sweep never ran, or `email-send` failed after the row existed this emits no log, because no action was taken. Detection is a **standing reconciliation, not a hung log line**:
523
+ ### 8.1 POST silently 500s at the insert (token missing D1 Edit)
285
524
 
286
- ```bash
287
- CLOUDFLARE_API_TOKEN="${MINTED_D1_READ}" wrangler d1 execute <db-name> --remote --command \
288
- "SELECT count(*), min(accepted_at) FROM acceptances WHERE swept = 0;"
289
- ```
525
+ Signal fires *without reproducing end to end*: the test POST returns non-`ok`, and the confirming `SELECT WHERE swept = 0` shows no new row. Fix: re-mint the token confirming **both** Pages Edit and D1 Edit (`d1-data-capture.md` § 0). First thing to check when acceptances stop arriving.
290
526
 
291
- Any row whose `accepted_at` is older than the sweep cadence is an undispatched acceptance. Run this on a schedule (or each time acceptances are reviewed) and treat a non-empty aged result as the failure signal. Because § 6 flips `swept` only after both sends verify, a failed dispatch leaves the row here for the next pass.
527
+ ### 8.2 Acceptance recorded but never dispatched (no-event) the durable health check
292
528
 
293
- 3. **Document modified after an acceptance (signed-content drift).** Signal: the dispatched PDF no longer matches what was signed. Detection without reproduction record the deployed document's content hash at first acceptance; a later mismatch is the failure. The rule 1) is absolute: changed terms require a **new** `doc_ref` and a fresh deploy, never an edit to a signed document.
529
+ The sweep never ran, or `email-send` failed after the row existed no log, because no action was taken. The sweep 6) runs from a **durable platform routine**, not a session-only cron: a session cron dies with the session and produces no signal, so aged acceptances would sit undispatched silently. The routine's **health check** is the standing reconciliation:
294
530
 
295
- 4. **Duplicate-acceptance row.** Signal: a `token` appears more than once — the UNIQUE handling regressed.
531
+ ```bash
532
+ CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
533
+ "SELECT count(*), min(accepted_at) FROM acceptances WHERE swept = 0;"
534
+ ```
296
535
 
297
- ```bash
298
- CLOUDFLARE_API_TOKEN="${MINTED_D1_READ}" wrangler d1 execute <db-name> --remote --command \
299
- "SELECT token, count(*) c FROM acceptances GROUP BY token HAVING c > 1;"
300
- ```
536
+ Any row whose `accepted_at` is older than the routine's cadence is an undispatched acceptance — the failure signal. Because § 6 flips `swept` only after both sends verify (and a text-only degrade is treated as a failed send, § 0 item 3), a failed or degraded dispatch leaves the row here for the next pass.
537
+
538
+ ### 8.3 Document modified after an acceptance, or base render missing (signed-content drift)
539
+
540
+ Two faces of the same integrity guarantee:
541
+
542
+ - **Drift is prevented structurally, not detected after the fact.** Dispatch (§ 6) stamps the **persisted** `base.pdf`, never the live page, so a post-acceptance edit to the deployed HTML cannot reach the dispatched record or the signer's copy — the evidentiary artifact is fixed at deploy. The document is also closed to new signers (the `doc_ref` lock plus the § 2 already-accepted state), so a later edit changes nothing of record. The § 1 immutability rule remains the operator policy: changed terms are a **new** `doc_ref` and a fresh deploy, never an edit to a signed document. (`base.sha256` is the integrity hash of the persisted base **PDF**, used below to confirm the base is intact — it is *not* a hash of the live HTML page, and the live page is never re-hashed for comparison.)
543
+ - **Render-once integrity (the standing check):** for every distinct `doc_ref` with rows, a persisted `base.pdf` must exist under `${ACCOUNT_DIR}/e-sign/<DOC_REF>/` and still match its recorded `base.sha256`. A missing base (dispatch then has nothing to stamp) or a corrupted/swapped base (hash mismatch) is the failure; it fires at the standing check, not at dispatch:
544
+
545
+ ```bash
546
+ # for each doc_ref with rows: the persisted base must exist and still match its recorded hash
547
+ d="${ACCOUNT_DIR}/e-sign/<DOC_REF>"
548
+ { [ -s "$d/base.pdf" ] && [ -s "$d/base.sha256" ] \
549
+ && [ "$(shasum -a 256 "$d/base.pdf" | awk '{print $1}')" = "$(cat "$d/base.sha256")" ] ; } \
550
+ && echo "base ok" || echo "BASE MISSING OR CORRUPT"
551
+ ```
552
+
553
+ ### 8.4 Duplicate-acceptance row by token (same-signer idempotency regressed)
554
+
555
+ Signal: a `token` appears more than once — the `token`-UNIQUE handling regressed.
556
+
557
+ ```bash
558
+ CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
559
+ "SELECT token, count(*) c FROM acceptances GROUP BY token HAVING c > 1;"
560
+ ```
561
+
562
+ Healthy signature: re-POSTing a token leaves its count at one.
563
+
564
+ ### 8.5 More than one acceptance per `doc_ref` (the lock failed — primary standing check)
565
+
566
+ The lock's failure is a **no-event** failure: an extra row appears silently — the `doc_ref` guard was dropped on a redeploy, never applied to a pre-existing table, or a race slipped through. This is the primary lock check; run it on the same cadence as the § 8.2 reconciliation, reusing the same D1-Edit token:
567
+
568
+ ```bash
569
+ CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
570
+ "SELECT doc_ref, count(*) c FROM acceptances GROUP BY doc_ref HAVING c > 1;"
571
+ ```
572
+
573
+ Empty is healthy; **any** row is a lock failure. It fires without reproducing a second submission and without waiting for future activity.
574
+
575
+ A *wrongly-locked* `doc_ref` (a legitimately new document refused because it reused a prior `doc_ref`) is **not** a defect — it is the immutability rule (§ 1) surfacing correctly; the remedy is a new `doc_ref`, not a re-open.
576
+
577
+ ### 8.6 Status endpoint leaks PII, or is dark
578
+
579
+ - **Leak:** the `GET /api/status` response for an accepted `doc_ref` must contain **only** `accepted` — assert the body carries no `name`/`email`/`captured_json` keys (a one-shot check at deploy, repeated in the § 7 contract). A leak is a breach because the page is reachable without authentication.
580
+ - **Dark (degraded, not a breach):** if the status `GET` errors, the page **fails open** to showing the form (§ 2). This does not let a second acceptance through — the accept Function (§ 4) still rejects it with the 409. The signal that the endpoint is dark is the form showing on an already-accepted `doc_ref`; the backstop is § 8.5, which stays empty because no extra row inserts.
581
+
582
+ ### 8.7 Compliance basis degraded (no-event)
301
583
 
302
- Healthy signature: re-POSTing a token leaves its count at one.
584
+ A hand-built document ships the old bare "agree to terms" checkbox (no intent/consent), or the certificate omits the assertion — the artifact dispatches and stores but lacks the evidentiary basis the "legally-recordable" claim depends on, with no error at any step. Signal — a **deploy-time** check, not a runtime log: grep the deployed page for the intent/consent checkbox wording (§ 2) before first acceptance, and confirm the first stamped certificate carries the assertion verbatim. Absence is the failure, caught at deploy/first-dispatch, not after a dispute.
585
+
586
+ ### 8.8 Token-type / freshness failures (handled branches, not no-event)
587
+
588
+ A `cfut_…` master at an account endpoint returns `9109`; a freshly-minted token returns `10000` for a few seconds. Both are explicit, reproducible API responses — `api.md` § Token type routes by prefix and § verify-with-backoff absorbs the `10000` window. Diagnostic path: the verify call's JSON `errors[].code`.
589
+
590
+ ### 8.9 Access gate active with zero enabled IdPs (no-event lockout)
591
+
592
+ Only relevant when the document is Access-gated (§ 1a). An Access app is active on the page while the org has **no** identity provider, so the login screen offers no method and everyone — operator included — is locked out. It is a **no-event** failure: the gate emits no log and does not reproduce until someone tries to log in, by which point they are already locked out. Detection is a **standing audit reconciling active Access apps against enabled login methods**:
593
+
594
+ ```bash
595
+ # ≥1 whenever any Access app is active = healthy; 0 with an active app = lockout.
596
+ CLOUDFLARE_API_TOKEN="${MINTED_ACCESS}" curl -sS \
597
+ "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/access/identity_providers" \
598
+ | jq '.result | length'
599
+ ```
600
+
601
+ Run on the same cadence as the § 8.2 reconciliation, but with an **Access-scoped** token — not the `${MINTED_D1_EDIT}` the other §8 checks share (this is a `curl` to the Access API, not a `wrangler d1` query). Two adjacent failures are **not** this one and need a different fix: a signer **refused after** the login page offered One-Time PIN is an allowlist miss (their email is absent from the policy `include`, § 1a); a signer who never receives the code despite the method being offered is an email-deliverability problem, not a gate defect. Gating is configuration state, so dispatch is unaffected — the sweep still stamps the persisted base (§ 1a, § 6), never the gated page.
303
602
 
304
603
  **Ground-truth lifeline** for one document's whole acceptance lifecycle:
305
604
 
306
605
  ```bash
307
- CLOUDFLARE_API_TOKEN="${MINTED_D1_READ}" wrangler d1 execute <db-name> --remote --json --command \
308
- "SELECT id, doc_ref, token, accepted_at, swept FROM acceptances WHERE doc_ref = 'QUOTE-LAKESIDE' ORDER BY id;"
606
+ CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --json --command \
607
+ "SELECT id, doc_ref, name, token, accepted_at, swept FROM acceptances WHERE doc_ref = 'QUOTE-LAKESIDE' ORDER BY id;"
309
608
  ```
609
+
610
+ ### 8.10 Root 404 on a gated site (no-event)
611
+
612
+ Only relevant when the document is Access-gated (§ 1a). The gate is active but the project ships **no `/` page**, so every Cloudflare landing on the bare root — after login (no deep-link) and after logout (`/?__cf_access_message=logged_out`) — hits a **404**. It is a **no-event** failure: nothing logs it, and it does not reproduce until a signer logs out or opens the bare domain, by which point they are staring at a raw 404 on a client portal. Signal — a deploy-time check tied to gating: whenever the § 1a gate is applied, the deployed output dir must contain an `index.html`, and that `index.html` must be the **neutral portal** — no redirect to or path of any specific document. The root is a **static file you author in the output dir** (§ 1a) and Pages serves that dir verbatim, so the check runs against your **local output dir** — the exact bytes Pages serves — and never needs to fetch the Access-gated `/`:
613
+
614
+ ```bash
615
+ DIST=<pages-output-dir> # the dir deployed in § 5
616
+ test -f "$DIST/index.html" \
617
+ || echo "FAIL 8.10: gated site ships no root page"
618
+ grep -qE 'location\.replace\(|http-equiv="refresh"' "$DIST/index.html" \
619
+ && echo "FAIL 8.10: root redirects to a specific document (single-document regression)"
620
+ grep -q "__cf_access_message" "$DIST/index.html" \
621
+ && echo "FAIL 8.11: root branches on a sticky Access param"
622
+ ```
623
+
624
+ ### 8.11 Sticky-param interstitial or document-specific root (the rejected designs regressing)
625
+
626
+ Two distinct regressions of the § 1a neutral portal, both caught by the § 8.10 grep of the local output dir:
627
+
628
+ - **Document-specific root (single-document regression).** The root redirects to or embeds **one** document's slug/path/content (the Task 720 design this supersedes), so it is wrong for every other document on the host and breaks silently the moment a second document is added — no event, until a signer for document B logs out and lands on document A. Signature: any `location.replace(`/meta-refresh or document path in the root. The fix is the neutral portal — no document-specific path, no document content.
629
+ - **Sticky-param interstitial.** The root **branches on `__cf_access_message=logged_out`** — showing a "signed out" interstitial when the param is present. Because Cloudflare preserves that param through the **entire re-login redirect**, a re-authenticated signer is landed back on `/` with the **stale** param and shown a logged-out screen *while logged in*. Signature: any reference to `__cf_access_message` in the root. The fix is the neutral portal — it makes no auth-state claim, so it is correct in every state.
630
+
631
+ The neutral portal must therefore contain **no** document-specific path **and no** auth-state claim; it shows the same content unconditionally.