@rubytech/create-realagent-code 0.1.576 → 0.1.578

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 (232) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
  3. package/payload/platform/lib/active-rules/dist/index.js +32 -1
  4. package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
  5. package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
  6. package/payload/platform/lib/active-rules/src/index.ts +36 -2
  7. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
  8. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
  9. package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
  10. package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
  11. package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
  12. package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
  13. package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
  14. package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
  15. package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
  16. package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
  17. package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
  18. package/payload/platform/lib/routine-templates/dist/index.d.ts +4 -4
  19. package/payload/platform/lib/routine-templates/dist/index.js +4 -4
  20. package/payload/platform/lib/routine-templates/src/index.ts +4 -4
  21. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
  22. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
  23. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -2
  24. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +17 -0
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
  30. package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
  31. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
  32. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
  33. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
  34. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
  35. package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
  36. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
  37. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
  38. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
  39. package/payload/platform/plugins/docs/references/internals.md +1 -1
  40. package/payload/platform/plugins/docs/references/platform.md +2 -0
  41. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +0 -2
  42. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  43. package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
  44. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  45. package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
  46. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  47. package/payload/platform/services/telegram-channel/dist/server.js +1 -1
  48. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  49. package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
  50. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  51. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  52. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
  53. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  54. package/payload/server/public/activity.html +4 -4
  55. package/payload/server/public/agents.html +3 -3
  56. package/payload/server/public/assets/{AdminLoginScreens-BLGZEaKU.js → AdminLoginScreens-CAZ3DSwA.js} +1 -1
  57. package/payload/server/public/assets/AdminLoginScreens-CAZ3DSwA.js.br +0 -0
  58. package/payload/server/public/assets/AdminLoginScreens-CAZ3DSwA.js.gz +0 -0
  59. package/payload/server/public/assets/{AdminShell-C_M7YE5Z.js → AdminShell-BLs5v0qF.js} +1 -1
  60. package/payload/server/public/assets/AdminShell-BLs5v0qF.js.br +0 -0
  61. package/payload/server/public/assets/AdminShell-BLs5v0qF.js.gz +0 -0
  62. package/payload/server/public/assets/{activity-BL6qbB9a.js → activity-BwPck2yT.js} +1 -1
  63. package/payload/server/public/assets/activity-BwPck2yT.js.br +0 -0
  64. package/payload/server/public/assets/activity-BwPck2yT.js.gz +0 -0
  65. package/payload/server/public/assets/{admin-jnJ3P2u4.js → admin-DjtOoabA.js} +1 -1
  66. package/payload/server/public/assets/admin-DjtOoabA.js.br +0 -0
  67. package/payload/server/public/assets/admin-DjtOoabA.js.gz +0 -0
  68. package/payload/server/public/assets/{agents-BzhfKZDN.js → agents-IQYaRvoh.js} +1 -1
  69. package/payload/server/public/assets/agents-IQYaRvoh.js.br +0 -0
  70. package/payload/server/public/assets/agents-IQYaRvoh.js.gz +0 -0
  71. package/payload/server/public/assets/{browser-C-6io2B9.js → browser-CPG8w8rQ.js} +1 -1
  72. package/payload/server/public/assets/browser-CPG8w8rQ.js.br +0 -0
  73. package/payload/server/public/assets/browser-CPG8w8rQ.js.gz +0 -0
  74. package/payload/server/public/assets/{calendar-Dt3gS88s.js → calendar-CRwrgNdq.js} +1 -1
  75. package/payload/server/public/assets/calendar-CRwrgNdq.js.br +0 -0
  76. package/payload/server/public/assets/calendar-CRwrgNdq.js.gz +0 -0
  77. package/payload/server/public/assets/chat-B7yZ4KLl.js +1 -0
  78. package/payload/server/public/assets/chat-B7yZ4KLl.js.br +3 -0
  79. package/payload/server/public/assets/chat-B7yZ4KLl.js.gz +0 -0
  80. package/payload/server/public/assets/chevron-left-BzKUQqLy.js +1 -0
  81. package/payload/server/public/assets/chevron-left-BzKUQqLy.js.br +0 -0
  82. package/payload/server/public/assets/clock-BafWFNGo.js +1 -0
  83. package/payload/server/public/assets/clock-BafWFNGo.js.br +0 -0
  84. package/payload/server/public/assets/{copy-TkPJgllS.js → copy-JyiMTV0k.js} +1 -1
  85. package/payload/server/public/assets/copy-JyiMTV0k.js.br +0 -0
  86. package/payload/server/public/assets/copy-JyiMTV0k.js.gz +0 -0
  87. package/payload/server/public/assets/data-DHtGCVrl.js +1 -0
  88. package/payload/server/public/assets/data-DHtGCVrl.js.br +0 -0
  89. package/payload/server/public/assets/data-DHtGCVrl.js.gz +0 -0
  90. package/payload/server/public/assets/{file-text-DyAL2DCh.js → file-text-cnHv9ZS6.js} +1 -1
  91. package/payload/server/public/assets/file-text-cnHv9ZS6.js.br +0 -0
  92. package/payload/server/public/assets/file-text-cnHv9ZS6.js.gz +0 -0
  93. package/payload/server/public/assets/{graph-PiNIcCfX.js → graph-lFwEUDSH.js} +1 -1
  94. package/payload/server/public/assets/graph-lFwEUDSH.js.br +0 -0
  95. package/payload/server/public/assets/graph-lFwEUDSH.js.gz +0 -0
  96. package/payload/server/public/assets/{graph-labels-8jKFrLHn.js → graph-labels-C6qUA0vO.js} +1 -1
  97. package/payload/server/public/assets/graph-labels-C6qUA0vO.js.br +0 -0
  98. package/payload/server/public/assets/graph-labels-C6qUA0vO.js.gz +0 -0
  99. package/payload/server/public/assets/{operator-DKGKGzrb.js → operator-BdoEy5mh.js} +1 -1
  100. package/payload/server/public/assets/operator-BdoEy5mh.js.br +0 -0
  101. package/payload/server/public/assets/operator-BdoEy5mh.js.gz +0 -0
  102. package/payload/server/public/assets/{page-DXz_K10G.js → page-DMrnnXNA.js} +1 -1
  103. package/payload/server/public/assets/page-DMrnnXNA.js.br +0 -0
  104. package/payload/server/public/assets/page-DMrnnXNA.js.gz +0 -0
  105. package/payload/server/public/assets/{page-BU5vlY9Y.js → page-DeMikGYL.js} +1 -1
  106. package/payload/server/public/assets/page-DeMikGYL.js.br +0 -0
  107. package/payload/server/public/assets/page-DeMikGYL.js.gz +0 -0
  108. package/payload/server/public/assets/play-D6OAQVJ_.js +1 -0
  109. package/payload/server/public/assets/play-D6OAQVJ_.js.br +0 -0
  110. package/payload/server/public/assets/play-D6OAQVJ_.js.gz +0 -0
  111. package/payload/server/public/assets/{public-BmA3-jLj.js → public-B-X1Cxfw.js} +1 -1
  112. package/payload/server/public/assets/public-B-X1Cxfw.js.br +0 -0
  113. package/payload/server/public/assets/public-B-X1Cxfw.js.gz +0 -0
  114. package/payload/server/public/assets/{rotate-ccw-fCH0LSMq.js → rotate-ccw-DVFvQAHG.js} +1 -1
  115. package/payload/server/public/assets/rotate-ccw-DVFvQAHG.js.br +0 -0
  116. package/payload/server/public/assets/rotate-ccw-DVFvQAHG.js.gz +0 -0
  117. package/payload/server/public/assets/{routines-BeFrmkls.js → routines-CgkkGJaS.js} +1 -1
  118. package/payload/server/public/assets/routines-CgkkGJaS.js.br +0 -0
  119. package/payload/server/public/assets/routines-CgkkGJaS.js.gz +0 -0
  120. package/payload/server/public/assets/{skills-mjls5HBa.js → skills-CW7gVcaG.js} +1 -1
  121. package/payload/server/public/assets/skills-CW7gVcaG.js.br +0 -0
  122. package/payload/server/public/assets/skills-CW7gVcaG.js.gz +0 -0
  123. package/payload/server/public/assets/{tasks-p6puHHD0.js → tasks-Ddjp0Rwh.js} +1 -1
  124. package/payload/server/public/assets/tasks-Ddjp0Rwh.js.br +0 -0
  125. package/payload/server/public/assets/tasks-Ddjp0Rwh.js.gz +0 -0
  126. package/payload/server/public/assets/{triangle-alert-CrZ1ewbd.js → triangle-alert-DUrUutX4.js} +1 -1
  127. package/payload/server/public/assets/triangle-alert-DUrUutX4.js.br +0 -0
  128. package/payload/server/public/assets/triangle-alert-DUrUutX4.js.gz +0 -0
  129. package/payload/server/public/assets/{useCopyFeedback-zOQNrIwd.js → useCopyFeedback-ClVINDrQ.js} +1 -1
  130. package/payload/server/public/assets/useCopyFeedback-ClVINDrQ.js.br +0 -0
  131. package/payload/server/public/assets/useCopyFeedback-ClVINDrQ.js.gz +0 -0
  132. package/payload/server/public/assets/{useMediaQuery-BmG090FR.js → useMediaQuery-BUFkZYRs.js} +1 -1
  133. package/payload/server/public/assets/useMediaQuery-BUFkZYRs.js.br +0 -0
  134. package/payload/server/public/assets/{useMediaQuery-BmG090FR.js.gz → useMediaQuery-BUFkZYRs.js.gz} +0 -0
  135. package/payload/server/public/assets/{useVoiceRecorder-9pf6FFJ-.js → useVoiceRecorder-DnyqQFet.js} +1 -1
  136. package/payload/server/public/assets/useVoiceRecorder-DnyqQFet.js.br +0 -0
  137. package/payload/server/public/assets/useVoiceRecorder-DnyqQFet.js.gz +0 -0
  138. package/payload/server/public/assets/{wrench-k4GYG5rc.js → wrench-BW28s_d0.js} +1 -1
  139. package/payload/server/public/assets/wrench-BW28s_d0.js.br +0 -0
  140. package/payload/server/public/assets/wrench-BW28s_d0.js.gz +0 -0
  141. package/payload/server/public/browser.html +3 -3
  142. package/payload/server/public/calendar.html +6 -6
  143. package/payload/server/public/chat.html +12 -12
  144. package/payload/server/public/data.html +10 -10
  145. package/payload/server/public/graph.html +8 -8
  146. package/payload/server/public/index.html +12 -12
  147. package/payload/server/public/operator.html +13 -13
  148. package/payload/server/public/public.html +12 -12
  149. package/payload/server/public/routines.html +6 -6
  150. package/payload/server/public/skills.html +4 -4
  151. package/payload/server/public/tasks.html +5 -5
  152. package/payload/server/server.js +1014 -375
  153. package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.d.ts +0 -6
  154. package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.d.ts.map +0 -1
  155. package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.js +0 -102
  156. package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.js.map +0 -1
  157. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.d.ts +0 -2
  158. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.d.ts.map +0 -1
  159. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.js +0 -87
  160. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.js.map +0 -1
  161. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.d.ts +0 -2
  162. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.d.ts.map +0 -1
  163. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.js +0 -79
  164. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.js.map +0 -1
  165. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.d.ts +0 -35
  166. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.d.ts.map +0 -1
  167. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.js +0 -56
  168. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.js.map +0 -1
  169. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.d.ts +0 -24
  170. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.d.ts.map +0 -1
  171. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.js +0 -87
  172. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.js.map +0 -1
  173. package/payload/server/public/assets/AdminLoginScreens-BLGZEaKU.js.br +0 -0
  174. package/payload/server/public/assets/AdminLoginScreens-BLGZEaKU.js.gz +0 -0
  175. package/payload/server/public/assets/AdminShell-C_M7YE5Z.js.br +0 -0
  176. package/payload/server/public/assets/AdminShell-C_M7YE5Z.js.gz +0 -0
  177. package/payload/server/public/assets/activity-BL6qbB9a.js.br +0 -0
  178. package/payload/server/public/assets/activity-BL6qbB9a.js.gz +0 -0
  179. package/payload/server/public/assets/admin-jnJ3P2u4.js.br +0 -0
  180. package/payload/server/public/assets/admin-jnJ3P2u4.js.gz +0 -0
  181. package/payload/server/public/assets/agents-BzhfKZDN.js.br +0 -0
  182. package/payload/server/public/assets/agents-BzhfKZDN.js.gz +0 -0
  183. package/payload/server/public/assets/browser-C-6io2B9.js.br +0 -0
  184. package/payload/server/public/assets/browser-C-6io2B9.js.gz +0 -0
  185. package/payload/server/public/assets/calendar-Dt3gS88s.js.br +0 -0
  186. package/payload/server/public/assets/calendar-Dt3gS88s.js.gz +0 -0
  187. package/payload/server/public/assets/chat-IUOvVkL-.js +0 -1
  188. package/payload/server/public/assets/chat-IUOvVkL-.js.br +0 -1
  189. package/payload/server/public/assets/chat-IUOvVkL-.js.gz +0 -0
  190. package/payload/server/public/assets/chevron-left-DSzR9zR7.js +0 -1
  191. package/payload/server/public/assets/chevron-left-DSzR9zR7.js.br +0 -0
  192. package/payload/server/public/assets/clock-C5xE5p2I.js +0 -1
  193. package/payload/server/public/assets/clock-C5xE5p2I.js.br +0 -0
  194. package/payload/server/public/assets/copy-TkPJgllS.js.br +0 -0
  195. package/payload/server/public/assets/copy-TkPJgllS.js.gz +0 -0
  196. package/payload/server/public/assets/data-Bhv0Pvfl.js +0 -1
  197. package/payload/server/public/assets/data-Bhv0Pvfl.js.br +0 -0
  198. package/payload/server/public/assets/data-Bhv0Pvfl.js.gz +0 -0
  199. package/payload/server/public/assets/file-text-DyAL2DCh.js.br +0 -0
  200. package/payload/server/public/assets/file-text-DyAL2DCh.js.gz +0 -0
  201. package/payload/server/public/assets/graph-PiNIcCfX.js.br +0 -0
  202. package/payload/server/public/assets/graph-PiNIcCfX.js.gz +0 -0
  203. package/payload/server/public/assets/graph-labels-8jKFrLHn.js.br +0 -0
  204. package/payload/server/public/assets/graph-labels-8jKFrLHn.js.gz +0 -0
  205. package/payload/server/public/assets/operator-DKGKGzrb.js.br +0 -0
  206. package/payload/server/public/assets/operator-DKGKGzrb.js.gz +0 -0
  207. package/payload/server/public/assets/page-BU5vlY9Y.js.br +0 -0
  208. package/payload/server/public/assets/page-BU5vlY9Y.js.gz +0 -0
  209. package/payload/server/public/assets/page-DXz_K10G.js.br +0 -0
  210. package/payload/server/public/assets/page-DXz_K10G.js.gz +0 -0
  211. package/payload/server/public/assets/play-CmSyYrB6.js +0 -1
  212. package/payload/server/public/assets/play-CmSyYrB6.js.br +0 -0
  213. package/payload/server/public/assets/play-CmSyYrB6.js.gz +0 -0
  214. package/payload/server/public/assets/public-BmA3-jLj.js.br +0 -0
  215. package/payload/server/public/assets/public-BmA3-jLj.js.gz +0 -0
  216. package/payload/server/public/assets/rotate-ccw-fCH0LSMq.js.br +0 -0
  217. package/payload/server/public/assets/rotate-ccw-fCH0LSMq.js.gz +0 -0
  218. package/payload/server/public/assets/routines-BeFrmkls.js.br +0 -0
  219. package/payload/server/public/assets/routines-BeFrmkls.js.gz +0 -0
  220. package/payload/server/public/assets/skills-mjls5HBa.js.br +0 -0
  221. package/payload/server/public/assets/skills-mjls5HBa.js.gz +0 -0
  222. package/payload/server/public/assets/tasks-p6puHHD0.js.br +0 -0
  223. package/payload/server/public/assets/tasks-p6puHHD0.js.gz +0 -0
  224. package/payload/server/public/assets/triangle-alert-CrZ1ewbd.js.br +0 -0
  225. package/payload/server/public/assets/triangle-alert-CrZ1ewbd.js.gz +0 -0
  226. package/payload/server/public/assets/useCopyFeedback-zOQNrIwd.js.br +0 -0
  227. package/payload/server/public/assets/useCopyFeedback-zOQNrIwd.js.gz +0 -0
  228. package/payload/server/public/assets/useMediaQuery-BmG090FR.js.br +0 -0
  229. package/payload/server/public/assets/useVoiceRecorder-9pf6FFJ-.js.br +0 -0
  230. package/payload/server/public/assets/useVoiceRecorder-9pf6FFJ-.js.gz +0 -0
  231. package/payload/server/public/assets/wrench-k4GYG5rc.js.br +0 -0
  232. package/payload/server/public/assets/wrench-k4GYG5rc.js.gz +0 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Real Agent ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:c2788a2fda2cab3890e93ca987a686c5115d06a836e1ebbc9da7782f3d827d34
