@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.477",
3
+ "version": "0.1.479",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -0,0 +1,673 @@
1
+ # Data portal standing audit — implementation plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Arm a standing per-account reconcile that reveals an orphan R2 object, a phantom manifest row, or a stalled ingestion backlog — three conditions that today emit nothing at all.
6
+
7
+ **Architecture:** A pure set-difference in `lib/storage-broker/src/audit.ts` beside its two siblings; a caller in `ui/server/routes/storage-broker.ts` beside `runStorageAudit`, which reads each side under its own `catch` so neither failure can skip the log line; a `registerLoop` arming in `ui/server/index.ts`.
8
+
9
+ **Tech Stack:** TypeScript, vitest (ui server), node:test (storage-broker lib), Hono, the loop registry (Task 1810).
10
+
11
+ ## Global Constraints
12
+
13
+ - The log line carries **counts only**, never object keys. Keys are `<ownerId>/<filename>` — real people's filenames.
14
+ - The audit is armed through `registerLoop`. A bare `setInterval` fails `scripts/check-loops-registered.mjs` at build time.
15
+ - A `d1Query` payload that does not parse **throws**. It never degrades to zero rows: zero rows against a populated bucket fabricates `orphanObjects=<everything>`.
16
+ - An account whose `data-portal.json` is absent, unparseable, or names no `portalDbName` and `bucketName` is **silent**.
17
+ - Node 22 for every vitest run in this worktree (`nvm use 22`).
18
+
19
+ ---
20
+
21
+ ### Task 1: The reconcile arithmetic
22
+
23
+ **Files:**
24
+ - Modify: `maxy-code/platform/lib/storage-broker/src/audit.ts` (append after `reconcilePages`)
25
+ - Test: `maxy-code/platform/lib/storage-broker/src/__tests__/audit.test.ts` (append)
26
+
27
+ **Interfaces:**
28
+ - Consumes: nothing.
29
+ - Produces: `reconcileDataPortal(objects, rows, nowMs): DataPortalReconcile`, exported through `lib/storage-broker/src/index.js`.
30
+
31
+ ```ts
32
+ export interface DataPortalObject { key: string }
33
+ export interface DataPortalRow { objectKey: string; ingested: number; uploadedAt: string }
34
+ export interface DataPortalReconcile {
35
+ objects: number
36
+ rows: number
37
+ orphanObjects: string[]
38
+ phantomRows: string[]
39
+ oldestUningestedHrs: number | null
40
+ }
41
+ ```
42
+
43
+ - [ ] **Step 1: Write the failing tests**
44
+
45
+ Append to `__tests__/audit.test.ts` (this suite is `node:test`, matching the file's existing style):
46
+
47
+ ```ts
48
+ test("reconcileDataPortal counts an object with no row as an orphan", () => {
49
+ const r = reconcileDataPortal([{ key: "alice/a.pdf" }], [], 0);
50
+ assert.deepEqual(r.orphanObjects, ["alice/a.pdf"]);
51
+ assert.deepEqual(r.phantomRows, []);
52
+ assert.equal(r.objects, 1);
53
+ assert.equal(r.rows, 0);
54
+ });
55
+
56
+ test("reconcileDataPortal counts a row with no object as a phantom", () => {
57
+ const r = reconcileDataPortal([], [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")], 0);
58
+ assert.deepEqual(r.phantomRows, ["alice/a.pdf"]);
59
+ assert.deepEqual(r.orphanObjects, []);
60
+ });
61
+
62
+ test("reconcileDataPortal counts a matched pair as neither", () => {
63
+ const r = reconcileDataPortal(
64
+ [{ key: "alice/a.pdf" }],
65
+ [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")],
66
+ 0,
67
+ );
68
+ assert.deepEqual(r.orphanObjects, []);
69
+ assert.deepEqual(r.phantomRows, []);
70
+ });
71
+
72
+ test("oldestUningestedHrs comes from the oldest ingested=0 row", () => {
73
+ const now = Date.parse("2026-07-20T12:00:00Z");
74
+ const r = reconcileDataPortal(
75
+ [],
76
+ [row("a", 0, "2026-07-20T09:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")],
77
+ now,
78
+ );
79
+ assert.equal(r.oldestUningestedHrs, 3);
80
+ });
81
+
82
+ test("an ingested row is excluded from the backlog age", () => {
83
+ const now = Date.parse("2026-07-20T12:00:00Z");
84
+ const r = reconcileDataPortal(
85
+ [],
86
+ [row("a", 1, "2026-07-01T00:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")],
87
+ now,
88
+ );
89
+ assert.equal(r.oldestUningestedHrs, 1);
90
+ });
91
+
92
+ test("oldestUningestedHrs is null when nothing is uningested", () => {
93
+ const r = reconcileDataPortal([], [row("a", 1, "2026-07-20T00:00:00Z")], 0);
94
+ assert.equal(r.oldestUningestedHrs, null);
95
+ });
96
+
97
+ test("an unparseable uploadedAt never yields NaN", () => {
98
+ const now = Date.parse("2026-07-20T12:00:00Z");
99
+ const only = reconcileDataPortal([], [row("a", 0, "not-a-date")], now);
100
+ assert.equal(only.oldestUningestedHrs, null);
101
+ const mixed = reconcileDataPortal(
102
+ [],
103
+ [row("a", 0, "not-a-date"), row("b", 0, "2026-07-20T11:00:00Z")],
104
+ now,
105
+ );
106
+ assert.equal(mixed.oldestUningestedHrs, 1);
107
+ });
108
+ ```
109
+
110
+ with this helper at the top of the appended block:
111
+
112
+ ```ts
113
+ const row = (objectKey: string, ingested: number, uploadedAt: string) => ({
114
+ objectKey,
115
+ ingested,
116
+ uploadedAt,
117
+ });
118
+ ```
119
+
120
+ and `reconcileDataPortal` added to the file's existing import from `../audit.js`.
121
+
122
+ - [ ] **Step 2: Run the tests to verify they fail**
123
+
124
+ Run: `cd maxy-code/platform/lib/storage-broker && npx tsc -p tsconfig.json && node --test 'dist/__tests__/audit.test.js'`
125
+ Expected: FAIL — `reconcileDataPortal` is not exported.
126
+
127
+ - [ ] **Step 3: Implement**
128
+
129
+ Append to `audit.ts`:
130
+
131
+ ```ts
132
+ // --- Data portal reconcile (Task 1704) -------------------------------------
133
+ //
134
+ // Third instance of the same no-event argument, one level down from the two
135
+ // above: these reconcile ownership records, this reconciles the objects
136
+ // themselves against the manifest rows that make them ingestible.
137
+ //
138
+ // orphan object — an object in R2 with no manifest row. The upload returned
139
+ // 200, the file is stored, and ingestion will never see it.
140
+ // phantom row — a manifest row whose object is absent. Ingestion will pull
141
+ // a missing file, and nothing says so until it runs.
142
+ //
143
+ // Keys are returned rather than only counted so the arithmetic is testable and
144
+ // a future caller has them, but the standing audit logs COUNTS ONLY: a key here
145
+ // is `<ownerId>/<filename>`, a real person's filename, and the brand journal is
146
+ // not where those belong. That is the same ownership discipline the in-process
147
+ // path owes for holding the account-wide credential and bypassing objectGate.
148
+
149
+ export interface DataPortalObject {
150
+ key: string;
151
+ }
152
+
153
+ export interface DataPortalRow {
154
+ objectKey: string;
155
+ ingested: number;
156
+ uploadedAt: string;
157
+ }
158
+
159
+ export interface DataPortalReconcile {
160
+ objects: number;
161
+ rows: number;
162
+ orphanObjects: string[];
163
+ phantomRows: string[];
164
+ /** Null when nothing is uningested, or when no uningested row carries a
165
+ * parseable timestamp. Rendered `na` by the caller. */
166
+ oldestUningestedHrs: number | null;
167
+ }
168
+
169
+ /** Pure over two lists and an injected clock, so the age arithmetic is
170
+ * deterministic under test rather than dependent on when the suite runs. */
171
+ export function reconcileDataPortal(
172
+ objects: DataPortalObject[],
173
+ rows: DataPortalRow[],
174
+ nowMs: number,
175
+ ): DataPortalReconcile {
176
+ const objectKeys = new Set(objects.map((o) => o.key));
177
+ const rowKeys = new Set(rows.map((r) => r.objectKey));
178
+
179
+ // Date.parse of a truthy non-ISO value is NaN, which slips past a `??` guard
180
+ // and renders as `oldestUningestedHrs=NaN` — the defect the availability
181
+ // loop's snapshotAgeMs already had to fix. Unparseable rows are excluded from
182
+ // the minimum; if that leaves none, the answer is null, never NaN.
183
+ const uningestedMs = rows
184
+ .filter((r) => r.ingested === 0)
185
+ .map((r) => Date.parse(r.uploadedAt))
186
+ .filter((ms) => !Number.isNaN(ms));
187
+
188
+ return {
189
+ objects: objectKeys.size,
190
+ rows: rowKeys.size,
191
+ orphanObjects: [...objectKeys].filter((k) => !rowKeys.has(k)),
192
+ phantomRows: [...rowKeys].filter((k) => !objectKeys.has(k)),
193
+ oldestUningestedHrs:
194
+ uningestedMs.length === 0 ? null : (nowMs - Math.min(...uningestedMs)) / 3_600_000,
195
+ };
196
+ }
197
+ ```
198
+
199
+ - [ ] **Step 4: Run the tests to verify they pass**
200
+
201
+ Run: `cd maxy-code/platform/lib/storage-broker && npx tsc -p tsconfig.json && node --test 'dist/__tests__/audit.test.js'`
202
+ Expected: PASS, all cases.
203
+
204
+ - [ ] **Step 5: Mutation check**
205
+
206
+ Temporarily delete the `phantomRows` line's filter (return `[]`). Re-run: the phantom test must fail and the orphan test must still pass. Restore.
207
+
208
+ - [ ] **Step 6: Commit**
209
+
210
+ ```bash
211
+ git add maxy-code/platform/lib/storage-broker/src/audit.ts maxy-code/platform/lib/storage-broker/src/__tests__/audit.test.ts
212
+ git commit -m "feat(data-portal): reconcile objects against manifest rows both ways"
213
+ ```
214
+
215
+ ---
216
+
217
+ ### Task 2: The wrangler envelope parse
218
+
219
+ **Files:**
220
+ - Modify: `maxy-code/platform/ui/server/routes/storage-broker.ts` (append near `runStorageAudit`)
221
+ - Test: `maxy-code/platform/ui/server/__tests__/data-portal-audit.test.ts` (create)
222
+
223
+ **Interfaces:**
224
+ - Consumes: `DataPortalRow` from Task 1.
225
+ - Produces: `parseManifestRows(raw: unknown): DataPortalRow[]`, exported from the route module.
226
+
227
+ - [ ] **Step 1: Write the failing tests**
228
+
229
+ Create `data-portal-audit.test.ts`:
230
+
231
+ ```ts
232
+ // @vitest-environment node
233
+ //
234
+ // Task 1704 — the standing data-portal audit.
235
+ //
236
+ // The property under test is not "it prints a line". It is that NO data-source
237
+ // failure can stop it printing one: a D1 throw, an R2 throw, and a malformed
238
+ // wrangler payload each still produce a line naming the degradation. An audit a
239
+ // failed read can silence is the same blind spot one layer up, in a feature
240
+ // that exists because failures emit nothing (.tasks/backlog/1351).
241
+
242
+ import { describe, it, expect, vi } from 'vitest'
243
+
244
+ vi.mock('../../app/lib/neo4j-store', () => ({
245
+ getSession: () => ({ run: vi.fn(), close: vi.fn() }),
246
+ }))
247
+
248
+ const { parseManifestRows } = await import('../routes/storage-broker')
249
+
250
+ describe('parseManifestRows', () => {
251
+ it('reads rows out of the wrangler --json envelope', () => {
252
+ const raw = [{ results: [{ objectKey: 'a/x.pdf', ingested: 0, uploadedAt: '2026-07-20T00:00:00Z' }] }]
253
+ expect(parseManifestRows(raw)).toEqual([
254
+ { objectKey: 'a/x.pdf', ingested: 0, uploadedAt: '2026-07-20T00:00:00Z' },
255
+ ])
256
+ })
257
+
258
+ it('reads an empty result set as no rows', () => {
259
+ expect(parseManifestRows([{ results: [] }])).toEqual([])
260
+ })
261
+
262
+ it('throws on an envelope it does not recognise', () => {
263
+ expect(() => parseManifestRows({ nope: true })).toThrow(/unexpected d1 payload/)
264
+ expect(() => parseManifestRows(null)).toThrow(/unexpected d1 payload/)
265
+ expect(() => parseManifestRows([{}])).toThrow(/unexpected d1 payload/)
266
+ })
267
+
268
+ it('throws on a row missing the fields the reconcile needs', () => {
269
+ expect(() => parseManifestRows([{ results: [{ objectKey: 'a' }] }])).toThrow(/unexpected d1 row/)
270
+ })
271
+ })
272
+ ```
273
+
274
+ - [ ] **Step 2: Run to verify it fails**
275
+
276
+ Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
277
+ Expected: FAIL — `parseManifestRows` is not exported.
278
+
279
+ - [ ] **Step 3: Implement**
280
+
281
+ Append to `storage-broker.ts`, above where `runDataPortalAudit` will go:
282
+
283
+ ```ts
284
+ // `d1Query` shells `wrangler d1 execute --json` and is typed `unknown`, so the
285
+ // shape is asserted here rather than assumed.
286
+ //
287
+ // It THROWS rather than returning [] on anything unrecognised, and that is the
288
+ // load-bearing half. An empty manifest against a populated bucket reconciles to
289
+ // `orphanObjects=<every object>` — a fabricated defect count read off a failed
290
+ // parse, which is exactly what reconcilePages refuses to print off a failed
291
+ // read. A throw lands in the d1-degraded branch, where the line says the read
292
+ // failed instead of asserting a number nobody measured.
293
+ export function parseManifestRows(raw: unknown): DataPortalRow[] {
294
+ const envelope = Array.isArray(raw) ? raw[0] : null
295
+ const results = (envelope as { results?: unknown } | null)?.results
296
+ if (!Array.isArray(results)) {
297
+ throw new Error(`unexpected d1 payload: ${JSON.stringify(raw)?.slice(0, 200)}`)
298
+ }
299
+ return results.map((r) => {
300
+ const row = r as Partial<DataPortalRow>
301
+ if (
302
+ typeof row.objectKey !== 'string' ||
303
+ typeof row.ingested !== 'number' ||
304
+ typeof row.uploadedAt !== 'string'
305
+ ) {
306
+ throw new Error(`unexpected d1 row: ${JSON.stringify(r)?.slice(0, 200)}`)
307
+ }
308
+ return { objectKey: row.objectKey, ingested: row.ingested, uploadedAt: row.uploadedAt }
309
+ })
310
+ }
311
+ ```
312
+
313
+ Add `reconcileDataPortal` and `type DataPortalRow` to the existing import from `'../../../lib/storage-broker/src/index.js'`.
314
+
315
+ - [ ] **Step 4: Run to verify it passes**
316
+
317
+ Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
318
+ Expected: PASS, 4 tests.
319
+
320
+ - [ ] **Step 5: Commit**
321
+
322
+ ```bash
323
+ git add maxy-code/platform/ui/server/routes/storage-broker.ts maxy-code/platform/ui/server/__tests__/data-portal-audit.test.ts
324
+ git commit -m "feat(data-portal): parse the d1 envelope, throwing rather than reading empty"
325
+ ```
326
+
327
+ ---
328
+
329
+ ### Task 3: The audit run
330
+
331
+ **Files:**
332
+ - Modify: `maxy-code/platform/ui/server/routes/storage-broker.ts`
333
+ - Test: `maxy-code/platform/ui/server/__tests__/data-portal-audit.test.ts`
334
+
335
+ **Interfaces:**
336
+ - Consumes: `parseManifestRows` (Task 2), `reconcileDataPortal` (Task 1).
337
+ - Produces: `runDataPortalAudit(root: string): Promise<string>` — the resolved string is the loop's operator-visible note.
338
+
339
+ - [ ] **Step 1: Write the failing tests**
340
+
341
+ Extend the mock block at the top of `data-portal-audit.test.ts` so `makeHouseCfExec` is stubbed and its construction is observable, then append the cases. Full added content:
342
+
343
+ ```ts
344
+ const cfExecConstructed = vi.fn()
345
+ let cfStub: { d1Query: (n: string, s: string) => Promise<unknown>; r2ObjectList: (b: string) => Promise<Array<{ key: string }>> }
346
+
347
+ vi.mock('../../../lib/storage-broker/src/index.js', async (importOriginal) => {
348
+ const actual = await importOriginal<typeof import('../../../lib/storage-broker/src/index.js')>()
349
+ return {
350
+ ...actual,
351
+ makeHouseCfExec: async (...args: unknown[]) => {
352
+ cfExecConstructed(...args)
353
+ return cfStub
354
+ },
355
+ }
356
+ })
357
+ ```
358
+
359
+ and, after the `parseManifestRows` describe block:
360
+
361
+ ```ts
362
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
363
+ import { tmpdir } from 'node:os'
364
+ import { join } from 'node:path'
365
+
366
+ const { runDataPortalAudit } = await import('../routes/storage-broker')
367
+
368
+ /** Builds a root whose sibling `data/accounts` carries the given configs, which
369
+ * is the layout runDataPortalAudit resolves (`root/../data/accounts`). */
370
+ function makeRoot(accounts: Record<string, string | null>): string {
371
+ const base = mkdtempSync(join(tmpdir(), 'portal-audit-'))
372
+ const root = join(base, 'platform')
373
+ mkdirSync(root)
374
+ for (const [id, config] of Object.entries(accounts)) {
375
+ const dir = join(base, 'data', 'accounts', id)
376
+ mkdirSync(dir, { recursive: true })
377
+ if (config !== null) writeFileSync(join(dir, 'data-portal.json'), config)
378
+ }
379
+ return root
380
+ }
381
+
382
+ const PORTAL = JSON.stringify({ portalDbName: 'acc-a-portal', bucketName: 'acc-a-bucket' })
383
+
384
+ describe('runDataPortalAudit', () => {
385
+ beforeEach(() => {
386
+ cfExecConstructed.mockClear()
387
+ logged.length = 0
388
+ })
389
+
390
+ const logged: string[] = []
391
+ beforeEach(() => {
392
+ vi.spyOn(console, 'error').mockImplementation((m: unknown) => { logged.push(String(m)) })
393
+ })
394
+
395
+ it('emits one line per portal account and stays silent for the rest', async () => {
396
+ cfStub = {
397
+ d1Query: async () => [{ results: [{ objectKey: 'alice/a.pdf', ingested: 0, uploadedAt: new Date(Date.now() - 7_200_000).toISOString() }] }],
398
+ r2ObjectList: async () => [{ key: 'alice/a.pdf' }],
399
+ }
400
+ const root = makeRoot({ 'acc-a': PORTAL, 'acc-b': null, 'acc-c': '{"nothing":1}' })
401
+ const note = await runDataPortalAudit(root)
402
+ const lines = logged.filter((l) => l.startsWith('[data-portal-audit]'))
403
+ expect(lines).toHaveLength(1)
404
+ expect(lines[0]).toContain('account=acc-a objects=1 rows=1 orphanObjects=0 phantomRows=0')
405
+ expect(lines[0]).toMatch(/oldestUningestedHrs=2(\.\d+)?/)
406
+ expect(note).toBe('portals=1 degraded=0')
407
+ })
408
+
409
+ it('never mints a house token when no account has a portal', async () => {
410
+ const root = makeRoot({ 'acc-b': null })
411
+ const note = await runDataPortalAudit(root)
412
+ expect(cfExecConstructed).not.toHaveBeenCalled()
413
+ expect(note).toBe('portals=0 degraded=0')
414
+ })
415
+
416
+ it('still emits a line when the D1 read throws, carrying what R2 measured', async () => {
417
+ cfStub = {
418
+ d1Query: async () => { throw new Error('d1 boom') },
419
+ r2ObjectList: async () => [{ key: 'alice/a.pdf' }],
420
+ }
421
+ const note = await runDataPortalAudit(makeRoot({ 'acc-a': PORTAL }))
422
+ const line = logged.find((l) => l.startsWith('[data-portal-audit]')) ?? ''
423
+ expect(line).toContain('objects=1 rows=na orphanObjects=na phantomRows=na oldestUningestedHrs=na')
424
+ expect(line).toContain('degraded=d1')
425
+ expect(note).toBe('portals=1 degraded=1')
426
+ })
427
+
428
+ it('still emits a line when the R2 read throws, keeping the backlog age', async () => {
429
+ cfStub = {
430
+ d1Query: async () => [{ results: [{ objectKey: 'alice/a.pdf', ingested: 0, uploadedAt: new Date(Date.now() - 3_600_000).toISOString() }] }],
431
+ r2ObjectList: async () => { throw new Error('r2 boom') },
432
+ }
433
+ await runDataPortalAudit(makeRoot({ 'acc-a': PORTAL }))
434
+ const line = logged.find((l) => l.startsWith('[data-portal-audit]')) ?? ''
435
+ expect(line).toContain('objects=na rows=1 orphanObjects=na phantomRows=na')
436
+ expect(line).toMatch(/oldestUningestedHrs=1(\.\d+)?/)
437
+ expect(line).toContain('degraded=r2')
438
+ })
439
+
440
+ it('degrades on a malformed D1 payload rather than reporting every object an orphan', async () => {
441
+ cfStub = {
442
+ d1Query: async () => ({ unexpected: true }),
443
+ r2ObjectList: async () => [{ key: 'alice/a.pdf' }, { key: 'bob/b.pdf' }],
444
+ }
445
+ await runDataPortalAudit(makeRoot({ 'acc-a': PORTAL }))
446
+ const line = logged.find((l) => l.startsWith('[data-portal-audit]')) ?? ''
447
+ expect(line).toContain('orphanObjects=na')
448
+ expect(line).not.toContain('orphanObjects=2')
449
+ expect(line).toContain('degraded=d1')
450
+ })
451
+
452
+ it('reports both sides down', async () => {
453
+ cfStub = {
454
+ d1Query: async () => { throw new Error('d1 boom') },
455
+ r2ObjectList: async () => { throw new Error('r2 boom') },
456
+ }
457
+ await runDataPortalAudit(makeRoot({ 'acc-a': PORTAL }))
458
+ const line = logged.find((l) => l.startsWith('[data-portal-audit]')) ?? ''
459
+ expect(line).toContain('objects=na rows=na')
460
+ expect(line).toContain('degraded=d1+r2')
461
+ })
462
+ })
463
+ ```
464
+
465
+ Add `beforeEach` to the vitest import.
466
+
467
+ - [ ] **Step 2: Run to verify it fails**
468
+
469
+ Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
470
+ Expected: FAIL — `runDataPortalAudit` is not exported.
471
+
472
+ - [ ] **Step 3: Implement**
473
+
474
+ Append to `storage-broker.ts`:
475
+
476
+ ```ts
477
+ // ---------------------------------------------------------------------------
478
+ // The data portal standing audit (Task 1704). Its own function rather than an
479
+ // arm of runStorageAudit, for the same reason runPagesAudit is: folding them in
480
+ // would make one Cloudflare failure hide the other's line.
481
+ //
482
+ // WHERE THE LOG LINE SITS IS THE POINT. .tasks/backlog/1351 records that
483
+ // [calendar-reconcile]'s audit sits after its D1 read, so a d1Query throw skips
484
+ // the audit and the one line that would have shown a growing backlog never
485
+ // prints. In a feature that exists because failures emit nothing, an audit a
486
+ // data-source failure can silence is the same blind spot one layer up. So each
487
+ // side is read under its own catch and the line is emitted unconditionally
488
+ // afterwards — it is downstream of neither read.
489
+ //
490
+ // LIST-ONLY, DELIBERATELY. r2ObjectList moves no bytes, so the 100 MiB object
491
+ // cap (1695) does not apply. It also drains every page rather than stopping
492
+ // early, and this audit is the reason that rule exists: a truncated listing
493
+ // reads as "those objects are absent", manufacturing false phantomRows while
494
+ // hiding real orphanObjects. Task 1697's r2ObjectFind is the cheaper primitive
495
+ // for sizing ONE object and is wrong here, because reconciling needs the
496
+ // complete set on both sides. If this ever calls r2ObjectGet it must
497
+ // re-implement the byte cap and the fail-closed-on-unusable-size rule, because
498
+ // R2Object.size is built by type assertion and is whatever Cloudflare sent.
499
+ // ---------------------------------------------------------------------------
500
+
501
+ interface PortalAccount {
502
+ accountId: string
503
+ portalDbName: string
504
+ bucketName: string
505
+ }
506
+
507
+ /** An account with no portal is not a fault and must not produce a line. Absent,
508
+ * unparseable, and present-but-naming-nothing are all silent — the same rule
509
+ * the availability loop applies to a config with no bookingDbName. */
510
+ function discoverPortalAccounts(accountsDir: string): PortalAccount[] {
511
+ if (!existsSync(accountsDir)) return []
512
+ const found: PortalAccount[] = []
513
+ for (const accountId of readdirSync(accountsDir)) {
514
+ const p = join(accountsDir, accountId, 'data-portal.json')
515
+ if (!existsSync(p)) continue
516
+ try {
517
+ const cfg = JSON.parse(readFileSync(p, 'utf-8')) as { portalDbName?: string; bucketName?: string }
518
+ if (typeof cfg.portalDbName === 'string' && cfg.portalDbName.length > 0 &&
519
+ typeof cfg.bucketName === 'string' && cfg.bucketName.length > 0) {
520
+ found.push({ accountId, portalDbName: cfg.portalDbName, bucketName: cfg.bucketName })
521
+ }
522
+ } catch {
523
+ /* unparseable config — no portal, same as absent */
524
+ }
525
+ }
526
+ return found
527
+ }
528
+
529
+ export async function runDataPortalAudit(root: string): Promise<string> {
530
+ const portals = discoverPortalAccounts(resolve(root, '..', 'data', 'accounts'))
531
+ // Returns BEFORE minting a house token when nothing has a portal. Most
532
+ // installs have neither a data portal nor a house credential, and minting
533
+ // unconditionally would make this loop report an error on every one of them
534
+ // forever — noise that would train an operator to ignore the tag.
535
+ if (portals.length === 0) return 'portals=0 degraded=0'
536
+
537
+ const cf = await makeHouseCfExec(root)
538
+ let degraded = 0
539
+
540
+ for (const portal of portals) {
541
+ let rows: DataPortalRow[] | null = null
542
+ let d1Error: string | null = null
543
+ try {
544
+ rows = parseManifestRows(
545
+ await cf.d1Query(portal.portalDbName, 'SELECT objectKey, ingested, uploadedAt FROM manifest'),
546
+ )
547
+ } catch (err) {
548
+ d1Error = (err as Error).message
549
+ }
550
+
551
+ let objects: Array<{ key: string }> | null = null
552
+ let r2Error: string | null = null
553
+ try {
554
+ objects = await cf.r2ObjectList(portal.bucketName)
555
+ } catch (err) {
556
+ r2Error = (err as Error).message
557
+ }
558
+
559
+ // Each field is whatever remains measurable. The backlog age survives an R2
560
+ // failure because it is computable from the manifest alone — and of the
561
+ // three conditions this audit exists for, a stalled ingestion is the one a
562
+ // half-broken audit can still report.
563
+ const both = rows !== null && objects !== null
564
+ const r = both ? reconcileDataPortal(objects!, rows!, Date.now()) : null
565
+ const age = rows === null
566
+ ? 'na'
567
+ : (reconcileDataPortal([], rows, Date.now()).oldestUningestedHrs?.toFixed(1) ?? 'na')
568
+
569
+ const degradation = [d1Error !== null ? 'd1' : null, r2Error !== null ? 'r2' : null]
570
+ .filter(Boolean)
571
+ .join('+')
572
+ if (degradation) degraded += 1
573
+
574
+ console.error(
575
+ `[data-portal-audit] account=${portal.accountId} ` +
576
+ `objects=${objects?.length ?? 'na'} rows=${rows?.length ?? 'na'} ` +
577
+ `orphanObjects=${r?.orphanObjects.length ?? 'na'} phantomRows=${r?.phantomRows.length ?? 'na'} ` +
578
+ `oldestUningestedHrs=${age}` +
579
+ (degradation ? ` degraded=${degradation} err="${d1Error ?? r2Error}"` : ''),
580
+ )
581
+ }
582
+
583
+ return `portals=${portals.length} degraded=${degraded}`
584
+ }
585
+ ```
586
+
587
+ - [ ] **Step 4: Run to verify it passes**
588
+
589
+ Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
590
+ Expected: PASS, 10 tests.
591
+
592
+ - [ ] **Step 5: Commit**
593
+
594
+ ```bash
595
+ git add maxy-code/platform/ui/server/routes/storage-broker.ts maxy-code/platform/ui/server/__tests__/data-portal-audit.test.ts
596
+ git commit -m "feat(data-portal): audit each portal account, degrading rather than falling silent"
597
+ ```
598
+
599
+ ---
600
+
601
+ ### Task 4: Arm it, and correct the doc
602
+
603
+ **Files:**
604
+ - Modify: `maxy-code/platform/ui/server/index.ts` (import at line 58; arm beside the storage/pages audit block)
605
+ - Modify: `maxy-code/.docs/data-portal.md` (§ Observability)
606
+
607
+ **Interfaces:**
608
+ - Consumes: `runDataPortalAudit` (Task 3).
609
+ - Produces: a `data-portal-audit` row on /activity.
610
+
611
+ - [ ] **Step 1: Arm the loop**
612
+
613
+ Widen the existing import:
614
+
615
+ ```ts
616
+ import storageBrokerRoutes, { runStorageAudit, runPagesAudit, runDataPortalAudit } from './routes/storage-broker'
617
+ ```
618
+
619
+ and append inside the same block that arms `storage-audit` and `pages-audit`:
620
+
621
+ ```ts
622
+ // Data portal reconcile (Task 1704). Same loop shape and rationale as the two
623
+ // above; separate callback so one Cloudflare failure cannot suppress another
624
+ // audit's line. First run offset past storage (20s), pages (35s) and
625
+ // google-account (45s) so a boot does not fire four token mints at once.
626
+ //
627
+ // A DEGRADED RUN IS NOT AN ERROR. runDataPortalAudit catches each account's
628
+ // D1 and R2 failures itself and reports them in the line, so it resolves with
629
+ // a note and the registry records ok — the audit did the job it could and
630
+ // said so. What reaches this catch is a genuine break: no house credential
631
+ // where a portal exists, an unreachable registry. That rethrows, so /activity
632
+ // shows a fresh Last run against a stale Last OK.
633
+ const runDataPortalAuditSafe = () =>
634
+ runDataPortalAudit(auditPlatformRoot).catch((err) => {
635
+ console.error(`[data-portal-audit] error="${(err as Error).message}"`)
636
+ throw err
637
+ })
638
+ registerLoop({
639
+ name: 'data-portal-audit',
640
+ intervalMs: STORAGE_AUDIT_INTERVAL_MS,
641
+ firstRunDelayMs: 50_000,
642
+ run: runDataPortalAuditSafe,
643
+ })
644
+ ```
645
+
646
+ - [ ] **Step 2: Verify the loop gate and the types**
647
+
648
+ Run: `cd maxy-code/platform/ui && node scripts/check-loops-registered.mjs && npx tsc --noEmit -p tsconfig.json`
649
+ Expected: gate passes, 0 type errors.
650
+
651
+ - [ ] **Step 3: Correct § Observability**
652
+
653
+ In `.docs/data-portal.md`, replace the paragraph beginning "The standing `[data-portal-audit]` … is **not built yet**" with the built behaviour: what the line carries, that counts are deliberate and keys are not logged, what each degradation still reports, and that absence of the tag means the audit is not armed and all three conditions are unobservable.
654
+
655
+ - [ ] **Step 4: Run the full storage and audit surface**
656
+
657
+ Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts server/__tests__/storage-broker-object-routes.test.ts server/__tests__/storage-audit-session-concurrency.test.ts`
658
+ Expected: PASS.
659
+
660
+ - [ ] **Step 5: Commit**
661
+
662
+ ```bash
663
+ git add maxy-code/platform/ui/server/index.ts maxy-code/.docs/data-portal.md
664
+ git commit -m "feat(data-portal): arm the standing audit and correct the doc"
665
+ ```
666
+
667
+ ---
668
+
669
+ ## Self-review
670
+
671
+ - **Spec coverage.** Reconcile → Task 1. Envelope parse → Task 2. Caller, discovery, degradation, counts-only line → Task 3. Arming, degraded-is-not-error, doc → Task 4. Mutation check → Task 1 Step 5.
672
+ - **Types.** `DataPortalRow` is defined in Task 1 and consumed by name in Tasks 2 and 3. `runDataPortalAudit` returns `Promise<string>` in Task 3 and is consumed as a note-returning run in Task 4.
673
+ - **No placeholders.** Every code step carries its code.