@rubytech/create-maxy-code 0.1.477 → 0.1.479

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 (204) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1704-data-portal-standing-audit.md +673 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +1244 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
  5. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
  6. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
  7. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1704-data-portal-standing-audit-design.md +177 -0
  8. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
  9. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
  10. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
  11. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1820-cpu-triage-admin-tools-design.md +97 -0
  12. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
  13. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  14. package/payload/platform/lib/graph-style/dist/index.js +22 -0
  15. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  16. package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
  17. package/payload/platform/lib/graph-style/src/index.ts +20 -0
  18. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +44 -0
  19. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/audit.d.ts +56 -0
  21. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/audit.js +34 -0
  23. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  24. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +70 -1
  25. package/payload/platform/lib/storage-broker/src/audit.ts +111 -0
  26. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  27. package/payload/platform/plugins/admin/PLUGIN.md +7 -1
  28. package/payload/platform/plugins/admin/mcp/dist/index.js +72 -0
  29. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  30. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.d.ts +94 -0
  31. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.d.ts.map +1 -0
  32. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.js +229 -0
  33. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.js.map +1 -0
  34. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +14 -2
  35. package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
  36. package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
  37. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
  38. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
  39. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
  40. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  41. package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
  44. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
  45. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
  46. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
  50. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
  51. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
  52. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
  53. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
  61. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  62. package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
  63. package/payload/platform/plugins/email/PLUGIN.md +2 -1
  64. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.d.ts +2 -0
  65. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.d.ts.map +1 -0
  66. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.js +92 -0
  67. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.js.map +1 -0
  68. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js +112 -12
  69. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js.map +1 -1
  70. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.d.ts +2 -0
  71. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.d.ts.map +1 -0
  72. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.js +54 -0
  73. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.js.map +1 -0
  74. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js +170 -1
  75. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js.map +1 -1
  76. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.d.ts +2 -0
  77. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.d.ts.map +1 -0
  78. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.js +86 -0
  79. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.js.map +1 -0
  80. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.d.ts +2 -0
  81. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.d.ts.map +1 -0
  82. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.js +164 -0
  83. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.js.map +1 -0
  84. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.d.ts +2 -0
  85. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.d.ts.map +1 -0
  86. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.js +82 -0
  87. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.js.map +1 -0
  88. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +95 -0
  89. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  90. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +223 -0
  91. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  92. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.d.ts +18 -0
  93. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.d.ts.map +1 -0
  94. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.js +59 -0
  95. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.js.map +1 -0
  96. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.d.ts +48 -0
  97. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.d.ts.map +1 -0
  98. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.js +79 -0
  99. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.js.map +1 -0
  100. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.d.ts +44 -0
  101. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.d.ts.map +1 -0
  102. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.js +63 -0
  103. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.js.map +1 -0
  104. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.d.ts +2 -0
  105. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.d.ts.map +1 -0
  106. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.js +55 -0
  107. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.js.map +1 -0
  108. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.d.ts.map +1 -1
  109. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js +64 -13
  110. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js.map +1 -1
  111. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.d.ts.map +1 -1
  112. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js +11 -1
  113. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js.map +1 -1
  114. package/payload/platform/plugins/email/mcp/dist/tools/email-send.d.ts.map +1 -1
  115. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js +16 -3
  116. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js.map +1 -1
  117. package/payload/platform/plugins/email/references/email-reference.md +36 -0
  118. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
  119. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
  120. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
  121. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
  122. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.d.ts +2 -0
  123. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.d.ts.map +1 -0
  124. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.js +18 -0
  125. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.js.map +1 -0
  126. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
  127. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
  128. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
  129. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
  130. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.d.ts +13 -0
  131. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.d.ts.map +1 -0
  132. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.js +10 -0
  133. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.js.map +1 -0
  134. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +175 -0
  135. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  136. package/payload/platform/scripts/cpu-triage-run.sh +117 -0
  137. package/payload/platform/scripts/cpu-triage.sh +23 -4
  138. package/payload/platform/services/claude-session-manager/dist/activity-range.d.ts +40 -0
  139. package/payload/platform/services/claude-session-manager/dist/activity-range.d.ts.map +1 -0
  140. package/payload/platform/services/claude-session-manager/dist/activity-range.js +68 -0
  141. package/payload/platform/services/claude-session-manager/dist/activity-range.js.map +1 -0
  142. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts +40 -8
  143. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts.map +1 -1
  144. package/payload/platform/services/claude-session-manager/dist/activity-rows.js +74 -7
  145. package/payload/platform/services/claude-session-manager/dist/activity-rows.js.map +1 -1
  146. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  147. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
  148. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  149. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  150. package/payload/platform/services/claude-session-manager/dist/http-server.js +9 -0
  151. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  152. package/payload/server/{chunk-56WJMBQQ.js → chunk-3XLLTG6R.js} +14 -0
  153. package/payload/server/{chunk-JECAP3Z2.js → chunk-XGNRSM57.js} +3 -0
  154. package/payload/server/maxy-edge.js +1 -1
  155. package/payload/server/public/activity.html +8 -6
  156. package/payload/server/public/assets/{AdminLoginScreens-mEKP4pDi.js → AdminLoginScreens-DkxQZnvB.js} +1 -1
  157. package/payload/server/public/assets/AdminShell-Cv_nIFGe.js +2 -0
  158. package/payload/server/public/assets/Checkbox-CsTWK8mm.js +1 -0
  159. package/payload/server/public/assets/activity-BbnTiVBT.js +1 -0
  160. package/payload/server/public/assets/{admin-BVhgfgs1.js → admin-BZY3i_Ff.js} +1 -1
  161. package/payload/server/public/assets/{browser-D-HMmHcM.js → browser-BbIiSFKr.js} +1 -1
  162. package/payload/server/public/assets/{calendar-tGWvFyFn.js → calendar-BvxeTHvQ.js} +1 -1
  163. package/payload/server/public/assets/chat-DUKxufRk.js +1 -0
  164. package/payload/server/public/assets/chevron-left-BtfJD6LL.js +1 -0
  165. package/payload/server/public/assets/data-hl1wa-Ax.js +1 -0
  166. package/payload/server/public/assets/{graph-zEw610xK.js → graph-CDSGRoeb.js} +1 -1
  167. package/payload/server/public/assets/graph-labels-C5qLwnWA.js +1 -0
  168. package/payload/server/public/assets/{maximize-2-BlTjXT_Y.js → maximize-2-CAzH8dED.js} +1 -1
  169. package/payload/server/public/assets/{operator-C7oIw2PG.js → operator-BK7r8DAB.js} +1 -1
  170. package/payload/server/public/assets/{page-CTP7OFZa.js → page-B5YngOrK.js} +1 -1
  171. package/payload/server/public/assets/{page-DL6Zsdvk.js → page-BsCS0Dmc.js} +1 -1
  172. package/payload/server/public/assets/{public-r1A9dqR_.js → public-C1Pz-IBs.js} +1 -1
  173. package/payload/server/public/assets/{rotate-ccw-CzkkKx4-.js → rotate-ccw-UDln4Tou.js} +1 -1
  174. package/payload/server/public/assets/tasks-B-cLsn3p.js +1 -0
  175. package/payload/server/public/assets/{time-entry-format-j669DgXj.js → time-entry-format-DPOBIE18.js} +1 -1
  176. package/payload/server/public/assets/{triangle-alert-BPgCgIef.js → triangle-alert-Dz-CZKo5.js} +1 -1
  177. package/payload/server/public/assets/{useCopyFeedback-BmLa2aes.js → useCopyFeedback-wtUHGohk.js} +1 -1
  178. package/payload/server/public/assets/{useSelectionMode-DnnM7A8-.js → useSelectionMode-sph7AQT5.js} +1 -1
  179. package/payload/server/public/assets/useSubAccountSwitcher-BRIluIKf.css +1 -0
  180. package/payload/server/public/assets/{useVoiceRecorder-u2dwZfen.js → useVoiceRecorder-DN4jYQ0A.js} +1 -1
  181. package/payload/server/public/brand-defaults.css +1 -0
  182. package/payload/server/public/browser.html +5 -5
  183. package/payload/server/public/calendar.html +6 -6
  184. package/payload/server/public/chat.html +12 -12
  185. package/payload/server/public/data.html +11 -11
  186. package/payload/server/public/graph.html +11 -11
  187. package/payload/server/public/index.html +14 -14
  188. package/payload/server/public/operator.html +14 -14
  189. package/payload/server/public/privacy.html +1 -0
  190. package/payload/server/public/public.html +12 -12
  191. package/payload/server/public/tasks.html +6 -5
  192. package/payload/server/public/vnc-popout.html +1 -0
  193. package/payload/server/server.js +894 -384
  194. package/payload/server/{src-JYPKMWJR.js → src-XQ63FPRE.js} +3 -1
  195. package/payload/server/public/assets/AdminShell-BwSaZ88d.js +0 -2
  196. package/payload/server/public/assets/Checkbox-CN4uQ80w.js +0 -1
  197. package/payload/server/public/assets/activity-CIQ8ozNZ.js +0 -1
  198. package/payload/server/public/assets/chat-DkttVxAz.js +0 -1
  199. package/payload/server/public/assets/chevron-left-CZ4ez9G5.js +0 -1
  200. package/payload/server/public/assets/data-P-mcmnNi.js +0 -1
  201. package/payload/server/public/assets/graph-labels-qnBleOE6.js +0 -1
  202. package/payload/server/public/assets/tasks-fIJwYFWG.js +0 -1
  203. package/payload/server/public/assets/useSubAccountSwitcher-C_E8h07P.css +0 -1
  204. /package/payload/server/public/assets/{useSubAccountSwitcher-CFa6ZAT1.js → useSubAccountSwitcher-v8LyBzZN.js} +0 -0