4
+ content-hash: sha256:3783bc8e36395aa87ab8e75dc385eaeae9bf826d201d552f5c97587f68149c05
5
5
  brand: realagent-code
6
6
  product-name: Real Agent
7
7
  ---
@@ -282,6 +282,8 @@ The sweep is the catch-net for `master-fd=close-failed` and any future regressio
282
282
 
283
283
  `openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop: `~/maxy-code/platform/scripts/logs-read.sh --tail server 400 | grep -E '\[fd-audit\]|op=archive-release'`. Full per-spawn lifeline: `grep -E '\[rc-spawn\]|\[pty-tracker\]'` filtered by `unitToken`.
284
284
 
285
+ **Reader frame-strip census.** `~/maxy-code/platform/scripts/logs-read.sh --tail server 200 | grep -E '\[chat-reader\]|op=channel-unified'` pairs the per-account envelope-strip counts against the multi-door spawn count, which is the pairing the check needs: `op=frame-unstripped rows=0` is only a pass alongside `op=frame-census nested>0`, because a zero on its own cannot tell a healthy account from a census that stopped measuring. `op=frame-census accounts=0` says the pass measured nothing. `server.log` rotates on size, so `zgrep` the `.gz` siblings before concluding a window is absent. Mechanism, success and failure conditions in [`.docs/admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).
286
+
285
287
  **WhatsApp inbound `/input` post-condition + sink-timestamp invariant.** An accepted `/input` returns `200` the moment the PTY write lands, but a `200` is not proof a turn submitted. The manager fires a bounded post-condition poll and logs `op=input-submitted … ackMs=…` (PTY went `busy` or a new JSONL user entry appeared) or `op=input-no-turn … ptyStatus=… lastJsonlEntryAgeMs=…` when the window elapses — so an accepted-but-unsubmitted input is visible in ≈8s, not only at the 300s turn-timeout. On timeout the bridge attributes the cause from the JSONL tail: `op=turn-timeout cause=no-turn` (no completed turn) vs `cause=relay-missed` (an assistant `end_turn` landed but the follower never relayed it); a per-sender `op=inbound-outcome relay-ok=N timeout-no-turn=N timeout-relay-missed=N` tally makes the failure rate one grep. **Sink-timestamp invariant:** every `server.log` line is stamped with a leading ISO timestamp at the stdout/stderr sink of the process that emits it — two sinks, one per process (UI: `server-init.cjs`; manager: `src/install-log-sink.ts`) — because both systemd units append to one `server.log` and `append:` bypasses journald. Never hand-inline a timestamp in a log call. Full lifeline, failure signatures, and diagnostic greps in [`.docs/whatsapp-inbound-lifeline.md`](../../../.docs/whatsapp-inbound-lifeline.md).
286
288
 
287
289
  **PTY lifecycle contract.** A PTY reaches its end via one of two branches: **operator-request** (operator clicks End or the auto-archive Stop hook calls `killSession`) or **natural-exit** (the claude child exits on its own — operator typed `/quit`, SIGINT in the PTY, crash, network drop on `--remote-control`). Both branches honour a single invariant: the pty master file descriptor is released by an explicit `pty.destroy()` and the in-process tracker entry is removed before the next `/list` or `/events` tick. The tracker is a module-scoped `Map<sessionId, PtyTracker>` in `pty-spawner.ts` — the metadata-rich `SessionStore` is gone; the tracker holds only what the file system cannot (PtyHandle + pid + bridge ids + runtime flags). Without the explicit destroy, the master fd lingers in node-pty's internal socket until V8 GC finalises the IPty object — non-deterministic and accumulates under load until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. Without the explicit row removal, the manager shutdown loop SIGTERMs PIDs that already logged `process-exited`, masking the leak only because the manager restarts every few hours. When both branches fire on the same exit (operator clicks End and node-pty's `onExit` fans out the SIGTERM to both listeners), a per-row `fdReleased` flag short-circuits the second branch so `pty.destroy()` runs exactly once on the live socket — without the flag, the second call throws "socket already destroyed" and the operator-request line would falsely log `master-fd=close-failed`. If the first branch's destroy throws and is rescued, the flag stays unset and the second branch retries (defense in depth). Every `kill … pid=<n>` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful exit into a 500); the operator-request line additionally identifies `reason=operator-request`, the natural-exit line identifies `reason=process-exited`. Both branches are verified by the `stop-session-fd-release` and `endpoint-stop-delete` integration tests (operator-request live and already-exited cycles + natural-exit cycle + throw-then-retry coordination, Linux kernel-level ptmx fd accounting on each).