@@ -0,0 +1,177 @@
1
+ # Task 1704 — data portal standing audit
2
+
3
+ **Date:** 2026-07-20 · **Lane:** cloudflare + storage-broker · data-portal observability
4
+ **Depends on:** 1689 (portal, landed), 1694 (broker R2 object ops, landed), 1810 (loop registry, landed)
5
+
6
+ ## The problem
7
+
8
+ The `[data-portal]` lifeline traces one upload end to end and ends in a re-read post-condition, so
9
+ every failure that emits an event is already covered. Three conditions emit nothing:
10
+
11
+ - **An orphan object** — an object in R2 with no manifest row. Stored, invisible to ingestion
12
+ forever, and the upload that produced it returned `200`.
13
+ - **A phantom row** — a manifest row whose object is absent. Ingestion will pull a missing file,
14
+ and nothing says so until ingestion runs, which may be never.
15
+ - **A stalled ingestion sweep** — an ingestion that never runs logs nothing, so a climbing backlog
16
+ and an empty one look identical.
17
+
18
+ Only a standing reconcile reveals any of them. Absence of the audit's line is itself the signal that
19
+ all three are unobservable.
20
+
21
+ ## Premise corrections carried from the task file
22
+
23
+ The task brief predates three changes. All three point the same way; none change its intent.
24
+
25
+ 1. **The `[storage-audit]` idiom is now `registerLoop`.** Task 1810 moved every standing loop onto
26
+ `platform/ui/server/loop-registry.ts`, and `scripts/check-loops-registered.mjs` fails the build on
27
+ a bare `setInterval`. "Copy the `[storage-audit]` shape" therefore means registering a loop, not
28
+ hand-rolling two unref'd timers.
29
+ 2. **Every line number in the brief is stale**, though every cited thing still exists.
30
+ `runStorageAudit` is `storage-broker.ts:773`; the "silent when the config names no portal" idiom
31
+ is the `hasBookingSite` check in the availability loop; `r2ObjectList`'s drain-to-exhaustion
32
+ comment at `cf-exec.ts:443` names this audit as its own reason for existing.
33
+ 3. **`reconcilePages` (Task 1728) is a closer template than the brief knew.** It is already a pure
34
+ orphan/phantom set difference over two lists in `lib/storage-broker/src/audit.ts`, carrying the
35
+ concurrency contract and the refusal to print `orphan=<everything>` off a failed read. This audit
36
+ is that shape one level down: objects against rows instead of projects against records.
37
+
38
+ ## Components
39
+
40
+ ### 1. `reconcileDataPortal` — pure, in `lib/storage-broker/src/audit.ts`
41
+
42
+ Third reconcile beside `reconcileStorage` and `reconcilePages`. It is D1 rows against R2 objects,
43
+ which is that lib's domain, and the ui server imports the lib by source path so there is no dist
44
+ rebuild in the loop.
45
+
46
+ ```ts
47
+ interface DataPortalReconcile {
48
+ objects: number
49
+ rows: number
50
+ orphanObjects: string[] // object keys with no manifest row
51
+ phantomRows: string[] // manifest objectKeys with no object
52
+ oldestUningestedHrs: number | null // null renders as `na`
53
+ }
54
+
55
+ function reconcileDataPortal(
56
+ objects: Array<{ key: string }>,
57
+ rows: Array<{ objectKey: string; ingested: number; uploadedAt: string }>,
58
+ nowMs: number,
59
+ ): DataPortalReconcile
60
+ ```
61
+
62
+ `nowMs` is injected so the age arithmetic is deterministic under test rather than clock-dependent.
63
+
64
+ `oldestUningestedHrs` is computed from rows with `ingested === 0` only, and is `null` when there are
65
+ none. **An `uploadedAt` that will not parse is excluded from the minimum rather than allowed to
66
+ produce `NaN`** — that is the `snapshotAgeMs=NaN` lesson from the availability loop, where a truthy
67
+ but non-ISO value slipped past a `??` guard and logged `NaN`. If exclusion leaves no usable row, the
68
+ result is `null`.
69
+
70
+ ### 2. `parseManifestRows` — pure, exported from the route module
71
+
72
+ `cf.d1Query` shells `npx wrangler d1 execute --json` and is typed `unknown`. This function parses
73
+ that envelope into the row shape above and **throws on anything it does not recognise**.
74
+
75
+ Throwing rather than returning `[]` is the load-bearing decision. Zero rows against a populated
76
+ bucket reads as "every object is an orphan" — a fabricated defect count off a failed read, which is
77
+ precisely what `reconcilePages` refuses to print. A throw lands in the d1-degraded branch, where the
78
+ line says the read failed instead of asserting a number it does not have.
79
+
80
+ ### 3. `runDataPortalAudit(root)` — the caller, in `ui/server/routes/storage-broker.ts`
81
+
82
+ Sits with `runStorageAudit` and `runPagesAudit` at the foot of the same file.
83
+
84
+ **Discovery.** Walks `data/accounts/*/data-portal.json`. A config that is absent, unparseable, or
85
+ parses but names no `portalDbName` and `bucketName` is **silent** — the `hasBookingSite` idiom. An
86
+ account with no portal is not a fault and must not produce a line.
87
+
88
+ **It returns before minting a house token when no account has a portal.** Most installs have no data
89
+ portal and no house Cloudflare credential; minting unconditionally would make this loop report an
90
+ error forever on every one of them.
91
+
92
+ **Reads.** One `makeHouseCfExec(root)` for the whole run, then per account:
93
+
94
+ - rows — `d1Query(portalDbName, 'SELECT objectKey, ingested, uploadedAt FROM manifest')`
95
+ - objects — `r2ObjectList(bucketName)`
96
+
97
+ The two halves are caught **separately**, and the line is emitted unconditionally afterwards. This is
98
+ `.tasks/backlog/1351` made structural: the log is not downstream of either read, so neither read can
99
+ skip it. An audit that a data-source failure silences is the same blind spot one layer up, in a task
100
+ that exists because failures emit nothing.
101
+
102
+ **Degradation.** Each line carries exactly what is still measurable:
103
+
104
+ | Failure | Line still carries |
105
+ |---|---|
106
+ | R2 read fails | `rows=N oldestUningestedHrs=N`, remainder `na`, `degraded=r2` |
107
+ | D1 read fails | `objects=N`, remainder `na`, `degraded=d1` |
108
+ | Both fail | all `na`, `degraded=d1+r2` |
109
+
110
+ `oldestUningestedHrs` surviving an R2 failure is not incidental: the backlog age is computable from
111
+ the manifest alone, and it is the one of the three conditions that a half-broken audit can still
112
+ report.
113
+
114
+ **Line format.** Counts only:
115
+
116
+ ```
117
+ [data-portal-audit] account=<id> objects=N rows=N orphanObjects=N phantomRows=N oldestUningestedHrs=N
118
+ ```
119
+
120
+ Healthy is `orphanObjects=0 phantomRows=0` with a bounded age. **Any non-zero orphan or phantom count
121
+ is a defect, not noise.**
122
+
123
+ `reconcilePages` reports orphan and phantom *names*, on the grounds that a count says something is
124
+ wrong and a name says what to fix. This audit deliberately does not, and the pure function returning
125
+ the keys does not change that: the keys here are `<ownerId>/<filename>`, real people's filenames,
126
+ and the journal is not where they belong. The task's success criterion specifies counts, and the
127
+ audit owning its own ownership discipline is called out in the brief as load-bearing precisely
128
+ because the in-process path holds the account-wide credential and bypasses `objectGate` entirely.
129
+
130
+ ### 4. Arming, in `ui/server/index.ts`
131
+
132
+ ```ts
133
+ registerLoop({
134
+ name: 'data-portal-audit',
135
+ intervalMs: 300_000,
136
+ firstRunDelayMs: 50_000,
137
+ run: runDataPortalAuditSafe,
138
+ })
139
+ ```
140
+
141
+ Same interval as the storage and pages audits. The first run is offset past storage (20s), pages
142
+ (35s) and google-account (45s) so a boot does not fire four token mints in the same instant.
143
+
144
+ **Degradation is not an error.** A degraded run returns a note (`portals=N degraded=M`) and the
145
+ registry records `ok`, because the audit did the job it could and said so. A genuine throw — no
146
+ house credential where a portal exists, an unreachable registry — is logged and **rethrown**, the
147
+ `runStorageAuditSafe` shape, so /activity shows a fresh Last run against a stale Last OK.
148
+
149
+ ## Testing
150
+
151
+ **Unit, `lib/storage-broker/src/__tests__/audit.test.ts`** — an object with no row counts one
152
+ `orphanObjects`; a row with no object counts one `phantomRows`; a matched pair counts neither;
153
+ `oldestUningestedHrs` comes from the oldest `ingested=0` row; `ingested=1` rows are excluded from it;
154
+ `na` when there are no uningested rows; an unparseable `uploadedAt` does not yield `NaN`.
155
+
156
+ **Envelope, same run** — `parseManifestRows` on a well-formed wrangler payload, and a throw on a
157
+ malformed one. The throw case is what stops a parse failure reading as an empty manifest.
158
+
159
+ **Route, new `ui/server/__tests__/data-portal-audit.test.ts`** — modelled on
160
+ `storage-broker-object-routes.test.ts`: `// @vitest-environment node`, `vi.mock` the neo4j store,
161
+ partially mock the lib via `importOriginal`, stub `makeHouseCfExec` with a fake carrying
162
+ `r2ObjectList` and `d1Query`. Cases: two accounts, one with a portal, yields one line; a D1 throw
163
+ still emits a line carrying `degraded=d1`; an R2 throw emits `degraded=r2` with `rows` still counted;
164
+ a malformed D1 payload degrades rather than reporting zero rows; a config naming no portal is silent;
165
+ no portal anywhere mints no token.
166
+
167
+ **Mutation check** — reverting the reconcile to compare in one direction only must fail the opposite
168
+ direction's test.
169
+
170
+ ## Scope boundaries
171
+
172
+ **In scope:** the reconcile and its tests, the caller, the arming, the route-level tests, and
173
+ `.docs/data-portal.md` § Observability, which currently states the audit is not built.
174
+
175
+ **Out of scope:** the device-side ingestion sweep (unbuilt, agent-side); any change to the portal
176
+ template or the gate (1689, landed); escalation beyond a log line — `.tasks/backlog/1351` records
177
+ that a non-zero count escalating nowhere is its own problem, and that is lane-wide, not this task's.
@@ -0,0 +1,201 @@
1
+ # Task 1789 — Re-seating a channel row forks it to webchat and destroys the channel seat
2
+
3
+ Design, 2026-07-20.
4
+
5
+ ## The defect
6
+
7
+ `session-reseat.ts` builds every fork payload with a hardcoded webchat shape. Re-seating a
8
+ WhatsApp or Telegram row therefore spawns a **webchat** session whose `senderId` is
9
+ `session:<newId>`. The per-sender channel server for the real sender is replaced, its SSE stream
10
+ aborts, and `whatsapp/gateway/routes.ts:56` fires `op=channel-detached`. The fork renders with a
11
+ webchat glyph.
12
+
13
+ The fork is also unreachable from the channel. A sender's session id is
14
+ `adminSessionIdFor(accountId, senderId)` — `sha256` shaped into a v4 UUID, a pure function. Every
15
+ future inbound recomputes the original id, never the fork's.
16
+
17
+ Observed 2026-07-18 on `sitedesk-code`, account `5e99bba5`, sender `447504472444`, session
18
+ `c6ed890f`. Brand-uniform code path.
19
+
20
+ ## Three corrections to the task brief
21
+
22
+ The brief was written against a reading of the code that does not hold. All three were confirmed
23
+ by reading the tree and were agreed with the operator before this design was written.
24
+
25
+ **The seam named in Fix #2 is log-only.** `wa-gateway.ts:312`'s `adminSessionIdFor` call feeds the
26
+ `op=inbound … sessionId=` line and nothing else. The id the spawn actually runs under comes from
27
+ the pure builder `whatsapp/gateway/spawn-request.ts:34`, called once at `server/index.ts:418`.
28
+ Telegram mirrors it exactly (`spawn-request.ts:31`, `server/index.ts:700`). Consulting the
29
+ override at the gateway line alone would make the log truthful and route nothing.
30
+
31
+ **The fork has no channel binding until the next inbound.** The manager wires the WhatsApp reply
32
+ tool only when a `waChannel` descriptor (senderId, gatewayUrl, serverPath) is present. The re-seat
33
+ route holds none of those, and the manager's store fallback is keyed on the new session id, which
34
+ is empty for a fresh fork. So the fork spawns correctly labelled but without
35
+ `mcp__whatsapp-channel__reply` until an inbound arrives and `ensureChannelSession` binds it. This
36
+ is accepted, not fixed — the existing re-attach path already covers it. It is made observable
37
+ rather than left silent, because the manager's own `op=channel-binding-lost` invariant is gated on
38
+ the store and cannot fire here.
39
+
40
+ **The audit sweep cannot answer the standing check as specified.** `runDuplicateSenderAudit`
41
+ walks session sidecars on disk. It has no event history, so "a sender whose last event is a
42
+ `channel-detached`" is not derivable from it. The gateway must record detach times for the sweep
43
+ to query.
44
+
45
+ ## Scope
46
+
47
+ In scope: WhatsApp and Telegram admin channel rows.
48
+
49
+ Out of scope, deliberately:
50
+
51
+ - **Public channel rows.** Public sessions key their id on `personId` as well as `senderId`, so
52
+ the `<channel>:<senderId>` override key is ambiguous for them. Public re-seat is not evidenced
53
+ as broken. Public rows re-seat exactly as they do today.
54
+ - **Telegram in the standing audit.** `runDuplicateSenderAudit` is WhatsApp-only today. The
55
+ unseated check extends it in place; making the whole sweep multi-channel is separate work.
56
+ - Everything already listed under the task's own "Out of scope": the fork's displayed model
57
+ (Task 1790), the cross-lever clobber (Task 940), re-attaching the live sender, and
58
+ `op=duplicate-sender-sessions` for `447850009518`.
59
+
60
+ ## Design
61
+
62
+ ### Unit 1 — `platform/ui/server/channel-session-override.ts` (new)
63
+
64
+ Modelled on `canonical-webchat-override.ts`. One JSON file, `channel-session-override.json`, in
65
+ the account directory.
66
+
67
+ ```json
68
+ { "bySender": { "whatsapp:447504472444": "fc397054-…" } }
69
+ ```
70
+
71
+ - `channelOverridePath(accountDir)` — the file path.
72
+ - `readChannelOverrideId(accountDir, channel, senderId): string | null` — the fork id for that
73
+ sender, or null when unset, malformed, or not a v4-shaped UUID. Never throws; a corrupt file
74
+ reads as null so a bad write can never wedge inbound routing.
75
+ - `writeChannelOverrideId(accountDir, channel, senderId, sessionId): void` — merges into
76
+ `bySender`, preserving other senders' entries. Atomic tmp+rename so a concurrent read never
77
+ sees a half-written file.
78
+
79
+ There is no legacy-shape migration. Unlike the canonical override this file has no predecessor.
80
+
81
+ ### Unit 2 — the fork inherits the source row's channel
82
+
83
+ `session-reseat.ts` reads the source session's `.meta.json` through the existing exported
84
+ `readSidecarMeta`, and passes the result to `resolveReseatPlan` as a new optional `source`
85
+ argument. `resolveReseatPlan` stays pure and unit-testable without the gate.
86
+
87
+ The channel shape applies when **all three** hold: sidecar `role` is `admin`, sidecar `channel` is
88
+ `whatsapp` or `telegram`, and sidecar `senderId` is a non-empty string. Then the payload carries:
89
+
90
+ - `channel` — the sidecar's channel
91
+ - `senderId` — the sidecar's senderId
92
+ - `role: 'admin'`
93
+ - `targetAccountId` — the sidecar's `accountId`, when present
94
+ - **no** `webchatChannel` descriptor
95
+
96
+ `personId` is never carried: the admin-only scope means it is always null on these rows.
97
+
98
+ When any of the three conditions fails, the payload is byte-for-byte what it is today.
99
+
100
+ `targetAccountId` is an addition beyond the task text and was approved explicitly. Without it the
101
+ manager defaults the fork to the boot account, so a channel row belonging to a client sub-account
102
+ would fork into the house account — wrong account scope for every Neo4j write the fork makes.
103
+ Pre-983 sidecars carry no `accountId`; those omit the field and keep today's behaviour.
104
+
105
+ ### Unit 3 — resolution consults the override
106
+
107
+ `buildWaSpawnRequest` and `buildTelegramSpawnRequest` each gain an optional `overrideSessionId`
108
+ input. When set it is used as `sessionId`; otherwise `adminSessionIdFor` as today. The builders
109
+ keep no filesystem access and stay pure.
110
+
111
+ `server/index.ts` reads the override at both spawn call sites (`:418` WhatsApp, `:700` Telegram),
112
+ resolving the effective account's directory by id through `listValidAccounts()`. Using the
113
+ **effective** account id on the read side matches the write side, which keys on the source
114
+ sidecar's `accountId` — so an account-manager-routed sub-account sender resolves its own override
115
+ and never the house's.
116
+
117
+ The two gateway `op=inbound` log lines consult the same override, so the logged `sessionId` names
118
+ the session the message actually reaches. The gateways do not read the file themselves: each takes
119
+ an optional `resolveSessionOverride?: (accountId, senderId) => string | null` dependency, injected
120
+ by `server/index.ts` at construction. The gateways keep no filesystem knowledge and stay testable
121
+ with a stub, and an absent dependency degrades to the deterministic id.
122
+
123
+ ### Chained re-seats
124
+
125
+ Mirroring Task 876's canonical pointer: the route writes the override when the source is either
126
+ the deterministic per-sender id **or** the current override value. Re-seating a fork moves the
127
+ pointer forward instead of stranding it.
128
+
129
+ ### Why not a mutable binding
130
+
131
+ Rejected, per the task. `adminSessionIdFor`'s determinism is what lets an inbound resume the right
132
+ JSONL after a bridge or manager restart, and every existing channel session's id derives from it.
133
+
134
+ ### Relationship to Task 1746
135
+
136
+ `spawn-request.ts` currently comments that "the deterministic per-sender id is the ONLY id a
137
+ sender's session ever has", written when Task 1746 removed Task 1526's per-firing seat. That seat
138
+ was **salted per scheduled firing**: it minted a new session on every dispatch, which then
139
+ attached under the sender's own hub key and swallowed their real messages, splitting one
140
+ conversation across two sessions.
141
+
142
+ This override is a **stable forward pointer**: one session, repointed once by an operator action,
143
+ resolving identically for every subsequent inbound. It cannot split a conversation because it
144
+ never yields a second id for a sender. Both comments are rewritten to say the deterministic id is
145
+ the default rather than the only id, and to record this distinction so the next reader does not
146
+ re-remove the override.
147
+
148
+ ### Unit 4 — observability
149
+
150
+ - `op=request` gains the source's resolved channel and senderId, so the lifeline says what kind of
151
+ row was re-seated.
152
+ - `op=channel-forward senderId=… from=… to=…` at the override write, mirroring the existing
153
+ `op=canonical-forward`.
154
+ - `op=channel-fork-unbound senderId=… session=…` at a channel fork, naming the window in which the
155
+ fork has no reply tool. The brief did not ask for this; it exists because the manager's own
156
+ binding-lost invariant provably cannot fire on this path.
157
+ - **Standing check.** The WhatsApp gateway records a detach timestamp per sender when
158
+ `op=channel-detached` fires, and clears it on attach. `runDuplicateSenderAudit` queries that map
159
+ and emits `op=sender-unseated senderId=… since=…` for any sender still detached longer than one
160
+ sweep interval (15 min). This reconciles expected state — every known sender has a seat or a
161
+ reason not to — against actual, without reproducing the failure. A detached sender with no live
162
+ channel server is otherwise invisible to action logging, because nothing happens.
163
+
164
+ ### Comments to correct
165
+
166
+ `session-reseat.ts`'s header (asserts the fork is always webchat-bound),
167
+ `SessionReseatControl.tsx:10-13` (asserts the opposite), and both `spawn-request.ts` Task 1746
168
+ comments. All four must describe the delivered behaviour.
169
+
170
+ ## Testing
171
+
172
+ - `session-reseat.test.ts` — a `whatsapp` admin sidecar source produces a payload carrying
173
+ `channel:'whatsapp'`, the source `senderId`, and no `webchatChannel` key; a `telegram` admin
174
+ sidecar does the same for its channel; a `webchat` sidecar produces today's payload
175
+ byte-for-byte; a `whatsapp` sidecar with `role:'public'` produces today's payload; a sidecar with
176
+ no `accountId` omits `targetAccountId`.
177
+ - Override round-trip — write then read returns the fork id for that sender and null for an
178
+ unwritten sender; a second sender's write preserves the first; a malformed file reads null
179
+ rather than throwing.
180
+ - Spawn builders — with an `overrideSessionId` the request carries it; without one it carries
181
+ `adminSessionIdFor`. Both channels.
182
+ - Unseated audit — a sender detached longer than one interval is reported; one detached within the
183
+ interval is not; one that re-attached is not.
184
+ - Regression proof on-device — re-seat a WhatsApp row, confirm no `channel-detached` without a
185
+ following `channel-attached`, then send an inbound and confirm `op=inbound … sessionId=<fork>`.
186
+
187
+ ## Files
188
+
189
+ | File | Change |
190
+ |---|---|
191
+ | `platform/ui/server/channel-session-override.ts` | new |
192
+ | `platform/ui/server/routes/admin/session-reseat.ts` | sidecar read, channel payload, override write, lifeline fields |
193
+ | `platform/ui/app/lib/whatsapp/gateway/spawn-request.ts` | optional `overrideSessionId` |
194
+ | `platform/ui/app/lib/telegram/gateway/spawn-request.ts` | optional `overrideSessionId` |
195
+ | `platform/ui/server/index.ts` | override read at both spawn sites; unseated audit |
196
+ | `platform/ui/app/lib/whatsapp/gateway/wa-gateway.ts` | detach-time map; override in `op=inbound` |
197
+ | `platform/ui/app/lib/telegram/gateway/telegram-gateway.ts` | override in `op=inbound` |
198
+ | `platform/ui/app/lib/whatsapp/gateway/duplicate-sender-audit.ts` | `op=sender-unseated` |
199
+ | `platform/ui/app/components/SessionReseatControl.tsx` | comment correction |
200
+ | `.docs/admin-webchat-native-channel.md` | document the override and the unbound window |
201
+ | `.tasks/LANES.md` | archive sync |
@@ -0,0 +1,217 @@
1
+ # Task 1818 — Widen the loop gate to cover `app/**` outside `app/lib/`
2
+
3
+ **Date:** 2026-07-20
4
+ **Lane:** Platform / admin UI · activity view · scheduled work visibility
5
+ **Depends on:** 1810 (landed), 1814 (landed — the widened gate and its marker convention)
6
+
7
+ ## Summary
8
+
9
+ `scripts/check-loops-registered.mjs` walks `server/**` and `app/lib/**`. The rest of
10
+ `app/**` is unscanned, so a standing server loop armed there would not be caught and
11
+ would be invisible on `/activity`, which is the omission mode 1810 and 1814 exist to
12
+ close.
13
+
14
+ This task widens the scan to the whole of `app/**` and marks the fifteen browser timers
15
+ already there `loop-exempt:client`.
16
+
17
+ ## Premise corrections
18
+
19
+ The task file's approach section rests on three factual claims about the tree. All three
20
+ are wrong, and the first one voids one of its two candidate approaches outright. They are
21
+ recorded here because the task's stated reasoning cannot be followed as written.
22
+
23
+ ### 1. There is no Next.js
24
+
25
+ The task describes the unscanned area as "Next.js route and component code" and names
26
+ `app/api/*/route.ts` as the shape of the loop that would escape.
27
+
28
+ `platform/ui` has no `next` dependency and no `next.config`. It is a Vite SPA with ten
29
+ HTML entries (`vite.config.ts`) plus a Hono server bundled by tsup from exactly two
30
+ entries, `server/index.ts` and `server/edge.ts` (`tsup.config.ts`). There is no `app/api/`
31
+ directory and no route file under `app/` at all. Server routes live in `server/routes/`,
32
+ which the gate already scans.
33
+
34
+ The task's second candidate — "treat `app/api/**` and any other server-executing subtree
35
+ as in scope while leaving component directories out" — therefore describes a layout that
36
+ cannot exist in this package. It is void rather than merely less attractive. The task
37
+ states that establishing "whichever the Next.js routing layout actually makes stable" is
38
+ the first work of this task; the answer is that there is no such layout, and the decision
39
+ collapses to the remaining candidate.
40
+
41
+ ### 2. `'use client'` is unusable, but not for the stated reason
42
+
43
+ The task rules out the `'use client'` directive as a discriminator on the grounds that
44
+ "the 1814 census confirmed `app/lib/use-now-tick.ts` is a React hook carrying no such
45
+ directive".
46
+
47
+ `app/lib/use-now-tick.ts` carries `'use client'` on line 14, and carried it at 1814's own
48
+ commit (`git show main:…` returns the directive).
49
+
50
+ The conclusion survives on different evidence. Of the eleven files under `app/` outside
51
+ `app/lib/` that arm a browser timer, three carry no directive: `app/chat/page.tsx`,
52
+ `app/chat/transcript-render.tsx`, and `app/graph/settle-lock.ts`. A discriminator that
53
+ misses three of eleven cannot gate a build.
54
+
55
+ ### 3. Fifteen sites, not sixteen
56
+
57
+ The task and its LANES row both count sixteen browser timers in the unscanned area.
58
+
59
+ Applying the gate's own `ARM` regex (`/\bsetInterval\s*\(/`) to `app/**` outside
60
+ `app/lib/`, skipping `__tests__`, yields **fifteen** sites across eleven files. The
61
+ difference is that eight further occurrences are `ReturnType<typeof setInterval>` type
62
+ positions, which have no `(` after the identifier and do not match. The sixteenth timer is
63
+ `app/lib/use-now-tick.ts`, which lives inside a scanned root and already carries its
64
+ marker.
65
+
66
+ ## What the census found
67
+
68
+ Two findings beyond the count, both bearing on the design.
69
+
70
+ **No hidden standing loop.** Every one of the fifteen sites is genuinely a browser timer.
71
+ The two least obvious were checked directly: `app/graph/settle-lock.ts` samples
72
+ `network.getPositions()` to freeze vis-network canvas physics and is imported only by
73
+ `app/graph/page.tsx`; `app/chat/transcript-render.tsx` is a React render module ticking
74
+ `setNow(Date.now())`. This is the opposite of 1814, whose widening surfaced
75
+ `visitor-event.ts`, a standing loop that had been invisible the whole time. Here the
76
+ widening surfaces nothing: every new site resolves to a marker, and no `registerLoop` call
77
+ is added, so the registry's 26 production loops are unchanged.
78
+
79
+ **Server-reachable code outside `app/lib/` already exists.** A census of every
80
+ `from '…/app/…'` import in `server/` returns `app/lib/…` in every case but one:
81
+ `server/routes/whatsapp-reader.ts:43` imports `SUPPORTED_MIME_SET`, `MAX_FILE_BYTES` and
82
+ `MAX_ATTACHMENTS` from `app/admin-types.ts`. Those are runtime values, not types, so this
83
+ is genuinely server-executing code living outside the scanned tree today. It arms no
84
+ timer, so nothing is currently hidden, but the boundary "server code stops at `app/lib/`"
85
+ is already false and the design must not depend on it. No dynamic `import()` or
86
+ `require()` in `server/` reaches `app/`, and no file under `app/` outside `app/lib/`
87
+ imports a `node:` builtin.
88
+
89
+ ## Approach
90
+
91
+ Scan the whole of `app/**`. Mark the fifteen browser timers `loop-exempt:client`.
92
+
93
+ Two alternatives were considered and rejected.
94
+
95
+ **Reachability from the two server entry points.** The gate could resolve the import graph
96
+ from `server/index.ts` and `server/edge.ts` and scan exactly what it reaches. This is the
97
+ most precise statement of the real semantic. It is rejected because it puts a module
98
+ resolver inside a gate whose own header records that hand-rolled parsing is what killed
99
+ its first version: that version stripped `//` comments, ate the closing backtick of every
100
+ template literal containing a URL, and reported `ok` on a real violation. The gate's
101
+ governing principle is to match raw lines and parse nothing.
102
+
103
+ **Close 1818 as void.** Since there is no route-file surface under `app/` in a Vite SPA, a
104
+ server loop arguably has nowhere there to hide. This is rejected because it rests on
105
+ "everything under `app/` is client today", which is the assumption the task exists to stop
106
+ relying on, and which `app/admin-types.ts` already partially falsifies.
107
+
108
+ ## Design
109
+
110
+ ### The scan root
111
+
112
+ `SCAN_ROOTS` becomes `['server', 'app']`, replacing `['server', 'app/lib']`.
113
+
114
+ `app/lib` is subsumed rather than listed alongside `app`. Listing both would walk `app/lib`
115
+ twice and double its contribution to `files=` and `sites=`, corrupting the exact counter
116
+ the task names as its evidence that the widening took effect.
117
+
118
+ ### The fifteen markers
119
+
120
+ Each site gets `// loop-exempt:client <reason>` on its own line, with the reason specific
121
+ to that timer rather than a repeated phrase. `client` is already in the closed category set
122
+ established by 1814 (`per-connection`, `demand-singleton`, `client`, `registry-primitive`),
123
+ so no convention changes. The task puts the marker convention and category set explicitly
124
+ out of scope.
125
+
126
+ The sites:
127
+
128
+ | File | Line | What it ticks |
129
+ |---|---|---|
130
+ | `app/AdminShell.tsx` | 228 | 60s poll |
131
+ | `app/Sidebar.tsx` | 512, 534, 1609 | channel conversations, sessions, system stats |
132
+ | `app/activity/page.tsx` | 428 | 15s activity reload |
133
+ | `app/chat/page.tsx` | 700, 721 | pointer poll, 60s read |
134
+ | `app/chat/transcript-render.tsx` | 80 | 1s relative-time clock |
135
+ | `app/graph/page.tsx` | 1456 | layout poll |
136
+ | `app/graph/settle-lock.ts` | 137 | vis-network motion sampler |
137
+ | `app/hooks/useViewMode.ts` | 110 | view-mode poll |
138
+ | `app/hooks/useVoiceRecorder.ts` | 142 | recording elapsed timer |
139
+ | `app/useAdminAuth.ts` | 219 | auth refresh |
140
+ | `app/useElapsedTimer.ts` | 29, 35 | session tick, elapsed tick |
141
+
142
+ ### The header
143
+
144
+ The gate's SCOPE block currently reads "Covers `server/**` and `app/lib/**` … NOT covered:
145
+ the rest of `app/**`, which is Next.js route and component code running in the visitor's
146
+ browser". Every clause of that becomes false or was already false.
147
+
148
+ It is rewritten to state the real boundary (`server/**` and `app/**`, excluding
149
+ `__tests__`), that the server bundles from two entries so `server/**` is where server code
150
+ starts, and that `app/admin-types.ts` is already server-reachable from outside `app/lib/`
151
+ — which is why the whole of `app/` is scanned rather than a subtree of it. A gate whose
152
+ header overclaims is the defect class this file exists to prevent, so the header moves in
153
+ the same change as the behaviour.
154
+
155
+ ### Tests
156
+
157
+ The behaviour belongs in the project's own committed suite at
158
+ `scripts/__tests__/check-loops-registered.test.ts`, so these are committed rather than
159
+ ephemeral.
160
+
161
+ One existing test **encodes the hole this task closes**. `ignores files outside the two
162
+ scan roots` (line 87) proves its point with `app/chat/page.tsx`, asserting exit 0 and
163
+ `sites=0`. That file moves in-scope, so the test must be rewritten against a path that is
164
+ genuinely outside both roots, not merely extended around.
165
+
166
+ New cases:
167
+
168
+ - An unmarked module-scope `setInterval` in a component file under `app/` outside
169
+ `app/lib/` fails with exit 1 and the message naming the file.
170
+ - A marked `client` site in that same area passes and is counted under `client=`.
171
+
172
+ The real-tree floor assertions rise so a walk that silently stops reading shows as
173
+ collapsed counts rather than a healthy `ok`.
174
+
175
+ ### Mutation check
176
+
177
+ Required by the task on 1814's terms. A real unmarked `setInterval` is inserted into an
178
+ actual file under `app/` outside `app/lib/`, the gate is observed exiting 1 and naming
179
+ that file, and the line is removed. A negative test that passes because the signal is dead
180
+ is the failure this whole line of work exists to remove, and a synthetic fixture under
181
+ `--root` cannot prove the real scan root moved.
182
+
183
+ ## Observability
184
+
185
+ `files=` rises from **284** to **393**: 109 non-test source files under `app/` outside
186
+ `app/lib/`. The predicted figure is asserted rather than "the number went up", so a partial
187
+ walk fails instead of passing.
188
+
189
+ `sites=` rises from 10 to 25, `marked=` from 9 to 24, and `client=` from 1 to 16. No
190
+ `registerLoop` call is added, because the census found no standing loop in the newly
191
+ covered area. That pairing — coverage widens, registered loop count holds at 26 — is the
192
+ finding, not an absence of one.
193
+
194
+ ## Scope boundaries
195
+
196
+ **In scope:** the scan boundary for `app/**` outside `app/lib/`, the marking that decision
197
+ implies, the gate's SCOPE header, and the test surface above.
198
+
199
+ **Out of scope:** the marker convention and category set, settled in 1814; the two scan
200
+ roots already covered; the gate's raw-line matching, whose two known holes are documented
201
+ in its header and are accepted rather than open.
202
+
203
+ **Answered, not deferred: `app/admin-types.ts` stays where it is.** Being server-reachable
204
+ from outside `app/lib/` reads at first like a filing error worth correcting. It is not.
205
+ That file exists precisely so it cannot import `app/lib/attachments.ts` — its own comment
206
+ records the reason ("cannot import a Node.js module into a client component"), and
207
+ `attachments.ts` opens with `node:crypto`, `node:fs/promises` and `node:fs`. `app/lib/` is
208
+ the Node-touching tree: 56 of its 191 non-test source files import a `node:` builtin.
209
+ Moving the deliberately client-safe mirror into that directory would file it beside the
210
+ module it exists to avoid. Separately, the move would buy this task's outcome nothing,
211
+ because the widened scan covers the file wherever it lives. So there is no structural
212
+ question left open here and no follow-up task to file.
213
+
214
+ **A real adjacent risk, named and not fixed here.** `ACCEPT_MIME` / `SUPPORTED_MIME_SET` in
215
+ `app/admin-types.ts` mirror `app/lib/attachments.ts` by hand, with nothing keeping the two
216
+ in sync. That is a mime-allowlist consistency problem with no bearing on loop coverage, and
217
+ it is not touched by this task.