@@ -6160,7 +6162,7 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
6160
6162
 
6161
6163
  **Reconcile by meaning on write (`profile-update`).** The existence check is two-tier. First an exact `(category, key)` match — the classic fast path, unchanged. When that misses, a semantic-neighbour lookup runs over the `preference_embedding` vector index, scoped to the same `accountId` and `scope:'admin'`, non-trashed. If the nearest neighbour is at or above `SAME_SUBJECT_COSINE` (0.85), the write lands on that node through the existing reinforce/update/contradict curve instead of minting a duplicate; the matched neighbours are returned in the tool result so the agent can classify future writes against real prior state. The confidence constants are unchanged — only the join key widens, so a rephrasing under a new `key` no longer forks the rule and a correction demotes the prior node rather than adding a contradictory one.
6162
6164
 
6163
- **Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules` block above `## Context`. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
6165
+ **Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules (account owner)` block above `## Context`, whose header and first line name the owner as the subject of the rules so a third-person rule about the owner is not read as a statement about the sender. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
6164
6166
 
6165
6167
  **Trust a scheduled turn's provenance, never an inbound's claim (channel-agent grounding).** A cron-dispatched agent turn reaches the same channel builders as a real inbound, so its own authorisation text would otherwise land inside `## Context` — the zone `REFRAME_INSTRUCTION` demotes to untrusted — and a security-cautious agent would distrust its own automation (the self-cancel loop). The dispatcher therefore stamps a `ScheduleProvenance` marker (owning account, event, owner userId, dispatch time) that the channel builders render as a `## Scheduled automation (platform-verified)` preamble **above** `## Context` (and above the rules block); the whatsapp wrap tag names the door `scheduler`. Both channel-server paths carry the marker — the single-target loop and the unified multi-target door reader (via the same `doorInboundPayload` seam that carries `standingRules`); the scheduler only dispatches to the whatsapp/telegram door, so a webchat frame never bears one. Because the marker is dispatcher-stamped and never derived from payload text, an inbound message cannot forge it — placement above `## Context` alone is not the trust signal, the dispatcher provenance is. The grounding binds to it in two places: one recipient-neutral clause added byte-identical to the three reframe instructions (a marker-bearing turn is authorised standing automation; a reconfiguration or re-enable is not intrusion), and the admin `IDENTITY.md` "Untrusted input" rule that the agent never states a platform-level authenticity or impersonation verdict it was not given (an unsourced "this is not from you" is its own inference, flagged, never asserted as the platform's). A sender has exactly one session: every inbound, real or scheduled, resolves to the deterministic `adminSessionIdFor(effectiveAccount, destination)`. An earlier change gave each firing its own salted seat so distrust could not accrete across firings, but the inbound hub keys on `senderId` and replaces its subscriber outright, so a seat's channel server attached under the sender's own key and swallowed the sender's REAL messages until it detached — splitting one conversation across two sessions that each denied what the other held. The seat is removed; the authoritative `agentPrompt`, this provenance marker, and the injected standing rules ground each firing without one. Self-cancel attribution is disarmed while that model beds in: the guard used to compare the cancelling turn's session id to a dispatcher stamp, which discriminated only because each firing ran its own seat, so with one session per sender it would report an autonomous loop on every operator cancel. It now always reports false — under-counting a real loop rather than inventing one — until the discriminator is rebuilt from the turn's own dispatched eventId. See `.docs/scheduling-agent-dispatch.md § Operator-provenance marker`, `§ Session model — one session per sender`, and `§ Self-cancel attribution — disarmed`.
6166
6168
 
@@ -9,6 +9,23 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-08-08 (0.1.578)
13
+
14
+ - Telegram conversations now appear on the dashboard. The composer shipped two releases ago had nothing to show, because the rows behind it were never written.
15
+ - Telegram shows a typing indicator while the assistant is working, so a pause reads as thinking rather than silence.
16
+ - A message no longer arrives wrapped in its own delivery envelope. The clean view was printing the whole composed frame instead of what was written.
17
+ - Two phones on separate accounts no longer share one reader. Two conversations were being served by a single reader while the health check called that healthy.
18
+ - The check that counts answered Telegram turns can now actually register one. It could never record an answer on a turn from you, so its count was always short.
19
+ - The three conversion scripts left behind by the previous release are gone from the install. They were still being built and shipped with nothing able to run them.
20
+
21
+ ## 2026-08-08 (0.1.577)
22
+
23
+ - Telegram messages keep arriving after a quiet spell. The reader that carries them was attached only when a conversation started, so it died once the conversation went quiet and nothing said so or restarted it. This is the same fault fixed for WhatsApp in the previous release; a standing check now covers Telegram too.
24
+ - Telegram now says who is writing. A message carried no sender, so a second person messaging the same assistant was answered as though they were the account owner, under the owner's own standing instructions.
25
+ - The standing instructions attached to a turn are now labelled as such, rather than appearing as unmarked text.
26
+ - The planner can now change a day, not just show one. It could display an allocated day and offer no way to alter it.
27
+ - A webhook address ending in a slash is no longer rejected. The guard that admits it tested the path differently from the router that serves it, so the two could disagree on the same address.
28
+
12
29
  ## 2026-08-08 (0.1.576)
13
30
 
14
31
  - Installing and upgrading no longer runs data conversions on your existing records. Five had accumulated, each added to repair a state that every install has long since passed, so all five did nothing but add work and risk to every upgrade. One of them stopped the install outright after reporting it had nothing to do. Any future conversion will be something you run deliberately, not part of an upgrade.
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
3
  MAX_NOTE_LENGTH,
4
+ INSTANT_RE,
5
+ MAX_OWNER_ID_LENGTH,
4
6
  MAX_STATUS_LENGTH,
5
7
  WORK_DATE_RE,
6
8
  processVisitStatus,
@@ -14,6 +16,9 @@ interface Row {
14
16
  accountId: string
15
17
  ownerId: string
16
18
  visitId: string
19
+ /** The visit's CURRENT status, which the Task 2511 transition pre-read needs
20
+ * to know what a move is moving from. */
21
+ status?: string
17
22
  }
18
23
 
19
24
  /**
@@ -23,7 +28,11 @@ interface Row {
23
28
  * `meta.changes` as the number of rows the SELECT admitted. A fake that always
24
29
  * reported one change would pass a handler with no gate at all.
25
30
  */
26
- function db(rows: Row[], person: { ownerId: string; recordScope: string; accountId: string }) {
31
+ function db(
32
+ rows: Row[],
33
+ person: { ownerId: string; recordScope: string; accountId: string },
34
+ transitions: Array<[string, string]> | string = [],
35
+ ) {
27
36
  const inserted: unknown[][] = []
28
37
  const database = {
29
38
  prepare(sql: string) {
@@ -34,7 +43,25 @@ function db(rows: Row[], person: { ownerId: string; recordScope: string; account
34
43
  return stmt
35
44
  },
36
45
  async first<T>() {
37
- return person as unknown as T
46
+ // Three reads now use first(): the session resolve, and the Task 2511
47
+ // pre-read of the visit's current status with the account's published
48
+ // transitions. Branching on the table is what keeps the fake honest
49
+ // about which one answered, and the pre-read APPLIES THE SAME TWO
50
+ // FILTERS the insert does, so a test that deletes the scope branch in
51
+ // the endpoint sees the pre-read stop matching too.
52
+ if (!sql.includes('FROM visits')) return person as unknown as T
53
+ const acct = bound[0] as string
54
+ const visitId = bound[bound.length - 1] as string
55
+ let admitted = rows.filter((r) => r.accountId === acct && r.visitId === visitId)
56
+ if (sql.includes('AND v.ownerId = ?')) {
57
+ admitted = admitted.filter((r) => r.ownerId === (bound[1] as string))
58
+ }
59
+ if (admitted.length === 0) return null as unknown as T
60
+ return {
61
+ status: admitted[0].status ?? '',
62
+ transitions:
63
+ typeof transitions === 'string' ? transitions : JSON.stringify(transitions),
64
+ } as unknown as T
38
65
  },
39
66
  async run() {
40
67
  if (!sql.startsWith('INSERT INTO visit_status')) return { meta: { changes: 0 } }
@@ -62,10 +89,25 @@ const env = (d: D1Database): PortalEnv =>
62
89
  ({ DB: d, BUCKET: {}, PORTAL_ACCOUNT_ID: 'acc-a' }) as unknown as PortalEnv
63
90
 
64
91
  const ROWS: Row[] = [
65
- { accountId: 'acc-a', ownerId: 'alice', visitId: 'v1' },
66
- { accountId: 'acc-a', ownerId: 'bob', visitId: 'v2' },
67
- { accountId: 'acc-a', ownerId: '', visitId: 'v3' },
68
- { accountId: 'acc-b', ownerId: 'alice', visitId: 'v4' },
92
+ { accountId: 'acc-a', ownerId: 'alice', visitId: 'v1', status: 'booked' },
93
+ { accountId: 'acc-a', ownerId: 'bob', visitId: 'v2', status: 'booked' },
94
+ { accountId: 'acc-a', ownerId: '', visitId: 'v3', status: 'booked' },
95
+ { accountId: 'acc-b', ownerId: 'alice', visitId: 'v4', status: 'booked' },
96
+ ]
97
+
98
+ /**
99
+ * Every move the tests BELOW this line make, declared legal.
100
+ *
101
+ * Those tests measure the scope gate and the field gates, not the transition
102
+ * gate Task 2511 added. Without this list each of them would be refused as an
103
+ * illegal move and would stop measuring what it was written to measure. The
104
+ * transition gate has its own describe block at the end of this file, where the
105
+ * list is stated per test.
106
+ */
107
+ const DECLARED: Array<[string, string]> = [
108
+ ['booked', 'on-site'],
109
+ ['booked', 'done'],
110
+ ['booked', 'on-site-at-granary-court'],
69
111
  ]
70
112
 
71
113
  const person = (ownerId: string, recordScope: string) => ({
@@ -78,7 +120,7 @@ const body = (visitId: string, status = 'on-site') => ({ visitId, status })
78
120
 
79
121
  describe('the visit-status write gate', () => {
80
122
  it('accepts an own-scoped person setting status on their own visit', async () => {
81
- const { database, inserted } = db(ROWS, person('alice', 'own'))
123
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
82
124
  const h = await processVisitStatus(body('v1'), 'sess', env(database), () => {}, 1)
83
125
  expect(h.status).toBe(200)
84
126
  expect(inserted).toHaveLength(1)
@@ -87,28 +129,28 @@ describe('the visit-status write gate', () => {
87
129
  it('refuses an own-scoped person setting status on someone else visit', async () => {
88
130
  // A read leak shows someone a day that is not theirs. A write leak lets
89
131
  // them change it, which is why the gate is the same one and is structural.
90
- const { database, inserted } = db(ROWS, person('alice', 'own'))
132
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
91
133
  const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
92
134
  expect(h.status).toBe(404)
93
135
  expect(inserted).toEqual([])
94
136
  })
95
137
 
96
138
  it('refuses an own-scoped person setting status on an unbound visit', async () => {
97
- const { database, inserted } = db(ROWS, person('alice', 'own'))
139
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
98
140
  const h = await processVisitStatus(body('v3'), 'sess', env(database), () => {}, 1)
99
141
  expect(h.status).toBe(404)
100
142
  expect(inserted).toEqual([])
101
143
  })
102
144
 
103
145
  it('lets an account-scoped person set status on any visit for the account', async () => {
104
- const { database, inserted } = db(ROWS, person('marie', 'account'))
146
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
105
147
  const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
106
148
  expect(h.status).toBe(200)
107
149
  expect(inserted).toHaveLength(1)
108
150
  })
109
151
 
110
152
  it('refuses another account visit even at account scope', async () => {
111
- const { database, inserted } = db(ROWS, person('marie', 'account'))
153
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
112
154
  const h = await processVisitStatus(body('v4'), 'sess', env(database), () => {}, 1)
113
155
  expect(h.status).toBe(404)
114
156
  expect(inserted).toEqual([])
@@ -123,7 +165,7 @@ describe('the visit-status write gate', () => {
123
165
  })
124
166
 
125
167
  it('refuses a body with no visitId or no status', async () => {
126
- const { database, inserted } = db(ROWS, person('alice', 'own'))
168
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
127
169
  const e = env(database)
128
170
  expect((await processVisitStatus({ status: 'x' }, 'sess', e, () => {}, 1)).status).toBe(400)
129
171
  expect((await processVisitStatus({ visitId: 'v1' }, 'sess', e, () => {}, 1)).status).toBe(400)
@@ -135,7 +177,7 @@ describe('the visit-status write gate', () => {
135
177
  // The value reaches :Visit.status through the pull. An unbounded free-text
136
178
  // field written from the open web is refused at the door, not truncated:
137
179
  // a truncated status is a different status, silently.
138
- const { database, inserted } = db(ROWS, person('alice', 'own'))
180
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
139
181
  const h = await processVisitStatus(
140
182
  body('v1', 'x'.repeat(MAX_STATUS_LENGTH + 1)),
141
183
  'sess',
@@ -148,7 +190,7 @@ describe('the visit-status write gate', () => {
148
190
  })
149
191
 
150
192
  it('stamps the setter own id, never one the request supplies', async () => {
151
- const { database, inserted } = db(ROWS, person('alice', 'own'))
193
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
152
194
  await processVisitStatus(
153
195
  { visitId: 'v1', status: 'on-site', ownerId: 'bob' },
154
196
  'sess',
@@ -162,7 +204,7 @@ describe('the visit-status write gate', () => {
162
204
 
163
205
  it('logs the scope and the outcome, and no status text', async () => {
164
206
  const lines: string[] = []
165
- const { database } = db(ROWS, person('alice', 'own'))
207
+ const { database } = db(ROWS, person('alice', 'own'), DECLARED)
166
208
  await processVisitStatus(
167
209
  body('v1', 'on-site-at-granary-court'),
168
210
  'sess',
@@ -181,7 +223,7 @@ describe('the working day a status edit may carry', () => {
181
223
  const day = (extra: Record<string, unknown>) => ({ visitId: 'v1', status: 'done', ...extra })
182
224
 
183
225
  it('inserts the day, the date and the note when the claim is well formed', async () => {
184
- const { database, inserted } = db(ROWS, person('alice', 'own'))
226
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
185
227
  const h = await processVisitStatus(
186
228
  day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'second fix' }),
187
229
  'sess',
@@ -197,7 +239,7 @@ describe('the working day a status edit may carry', () => {
197
239
 
198
240
  it('defaults to no claim when the body carries no day', async () => {
199
241
  // A status-only edit is the common case and must stay a one-field write.
200
- const { database, inserted } = db(ROWS, person('alice', 'own'))
242
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
201
243
  const h = await processVisitStatus(day({}), 'sess', env(database), () => {}, 1)
202
244
  expect(h.status).toBe(200)
203
245
  expect(inserted[0]).toContain(0)
@@ -207,7 +249,7 @@ describe('the working day a status edit may carry', () => {
207
249
  it('refuses a day outside 0..100 rather than writing it into the graph', async () => {
208
250
  // A value above a full day is described by no shipped surface. Refused at
209
251
  // the door, exactly as an over-long status is.
210
- const { database, inserted } = db(ROWS, person('alice', 'own'))
252
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
211
253
  const e = env(database)
212
254
  for (const d of [101, -1, 50.5, '100']) {
213
255
  const h = await processVisitStatus(
@@ -225,7 +267,7 @@ describe('the working day a status edit may carry', () => {
225
267
  it('refuses a claim whose date is missing or malformed', async () => {
226
268
  // The date becomes part of the cost key. A claim the roll-up cannot date is
227
269
  // told to the person now, not left sitting in the queue.
228
- const { database, inserted } = db(ROWS, person('alice', 'own'))
270
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
229
271
  const e = env(database)
230
272
  for (const wd of [undefined, '', '5th Aug', '2026-8-5']) {
231
273
  const h = await processVisitStatus(
@@ -246,7 +288,7 @@ describe('the working day a status edit may carry', () => {
246
288
  })
247
289
 
248
290
  it('refuses a note longer than the cap rather than truncating it', async () => {
249
- const { database, inserted } = db(ROWS, person('alice', 'own'))
291
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
250
292
  const h = await processVisitStatus(
251
293
  day({
252
294
  dayHundredths: 100,
@@ -264,7 +306,7 @@ describe('the working day a status edit may carry', () => {
264
306
 
265
307
  it('logs whether the edit carried a day, and never the note', async () => {
266
308
  const lines: string[] = []
267
- const { database } = db(ROWS, person('alice', 'own'))
309
+ const { database } = db(ROWS, person('alice', 'own'), DECLARED)
268
310
  await processVisitStatus(
269
311
  day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'granary court rewire' }),
270
312
  'sess',
@@ -280,7 +322,7 @@ describe('the working day a status edit may carry', () => {
280
322
 
281
323
  it('still refuses another person visit when the edit carries a day', async () => {
282
324
  // The gate is the SELECT and the new fields must not have moved it.
283
- const { database, inserted } = db(ROWS, person('alice', 'own'))
325
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
284
326
  const h = await processVisitStatus(
285
327
  { visitId: 'v2', status: 'done', dayHundredths: 100, workDate: '2026-08-05' },
286
328
  'sess',
@@ -292,3 +334,272 @@ describe('the working day a status edit may carry', () => {
292
334
  expect(inserted).toEqual([])
293
335
  })
294
336
  })
337
+
338
+ describe('the move a status edit may carry (Task 2511)', () => {
339
+ const move = (extra: Record<string, unknown>) => ({ visitId: 'v1', status: 'on-site', ...extra })
340
+
341
+ it('inserts the new owner and the new start when both are well formed', async () => {
342
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
343
+ const h = await processVisitStatus(
344
+ move({ assignedOwnerId: 'bob', startDate: '2026-08-10T09:00:00.000Z' }),
345
+ 'sess',
346
+ env(database),
347
+ () => {},
348
+ 1,
349
+ )
350
+ expect(h.status).toBe(200)
351
+ expect(inserted[0]).toContain('bob')
352
+ expect(inserted[0]).toContain('2026-08-10T09:00:00.000Z')
353
+ })
354
+
355
+ it('defaults both to empty when the body carries neither', async () => {
356
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
357
+ const h = await processVisitStatus(move({}), 'sess', env(database), () => {}, 1)
358
+ expect(h.status).toBe(200)
359
+ // status, statusAt, workDate, workNote, assignedOwnerId, startDate: four of
360
+ // those six are empty on a status-only edit.
361
+ expect(inserted[0].filter((v) => v === '').length).toBeGreaterThanOrEqual(4)
362
+ })
363
+
364
+ it('refuses an offset-bearing, date-only or second-precision start rather than converting it', async () => {
365
+ // readCommitments compares stored start values LEXICALLY, so an
366
+ // offset-bearing row reads the wrong window and a real double-booking goes
367
+ // unreported. Refused at the door, never normalised here.
368
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
369
+ const e = env(database)
370
+ for (const s of [
371
+ '2026-08-10T09:00:00+01:00',
372
+ '2026-08-10',
373
+ '2026-08-10T09:00:00Z',
374
+ '2026-08-10 09:00:00.000Z',
375
+ 'soon',
376
+ ]) {
377
+ const h = await processVisitStatus(move({ startDate: s }), 'sess', e, () => {}, 1)
378
+ expect(h.status, `refuses ${s}`).toBe(400)
379
+ }
380
+ expect(inserted).toEqual([])
381
+ })
382
+
383
+ it('refuses an over-long assignedOwnerId rather than truncating it', async () => {
384
+ // A truncated id matches a different person, or nobody, silently.
385
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
386
+ const h = await processVisitStatus(
387
+ move({ assignedOwnerId: 'x'.repeat(MAX_OWNER_ID_LENGTH + 1) }),
388
+ 'sess',
389
+ env(database),
390
+ () => {},
391
+ 1,
392
+ )
393
+ expect(h.status).toBe(400)
394
+ expect(inserted).toEqual([])
395
+ })
396
+
397
+ it('still refuses another person visit when the edit carries a move', async () => {
398
+ // The gate is the SELECT and the new fields must not have moved it.
399
+ const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
400
+ const h = await processVisitStatus(
401
+ { visitId: 'v2', status: 'on-site', assignedOwnerId: 'bob' },
402
+ 'sess',
403
+ env(database),
404
+ () => {},
405
+ 1,
406
+ )
407
+ expect(h.status).toBe(404)
408
+ expect(inserted).toEqual([])
409
+ })
410
+
411
+ it('accepts a start only in the shape the tree stores', () => {
412
+ expect(INSTANT_RE.test('2026-08-10T09:00:00.000Z')).toBe(true)
413
+ expect(INSTANT_RE.test('2026-08-10T09:00:00Z')).toBe(false)
414
+ expect(INSTANT_RE.test('2026-08-10')).toBe(false)
415
+ })
416
+
417
+ it('names the visit and the reason when it refuses a bad instant', async () => {
418
+ const lines: string[] = []
419
+ const { database } = db(ROWS, person('alice', 'own'), DECLARED)
420
+ await processVisitStatus(
421
+ move({ startDate: 'soon' }),
422
+ 'sess',
423
+ env(database),
424
+ (l) => lines.push(l),
425
+ 1,
426
+ )
427
+ expect(lines.join('\n')).toContain('op=rejected')
428
+ expect(lines.join('\n')).toContain('reason=bad-instant')
429
+ expect(lines.join('\n')).toContain('v1')
430
+ })
431
+ })
432
+
433
+ describe('the transition gate at the door (Task 2511)', () => {
434
+ it('accepts a move the account declared', async () => {
435
+ const { database, inserted } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
436
+ const h = await processVisitStatus(
437
+ { visitId: 'v1', status: 'on-site' },
438
+ 'sess',
439
+ env(database),
440
+ () => {},
441
+ 1,
442
+ )
443
+ expect(h.status).toBe(200)
444
+ expect(inserted).toHaveLength(1)
445
+ })
446
+
447
+ it('refuses a move the account did not declare, and writes nothing', async () => {
448
+ // The refusal is here and not at the pull, which runs sixty seconds later
449
+ // with no screen to refuse to, and which sets the status unconditionally.
450
+ const { database, inserted } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
451
+ const h = await processVisitStatus(
452
+ { visitId: 'v1', status: 'done' },
453
+ 'sess',
454
+ env(database),
455
+ () => {},
456
+ 1,
457
+ )
458
+ expect(h.status).toBe(400)
459
+ expect(h.payload).toEqual({ ok: false, error: 'illegal-transition' })
460
+ expect(inserted).toEqual([])
461
+ })
462
+
463
+ it('lets a re-time through when the status is unchanged', async () => {
464
+ // `[from, to]` declares a MOVE. A write whose status equals the current one
465
+ // is not a move, and no account declares a self-transition, so without this
466
+ // rule every pure re-timing would be refused.
467
+ const { database, inserted } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
468
+ const h = await processVisitStatus(
469
+ { visitId: 'v1', status: 'booked', startDate: '2026-08-10T09:00:00.000Z' },
470
+ 'sess',
471
+ env(database),
472
+ () => {},
473
+ 1,
474
+ )
475
+ expect(h.status).toBe(200)
476
+ expect(inserted).toHaveLength(1)
477
+ })
478
+
479
+ it('refuses every move when the account published no transitions', async () => {
480
+ // An empty list means the account declared none, which refuses everything.
481
+ // That is visibly wrong on screen; accepting everything would leave the page
482
+ // enforcing nothing and looking identical to one that enforces correctly.
483
+ const { database, inserted } = db(ROWS, person('marie', 'account'), [])
484
+ const h = await processVisitStatus(
485
+ { visitId: 'v1', status: 'on-site' },
486
+ 'sess',
487
+ env(database),
488
+ () => {},
489
+ 1,
490
+ )
491
+ expect(h.status).toBe(400)
492
+ expect(inserted).toEqual([])
493
+ })
494
+
495
+ it('refuses every move when the published list is malformed', async () => {
496
+ const { database, inserted } = db(ROWS, person('marie', 'account'), 'not json')
497
+ const h = await processVisitStatus(
498
+ { visitId: 'v1', status: 'on-site' },
499
+ 'sess',
500
+ env(database),
501
+ () => {},
502
+ 1,
503
+ )
504
+ expect(h.status).toBe(400)
505
+ expect(inserted).toEqual([])
506
+ })
507
+
508
+ it('answers 404 for a visit the person may not see, before any transition check', async () => {
509
+ const { database, inserted } = db(ROWS, person('alice', 'own'), [['booked', 'on-site']])
510
+ const h = await processVisitStatus(
511
+ { visitId: 'v2', status: 'on-site' },
512
+ 'sess',
513
+ env(database),
514
+ () => {},
515
+ 1,
516
+ )
517
+ expect(h.status).toBe(404)
518
+ expect(inserted).toEqual([])
519
+ })
520
+
521
+ it('names the reason in the log, with the visit', async () => {
522
+ const lines: string[] = []
523
+ const { database } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
524
+ await processVisitStatus(
525
+ { visitId: 'v1', status: 'done' },
526
+ 'sess',
527
+ env(database),
528
+ (l) => lines.push(l),
529
+ 1,
530
+ )
531
+ expect(lines.join('\n')).toContain('op=rejected')
532
+ expect(lines.join('\n')).toContain('reason=illegal-transition')
533
+ expect(lines.join('\n')).toContain('v1')
534
+ })
535
+
536
+ it('still never says whether the visit exists, on a 404', async () => {
537
+ // One answer for "no such visit" and "not yours". Distinguishing them would
538
+ // let anyone enumerate the account's visit ids.
539
+ const { database } = db(ROWS, person('alice', 'own'), [['booked', 'on-site']])
540
+ const missing = await processVisitStatus(
541
+ { visitId: 'nope', status: 'on-site' },
542
+ 'sess',
543
+ env(database),
544
+ () => {},
545
+ 1,
546
+ )
547
+ const theirs = await processVisitStatus(
548
+ { visitId: 'v2', status: 'on-site' },
549
+ 'sess',
550
+ env(database),
551
+ () => {},
552
+ 1,
553
+ )
554
+ expect(missing.payload).toEqual(theirs.payload)
555
+ expect(missing.status).toBe(theirs.status)
556
+ })
557
+ })
558
+
559
+ describe('a move and a labour claim are separate edits (Task 2511)', () => {
560
+ it('refuses a body carrying both, and writes nothing', async () => {
561
+ // The device applies a move in one statement that writes no cost, so a row
562
+ // carrying both would set the status, move the worker, be claimed, and drop
563
+ // the day the worker stated.
564
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
565
+ const e = env(database)
566
+ for (const move of [{ assignedOwnerId: 'bob' }, { startDate: '2026-08-10T09:00:00.000Z' }]) {
567
+ const h = await processVisitStatus(
568
+ { visitId: 'v1', status: 'on-site', dayHundredths: 100, workDate: '2026-08-05', ...move },
569
+ 'sess',
570
+ e,
571
+ () => {},
572
+ 1,
573
+ )
574
+ expect(h.status).toBe(400)
575
+ }
576
+ expect(inserted).toEqual([])
577
+ })
578
+
579
+ it('still accepts each one on its own', async () => {
580
+ const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
581
+ const e = env(database)
582
+ const labour = await processVisitStatus(
583
+ { visitId: 'v1', status: 'done', dayHundredths: 100, workDate: '2026-08-05' },
584
+ 'sess', e, () => {}, 1,
585
+ )
586
+ const move = await processVisitStatus(
587
+ { visitId: 'v1', status: 'on-site', assignedOwnerId: 'bob' },
588
+ 'sess', e, () => {}, 1,
589
+ )
590
+ expect(labour.status).toBe(200)
591
+ expect(move.status).toBe(200)
592
+ expect(inserted).toHaveLength(2)
593
+ })
594
+
595
+ it('names the reason with the visit', async () => {
596
+ const lines: string[] = []
597
+ const { database } = db(ROWS, person('marie', 'account'), DECLARED)
598
+ await processVisitStatus(
599
+ { visitId: 'v1', status: 'on-site', dayHundredths: 100, workDate: '2026-08-05', assignedOwnerId: 'bob' },
600
+ 'sess', env(database), (l) => lines.push(l), 1,
601
+ )
602
+ expect(lines.join('\n')).toContain('reason=move-with-labour')
603
+ expect(lines.join('\n')).toContain('v1')
604
+ })
605
+ })