@rubytech/create-maxy-code 0.1.487 → 0.1.489

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 (130) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +748 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +121 -0
  4. package/payload/platform/lib/active-rules/package-lock.json +12 -0
  5. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js +51 -0
  6. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js.map +1 -1
  7. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts +2 -2
  8. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts.map +1 -1
  9. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js +20 -4
  10. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js.map +1 -1
  11. package/payload/platform/lib/graph-mcp/src/__tests__/schema-cypher-parser.test.ts +55 -0
  12. package/payload/platform/lib/graph-mcp/src/schema-cypher-parser.ts +19 -4
  13. package/payload/platform/lib/graph-style/dist/index.d.ts +24 -0
  14. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  15. package/payload/platform/lib/graph-style/dist/index.js +25 -1
  16. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  17. package/payload/platform/lib/graph-style/src/__tests__/additional-base-labels.test.ts +22 -0
  18. package/payload/platform/lib/graph-style/src/index.ts +27 -0
  19. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +15 -0
  20. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  21. package/payload/platform/lib/storage-broker/dist/audit.d.ts +5 -0
  22. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  23. package/payload/platform/lib/storage-broker/dist/audit.js +3 -2
  24. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  25. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +21 -0
  26. package/payload/platform/lib/storage-broker/src/audit.ts +8 -2
  27. package/payload/platform/neo4j/schema.cypher +105 -0
  28. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -2
  29. package/payload/platform/plugins/cloudflare/PLUGIN.md +22 -2
  30. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
  31. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +75 -13
  32. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +77 -9
  33. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +38 -2
  34. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +72 -0
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +43 -7
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +22 -7
  37. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -2
  38. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +23 -5
  39. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +3 -0
  40. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +46 -5
  41. package/payload/platform/plugins/docs/references/calendar-booking.md +5 -1
  42. package/payload/platform/plugins/docs/references/internals.md +2 -0
  43. package/payload/platform/plugins/memory/PLUGIN.md +1 -0
  44. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +61 -31
  45. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -1
  46. package/payload/platform/plugins/memory/references/schema-construction.md +13 -0
  47. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  48. package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
  49. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  50. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +70 -5
  51. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
  52. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +31 -6
  53. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -1
  54. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +31 -4
  55. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
  56. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +18 -7
  57. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
  58. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +15 -6
  59. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
  60. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +24 -11
  61. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
  62. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +84 -5
  63. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  64. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -0
  65. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +22 -2
  66. package/payload/platform/scripts/__tests__/quarantine-restore.test.sh +143 -0
  67. package/payload/platform/scripts/quarantine-restore.mjs +175 -0
  68. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +24 -7
  69. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +55 -72
  71. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/index.js +11 -8
  73. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  74. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +32 -0
  75. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  76. package/payload/platform/services/whatsapp-channel/dist/notification.js +32 -2
  77. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  78. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +6 -1
  79. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  80. package/payload/platform/services/whatsapp-channel/dist/targets.js +6 -0
  81. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  82. package/payload/platform/templates/account-schema/SCHEMA.md +7 -4
  83. package/payload/server/{chunk-REWXLAPC.js → chunk-UFXRUKXO.js} +4 -2
  84. package/payload/server/public/activity.html +6 -6
  85. package/payload/server/public/assets/{AdminLoginScreens-DF9S5Rca.js → AdminLoginScreens-Dic_fPt6.js} +1 -1
  86. package/payload/server/public/assets/{AdminShell-B0PNDCAb.js → AdminShell-JN7rZ8aG.js} +1 -1
  87. package/payload/server/public/assets/{Checkbox-s5X6dso4.js → Checkbox-CB74kINA.js} +1 -1
  88. package/payload/server/public/assets/{activity-BRBsaalV.js → activity-DbfpHg3i.js} +1 -1
  89. package/payload/server/public/assets/{admin-BoVCP4Mw.js → admin-DvQPTxqe.js} +1 -1
  90. package/payload/server/public/assets/{bot-BVO9Uqr_.js → bot-DnuyRsLS.js} +1 -1
  91. package/payload/server/public/assets/{browser-BVW4FFii.js → browser-q4EFqKvR.js} +1 -1
  92. package/payload/server/public/assets/{calendar-CWsD3uLk.js → calendar-C-q9kWmA.js} +1 -1
  93. package/payload/server/public/assets/chat-DcefIV9q.js +1 -0
  94. package/payload/server/public/assets/chevron-down-Blma_CKl.js +1 -0
  95. package/payload/server/public/assets/chevron-left-xeYDxFiC.js +1 -0
  96. package/payload/server/public/assets/chevron-right-BonbtjqJ.js +1 -0
  97. package/payload/server/public/assets/data-Bu-5SUmR.js +1 -0
  98. package/payload/server/public/assets/{graph-mJ9ky5dc.js → graph-Cyh6FWaV.js} +1 -1
  99. package/payload/server/public/assets/graph-labels-CUQtXFKW.js +1 -0
  100. package/payload/server/public/assets/{maximize-2-yucCepu_.js → maximize-2-k3Fu5hdP.js} +1 -1
  101. package/payload/server/public/assets/{operator-911z_Fy_.js → operator-8NVpNLeD.js} +1 -1
  102. package/payload/server/public/assets/{page-Cv0qUwMG.js → page-Cno8NNMk.js} +1 -1
  103. package/payload/server/public/assets/{page-CgSgj8Cy.js → page-DhwPG5Zf.js} +1 -1
  104. package/payload/server/public/assets/{public-BzwveFwz.js → public-BJ5Oo2Fw.js} +1 -1
  105. package/payload/server/public/assets/{rotate-ccw-BH51cf2Q.js → rotate-ccw-D-ov5jM4.js} +1 -1
  106. package/payload/server/public/assets/tasks-DHMxTZVD.js +1 -0
  107. package/payload/server/public/assets/{time-entry-format-DPdGkp1C.js → time-entry-format-EmV5lz6o.js} +1 -1
  108. package/payload/server/public/assets/{triangle-alert-DO2rjjfV.js → triangle-alert-CivyPfAL.js} +1 -1
  109. package/payload/server/public/assets/{useCopyFeedback-HMSM7-Ir.js → useCopyFeedback-hbnPS-r9.js} +1 -1
  110. package/payload/server/public/assets/{useSubAccountSwitcher-2ojOOYNo.css → useSubAccountSwitcher-kwhG-aJj.css} +1 -1
  111. package/payload/server/public/assets/{useVoiceRecorder-BcukQIza.js → useVoiceRecorder-QN9_GqB_.js} +1 -1
  112. package/payload/server/public/browser.html +4 -4
  113. package/payload/server/public/calendar.html +6 -6
  114. package/payload/server/public/chat.html +13 -13
  115. package/payload/server/public/data.html +10 -10
  116. package/payload/server/public/graph.html +11 -11
  117. package/payload/server/public/index.html +16 -16
  118. package/payload/server/public/operator.html +15 -15
  119. package/payload/server/public/public.html +13 -13
  120. package/payload/server/public/tasks.html +6 -6
  121. package/payload/server/server.js +1036 -784
  122. package/payload/server/{src-JJ6PHO5I.js → src-YJF3UJNR.js} +1 -1
  123. package/payload/server/public/assets/chat-DKGREuIO.js +0 -1
  124. package/payload/server/public/assets/chevron-down-m581tl1J.js +0 -1
  125. package/payload/server/public/assets/chevron-left-BAY0xfmR.js +0 -1
  126. package/payload/server/public/assets/chevron-right-Dy_REdYZ.js +0 -1
  127. package/payload/server/public/assets/data-CtOxa94P.js +0 -1
  128. package/payload/server/public/assets/graph-labels-C8-sIKuv.js +0 -1
  129. package/payload/server/public/assets/tasks-21egY7A6.js +0 -1
  130. /package/payload/server/public/assets/{useSubAccountSwitcher-DLvqvfoc.js → useSubAccountSwitcher-DotTrvX3.js} +0 -0
@@ -114,4 +114,19 @@ const row = (objectKey, ingested, uploadedAt) => ({
114
114
  const mixed = (0, audit_js_1.reconcileDataPortal)([], [row("a", 0, "not-a-date"), row("b", 0, "2026-07-20T11:00:00Z")], now);
115
115
  strict_1.default.equal(mixed.oldestUningestedHrs, 1);
116
116
  });
117
+ (0, node_test_1.default)("uningestedRows counts every pending row, including one the age sample drops", () => {
118
+ const now = Date.parse("2026-07-20T12:00:00Z");
119
+ const r = (0, audit_js_1.reconcileDataPortal)([], [
120
+ row("a", 0, "2026-07-20T11:00:00Z"),
121
+ row("b", 0, "not-a-date"),
122
+ row("c", 1, "2026-07-20T11:00:00Z"),
123
+ ], now);
124
+ strict_1.default.equal(r.uningestedRows, 2);
125
+ strict_1.default.equal(r.oldestUningestedHrs, 1);
126
+ });
127
+ (0, node_test_1.default)("a backlog of only unparseable rows reports a count beside a null age", () => {
128
+ const r = (0, audit_js_1.reconcileDataPortal)([], [row("a", 0, "not-a-date")], 0);
129
+ strict_1.default.equal(r.uningestedRows, 1);
130
+ strict_1.default.equal(r.oldestUningestedHrs, null);
131
+ });
117
132
  //# sourceMappingURL=audit.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"audit.test.js","sourceRoot":"","sources":["../../src/__tests__/audit.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"audit.test.js","sourceRoot":"","sources":["../../src/__tests__/audit.test.ts"],"names":[],"mappings":"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"}
@@ -75,6 +75,11 @@ export interface DataPortalReconcile {
75
75
  /** Null when nothing is uningested, or when no uningested row carries a
76
76
  * parseable timestamp. Rendered `na` by the caller. */
77
77
  oldestUningestedHrs: number | null;
78
+ /** Every row at `ingested = 0`, including rows whose `uploadedAt` does not
79
+ * parse. Deliberately NOT the size of the age sample below: that sample
80
+ * excludes unparseable timestamps, so a backlog made entirely of them would
81
+ * report a null age beside a zero count and read as an empty queue. */
82
+ uningestedRows: number;
78
83
  }
79
84
  /** Pure over two lists and an injected clock, so the age arithmetic is
80
85
  * deterministic under test rather than dependent on when the suite runs.
@@ -1 +1 @@
1
- {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,SAAS,GAAG,iBAAiB,GAAG,iBAAiB,GAAG,oBAAoB,CAAC;AAErF,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAMD;;;;;;;;;;;;;;;;;uDAiBuD;AACvD,MAAM,WAAW,SAAS;IACxB,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,yEAAyE;IACzE,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACtD,mBAAmB,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CAC9F;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,KAAK,EAAE,CAAA;CAAE,CAAC,CA8BpF;AAmBD;;;;;;2CAM2C;AAC3C,MAAM,WAAW,cAAc;IAC7B,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAChC,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAclF;AAoBD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;;uEAKmE;IACnE,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB;4DACwD;IACxD,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;;;;gEAKgE;AAChE,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,gBAAgB,EAAE,EAC3B,IAAI,EAAE,aAAa,EAAE,EACrB,KAAK,EAAE,MAAM,GACZ,mBAAmB,CA6BrB"}
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,SAAS,GAAG,iBAAiB,GAAG,iBAAiB,GAAG,oBAAoB,CAAC;AAErF,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAMD;;;;;;;;;;;;;;;;;uDAiBuD;AACvD,MAAM,WAAW,SAAS;IACxB,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,yEAAyE;IACzE,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACtD,mBAAmB,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CAC9F;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,KAAK,EAAE,CAAA;CAAE,CAAC,CA8BpF;AAmBD;;;;;;2CAM2C;AAC3C,MAAM,WAAW,cAAc;IAC7B,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAChC,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAclF;AAoBD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;;uEAKmE;IACnE,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB;4DACwD;IACxD,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC;;;4EAGwE;IACxE,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;;;gEAKgE;AAChE,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,gBAAgB,EAAE,EAC3B,IAAI,EAAE,aAAa,EAAE,EACrB,KAAK,EAAE,MAAM,GACZ,mBAAmB,CA8BrB"}
@@ -68,8 +68,8 @@ function reconcileDataPortal(objects, rows, nowMs) {
68
68
  // and renders as `oldestUningestedHrs=NaN` — the defect the availability
69
69
  // loop's snapshotAgeMs already had to fix. Unparseable rows are excluded from
70
70
  // the minimum; if that leaves none, the answer is null, never NaN.
71
- const uningestedMs = rows
72
- .filter((r) => r.ingested === 0)
71
+ const uningested = rows.filter((r) => r.ingested === 0);
72
+ const uningestedMs = uningested
73
73
  .map((r) => Date.parse(r.uploadedAt))
74
74
  .filter((ms) => !Number.isNaN(ms));
75
75
  // Reduced, never `Math.min(...uningestedMs)`. A spread passes one argument per
@@ -86,6 +86,7 @@ function reconcileDataPortal(objects, rows, nowMs) {
86
86
  orphanObjects: [...objectKeys].filter((k) => !rowKeys.has(k)),
87
87
  phantomRows: [...rowKeys].filter((k) => !objectKeys.has(k)),
88
88
  oldestUningestedHrs: uningestedMs.length === 0 ? null : (nowMs - oldestMs) / 3_600_000,
89
+ uningestedRows: uningested.length,
89
90
  };
90
91
  }
91
92
  //# sourceMappingURL=audit.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
@@ -147,3 +147,24 @@ test("an unparseable uploadedAt never yields NaN", () => {
147
147
  );
148
148
  assert.equal(mixed.oldestUningestedHrs, 1);
149
149
  });
150
+
151
+ test("uningestedRows counts every pending row, including one the age sample drops", () => {
152
+ const now = Date.parse("2026-07-20T12:00:00Z");
153
+ const r = reconcileDataPortal(
154
+ [],
155
+ [
156
+ row("a", 0, "2026-07-20T11:00:00Z"),
157
+ row("b", 0, "not-a-date"),
158
+ row("c", 1, "2026-07-20T11:00:00Z"),
159
+ ],
160
+ now,
161
+ );
162
+ assert.equal(r.uningestedRows, 2);
163
+ assert.equal(r.oldestUningestedHrs, 1);
164
+ });
165
+
166
+ test("a backlog of only unparseable rows reports a count beside a null age", () => {
167
+ const r = reconcileDataPortal([], [row("a", 0, "not-a-date")], 0);
168
+ assert.equal(r.uningestedRows, 1);
169
+ assert.equal(r.oldestUningestedHrs, null);
170
+ });
@@ -167,6 +167,11 @@ export interface DataPortalReconcile {
167
167
  /** Null when nothing is uningested, or when no uningested row carries a
168
168
  * parseable timestamp. Rendered `na` by the caller. */
169
169
  oldestUningestedHrs: number | null;
170
+ /** Every row at `ingested = 0`, including rows whose `uploadedAt` does not
171
+ * parse. Deliberately NOT the size of the age sample below: that sample
172
+ * excludes unparseable timestamps, so a backlog made entirely of them would
173
+ * report a null age beside a zero count and read as an empty queue. */
174
+ uningestedRows: number;
170
175
  }
171
176
 
172
177
  /** Pure over two lists and an injected clock, so the age arithmetic is
@@ -187,8 +192,8 @@ export function reconcileDataPortal(
187
192
  // and renders as `oldestUningestedHrs=NaN` — the defect the availability
188
193
  // loop's snapshotAgeMs already had to fix. Unparseable rows are excluded from
189
194
  // the minimum; if that leaves none, the answer is null, never NaN.
190
- const uningestedMs = rows
191
- .filter((r) => r.ingested === 0)
195
+ const uningested = rows.filter((r) => r.ingested === 0);
196
+ const uningestedMs = uningested
192
197
  .map((r) => Date.parse(r.uploadedAt))
193
198
  .filter((ms) => !Number.isNaN(ms));
194
199
 
@@ -207,5 +212,6 @@ export function reconcileDataPortal(
207
212
  orphanObjects: [...objectKeys].filter((k) => !rowKeys.has(k)),
208
213
  phantomRows: [...rowKeys].filter((k) => !objectKeys.has(k)),
209
214
  oldestUningestedHrs: uningestedMs.length === 0 ? null : (nowMs - oldestMs) / 3_600_000,
215
+ uningestedRows: uningested.length,
210
216
  };
211
217
  }
@@ -2228,3 +2228,108 @@ OPTIONS {
2228
2228
  `vector.similarity_function`: 'cosine'
2229
2229
  }
2230
2230
  };
2231
+
2232
+ // ---------------------------------------------------------------------------
2233
+ // Vertical node-type declarations (Task 1893).
2234
+ //
2235
+ // Every label below is declared in a `## Node Types` / `## Additional Node
2236
+ // Types` table of a schema-*.md ontology doc but had no line here, so the
2237
+ // schema-doc drift guard could not see it and the first memory-write against a
2238
+ // fresh graph would hit an unregistered label. The guard was blind to them
2239
+ // because it matched only the backticked `:Label` prose form.
2240
+ //
2241
+ // An accountId index, not a uniqueness constraint. Only schema-construction.md
2242
+ // and schema-knowledge-work.md carry an `## Idempotency contracts` section, so
2243
+ // no natural key is agreed for any label here, and several have none that would
2244
+ // be correct: two :StockMovement rows on one day are legitimately distinct, as
2245
+ // are two :Stop rows on one route. accountId is the one property every one of
2246
+ // them requires, and an index can never reject a legitimate write.
2247
+ // ---------------------------------------------------------------------------
2248
+
2249
+ // schema-base.md
2250
+ //
2251
+ // :`Order` and :`Case` are backtick-escaped because ORDER and CASE are Cypher
2252
+ // reserved words. Escaping a label is always legal whether or not the word is
2253
+ // reserved, and seed-neo4j.sh pipes this whole file through cypher-shell under
2254
+ // `set -euo pipefail`, so one unparseable statement aborts the entire install
2255
+ // seed. They are the only two of the 130 declared labels that collide.
2256
+ CREATE INDEX order_account IF NOT EXISTS
2257
+ FOR (n:`Order`) ON (n.accountId);
2258
+
2259
+ CREATE INDEX lead_account IF NOT EXISTS
2260
+ FOR (n:Lead) ON (n.accountId);
2261
+
2262
+ CREATE INDEX case_account IF NOT EXISTS
2263
+ FOR (n:`Case`) ON (n.accountId);
2264
+
2265
+ CREATE INDEX whatsapp_conversation_account IF NOT EXISTS
2266
+ FOR (n:WhatsAppConversation) ON (n.accountId);
2267
+
2268
+ CREATE INDEX whatsapp_message_account IF NOT EXISTS
2269
+ FOR (n:WhatsAppMessage) ON (n.accountId);
2270
+
2271
+ // schema-creator.md
2272
+ CREATE INDEX content_account IF NOT EXISTS
2273
+ FOR (n:Content) ON (n.accountId);
2274
+
2275
+ CREATE INDEX campaign_account IF NOT EXISTS
2276
+ FOR (n:Campaign) ON (n.accountId);
2277
+
2278
+ CREATE INDEX audience_account IF NOT EXISTS
2279
+ FOR (n:Audience) ON (n.accountId);
2280
+
2281
+ CREATE INDEX brand_deal_account IF NOT EXISTS
2282
+ FOR (n:BrandDeal) ON (n.accountId);
2283
+
2284
+ // schema-food-beverage.md
2285
+ CREATE INDEX table_reservation_account IF NOT EXISTS
2286
+ FOR (n:TableReservation) ON (n.accountId);
2287
+
2288
+ CREATE INDEX menu_item_account IF NOT EXISTS
2289
+ FOR (n:MenuItem) ON (n.accountId);
2290
+
2291
+ CREATE INDEX menu_account IF NOT EXISTS
2292
+ FOR (n:Menu) ON (n.accountId);
2293
+
2294
+ CREATE INDEX dining_order_account IF NOT EXISTS
2295
+ FOR (n:DiningOrder) ON (n.accountId);
2296
+
2297
+ // schema-hospitality.md
2298
+ CREATE INDEX reservation_account IF NOT EXISTS
2299
+ FOR (n:Reservation) ON (n.accountId);
2300
+
2301
+ CREATE INDEX room_account IF NOT EXISTS
2302
+ FOR (n:Room) ON (n.accountId);
2303
+
2304
+ // schema-logistics.md
2305
+ CREATE INDEX shipment_account IF NOT EXISTS
2306
+ FOR (n:Shipment) ON (n.accountId);
2307
+
2308
+ CREATE INDEX vehicle_account IF NOT EXISTS
2309
+ FOR (n:Vehicle) ON (n.accountId);
2310
+
2311
+ CREATE INDEX route_account IF NOT EXISTS
2312
+ FOR (n:Route) ON (n.accountId);
2313
+
2314
+ CREATE INDEX stop_account IF NOT EXISTS
2315
+ FOR (n:Stop) ON (n.accountId);
2316
+
2317
+ // schema-professional-services.md
2318
+ CREATE INDEX engagement_account IF NOT EXISTS
2319
+ FOR (n:Engagement) ON (n.accountId);
2320
+
2321
+ CREATE INDEX retainer_account IF NOT EXISTS
2322
+ FOR (n:Retainer) ON (n.accountId);
2323
+
2324
+ CREATE INDEX deliverable_account IF NOT EXISTS
2325
+ FOR (n:Deliverable) ON (n.accountId);
2326
+
2327
+ // schema-retail.md
2328
+ CREATE INDEX product_account IF NOT EXISTS
2329
+ FOR (n:Product) ON (n.accountId);
2330
+
2331
+ CREATE INDEX stock_movement_account IF NOT EXISTS
2332
+ FOR (n:StockMovement) ON (n.accountId);
2333
+
2334
+ CREATE INDEX store_location_account IF NOT EXISTS
2335
+ FOR (n:StoreLocation) ON (n.accountId);
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:103d5688cd9255611a9bf233effbaf7ffd8b45afa48b26d4f92b86febcfac1f5
4
+ content-hash: sha256:643a947eb8968517ad598f7f8b4e433d8545877d7c21f857d4867ac914e34cc9
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -2482,10 +2482,14 @@ This usually completes within a couple of minutes of the booking.
2482
2482
  Bookings are reconciled on a cycle and the system audits itself each run. In the device logs:
2483
2483
 
2484
2484
  - `[calendar-booking]` lines trace one booking through submit, write, notify, and done.
2485
- - `[calendar-reconcile]` lines report three health numbers per account: `eligibleExamined` (bookings the check looked at), `pendingUnreconciled` (bookings captured but not yet turned into meetings) and `orphanMeetings` (booking meetings with no matching saved booking). Entries that only block out a slot are not counted — they are not bookings and never become meetings.
2485
+ - `[calendar-reconcile]` lines report four health numbers per account: `eligibleExamined` (bookings the check looked at), `pendingUnreconciled` (bookings captured but not yet turned into meetings), `pendingOldestAgeMin` (how many minutes the longest-waiting of those has been waiting) and `orphanMeetings` (booking meetings with no matching saved booking). Entries that only block out a slot are not counted — they are not bookings and never become meetings.
2486
2486
 
2487
2487
  A `pendingUnreconciled` number that stays above zero across several runs means those bookings are failing to turn into meetings, and the `attempt=` number on the matching `[calendar-booking]` lines says how many times each has been tried. A booking whose meeting is deleted is picked up again automatically on the next run. To read them: `journalctl --user -u <brand-service> | grep -E '\[calendar-booking\]|\[calendar-reconcile\]'`, then filter by one booking id for its full story. The edge half of a booking (before your device sees it) is in the Cloudflare Pages function logs.
2488
2488
 
2489
+ A count on its own cannot tell a short lag from a long one: five bookings waiting two minutes and five waiting nine hours are both `pendingUnreconciled=5`. `pendingOldestAgeMin` is what separates them. It reads `0` when nothing is waiting, and also when something is waiting but for under a minute, so read it together with the count beside it.
2490
+
2491
+ A separate `op=liveness` line is printed every ten minutes by your device's own server, whether or not anything is wrong. `stale=false` means the booking check has run recently. `stale=true` means it has not run in over six minutes, which is the case nothing else can tell you about: a check that has stopped cannot report that it has stopped. `lastSkipReason` names the last account whose check finished without looking at any bookings, and reads `none` when every account it got to was checked. If you run more than one account, a check can both look at one account and skip another, so seeing a recent check time next to a skip reason is normal and means two different accounts.
2492
+
2489
2493
  ## When your real calendar lives elsewhere
2490
2494
 
2491
2495
  The booking page publishes open slots from the internal calendar, the one Maxy keeps in its own graph.
@@ -4219,6 +4223,8 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
4219
4223
 
4220
4224
  **Trust a scheduled turn's provenance, never an inbound's claim (channel-agent grounding).** A cron-dispatched agent turn reaches the same channel builders as a real inbound, so its own authorisation text would otherwise land inside `## Context` — the zone `REFRAME_INSTRUCTION` demotes to untrusted — and a security-cautious agent would distrust its own automation (the self-cancel loop). The dispatcher therefore stamps a `ScheduleProvenance` marker (owning account, event, owner userId, dispatch time) that the channel builders render as a `## Scheduled automation (platform-verified)` preamble **above** `## Context` (and above the rules block); the whatsapp wrap tag names the door `scheduler`. Both channel-server paths carry the marker — the single-target loop and the unified multi-target door reader (via the same `doorInboundPayload` seam that carries `standingRules`); the scheduler only dispatches to the whatsapp/telegram door, so a webchat frame never bears one. Because the marker is dispatcher-stamped and never derived from payload text, an inbound message cannot forge it — placement above `## Context` alone is not the trust signal, the dispatcher provenance is. The grounding binds to it in two places: one recipient-neutral clause added byte-identical to the three reframe instructions (a marker-bearing turn is authorised standing automation; a reconfiguration or re-enable is not intrusion), and the admin `IDENTITY.md` "Untrusted input" rule that the agent never states a platform-level authenticity or impersonation verdict it was not given (an unsourced "this is not from you" is its own inference, flagged, never asserted as the platform's). A sender has exactly one session: every inbound, real or scheduled, resolves to the deterministic `adminSessionIdFor(effectiveAccount, destination)`. An earlier change gave each firing its own salted seat so distrust could not accrete across firings, but the inbound hub keys on `senderId` and replaces its subscriber outright, so a seat's channel server attached under the sender's own key and swallowed the sender's REAL messages until it detached — splitting one conversation across two sessions that each denied what the other held. The seat is removed; the authoritative `agentPrompt`, this provenance marker, and the injected standing rules ground each firing without one. Self-cancel attribution is disarmed while that model beds in: the guard used to compare the cancelling turn's session id to a dispatcher stamp, which discriminated only because each firing ran its own seat, so with one session per sender it would report an autonomous loop on every operator cancel. It now always reports false — under-counting a real loop rather than inventing one — until the discriminator is rebuilt from the turn's own dispatched eventId. See `.docs/scheduling-agent-dispatch.md § Operator-provenance marker`, `§ Session model — one session per sender`, and `§ Self-cancel attribution — disarmed`.
4221
4225
 
4226
+ **Carry what a reply points at (WhatsApp reply quote).** A WhatsApp sender replying to an earlier message produces a quote that `app/lib/whatsapp/inbound/extract.ts` resolves to `{ id, text?, sender? }` and `manager.ts` assigns to the route payload's `replyContext`. That field had no reader: the `server/index.ts` gateway argument omitted it, `AdminInbound` and the hub frame had no such field, and the channel service's `composePayload` emitted the caption and media notes only, so a message saying "prepare an email confirmation for this booking" reached the agent with nothing to resolve "this booking" against. It now rides both WhatsApp admin doors. On the persistent door it travels `server/index.ts` -> `AdminInbound` -> the hub frame the SSE route stringifies -> the channel service's `InboundPayload`, where `composeReplyNote` renders it as a bracketed note at the top of the payload, inside `## Context` and above the caption and any woven media notes: the quoted body is sender-controlled and belongs in the reframed untrusted zone, never in the platform preamble. Both channel-server inbound paths carry it, through the same `doorInboundPayload` seam that carries `standingRules` and the schedule provenance marker: the single-target SSE loop parses the whole frame, and the unified multi-target door reader rebuilds the payload field by field, so a field that seam omits is dropped on multi-target sessions alone. That seam is guarded by a test which reads the `InboundPayload` declaration and the rebuild function rather than a fixture, because the pre-existing byte-identity guard compared one hardcoded payload and so stayed green for every field added after it was written. On the passive account-manager door there is no channel server and no `## Context` frame, so the one-shot's whole initial prompt is read at face value; `composePassivePrompt` therefore puts the BARE sentence above the requirement and never the quoted body. The body arrives from the sender's own client and is never checked against the message store, so quoting it in a platform-voiced bracket on the door with no untrusted zone would let a modified client put words the agent never said into a `work-create` filing. The empty-text drop gate still tests the requirement alone, so a quote never rescues an inbound carrying no requirement of its own. The quoted body is capped at `QUOTED_TEXT_CAP` (500 characters, `…` appended when cut) because it is unbounded sender-controlled input, and the cut lands on whole characters so a body whose 500th boundary falls inside a surrogate pair cannot emit a lone surrogate at a position the sender chose. Only the channel service renders a body, so the cap lives there alone; platform/ui's `app/lib/whatsapp/reply-quote.ts` holds just the bare sentence for the passive door, and both packages hardcode that sentence in their own tests so the two doors cannot drift apart on the wording (the packages never cross-import, the same arrangement `REFRAME_INSTRUCTION` and `MediaRef` use). Telegram and webchat extract no quote at all, so neither carries one. `payloadBytes` measures the whole inner payload, so a reply carrying a quote produces a visibly larger `[reframe] op=applied … payloadBytes=<n>` than the same text without one, and a bare-text-length `payloadBytes` on an inbound whose store row has a non-null `quotedId` is the signature of this defect returning. Because the field is optional at every hop, dropping it from either `server/index.ts` argument would be silent (tsc stays green, and no unit test executes that wiring file), so the `check-reply-context-threaded` build gate asserts both argument sites.
4227
+
4222
4228
  **Observability.** `[preference-write] op=reconcile … mode=<reinforce|update|contradict|new> similarity=…` — a stream of `mode=new` for subjects that already exist is the duplicate-minting signature. `[preference-inject] op=inject channel=<wa|tg|web> injected=<N> owner=<8> source=<owner|no-owner|owner-error|fetch-error>` — injection selects only the account owner's admin preferences, so `source=owner injected=0` is a genuinely empty owner, `source=no-owner` is an account with no seeded owner (injects nothing, a missed owner seeding), and `injected=0 source=owner` on an account whose owner holds rules is the write-only regression re-appearing. `[preference-audit] op=drift accountId=… storedActive=… injectedActive=… duplicateClusters=…` — the standing reconciliation audit (claude-session-manager, 5-minute tick) flags any account whose store holds more active rows than injection would surface, the no-event detector for drift.
4223
4229
 
4224
4230
  ---
@@ -73,11 +73,11 @@ The setup-done claim only fires when `curl -I https://<hostname>` issued from ou
73
73
 
74
74
  The portal shows a sub-account's deliverable folders alongside what the client uploaded. Which folders those are is never a list in the code: it is derived per account from that account's own `SCHEMA.md` — the buckets in its `<!-- ontology-buckets -->` region plus `output`, intersected with its `allowed-top-level` block. A brand on a different vertical exposes different folders with no code change. `bin/schema-exposed-dirs.mjs` is the only place that decision is made. An operator can add non-ontology folders (a workflow folder such as `inbound-invoices`) by listing them in `exposeFolders` in the account's `data-portal.json`; each must match `[A-Za-z0-9_-]+` and is never a system folder (`agents`, `secrets`, `specialists`, `state`, `memory`, `cache`, `logs`, `tmp`) or dot-prefixed. Those extras union on top of the schema-derived set and, unlike ontology buckets, are not intersected with `allowed-top-level`; empty or absent behaves as before.
75
75
 
76
- `bin/portal-index-push.mjs` walks those folders and full-replaces the account's rows in the portal's D1 `directory` table each cycle. Metadata only — names, sizes, times — so listing survives the device being offline, and a deleted or renamed file converges on the next cycle without a tombstone. The scheduling heartbeat runs it hourly for every account carrying a `data-portal.json`; it is also runnable standalone, which is how it stays operable while the heartbeat spawn path is unproven on a device.
76
+ `bin/portal-index-push.mjs` walks those folders and full-replaces the account's rows in the portal's D1 `directory` table each cycle. Metadata only — names, sizes, times — so listing survives the device being offline, and a deleted or renamed file converges on the next cycle without a tombstone. Every walked folder gets a row of its own marked `isDir`, not only the files inside it, so an exposed folder holding nothing at any depth still renders and opens; a folder whose `readdir` fails publishes nothing, so the tree never offers one the device does not have. `isDir` is a later column and `IF NOT EXISTS` adds nothing to an existing table, so a portal that predates it needs one hand-run `ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0`. The scheduling heartbeat runs it hourly for every account carrying a `data-portal.json`; it is also runnable standalone, which is how it stays operable while the heartbeat spawn path is unproven on a device.
77
77
 
78
78
  Opening a file reaches the install: the portal probes it, then mints an `/api/portal/fetch` link signed with the account's `PORTAL_FETCH_SECRET` and valid for five minutes. The install verifies the signature **and independently re-derives the exposed folder set**, so a correctly signed link for a non-exposed path is still refused. That second check is the security boundary, not the signature.
79
79
 
80
- Exposure is narrowed twice. The install re-derives the account's exposed set from `SCHEMA.md` on every request and refuses anything outside it, which is the account-isolation boundary. On top of that, a person's `people.folders` grant (written at enrolment, empty means the full set) narrows them to named top-level folders inside that set. The grant lives only in the portal's D1, so a portal compromise widens a person back to the account's exposed set and never beyond it. `op=list` carries `granted=<n|full>` and an out-of-grant download logs `op=fetch-through result=not-granted`, distinct from `result=absent`.
80
+ Exposure is narrowed twice. The install re-derives the account's exposed set from `SCHEMA.md` on every request and refuses anything outside it, which is the account-isolation boundary. On top of that, a person's `people.folders` grant (written at enrolment, empty means the full set) narrows them to named top-level folders inside that set. The grant lives only in the portal's D1, so a portal compromise widens a person back to the account's exposed set and never beyond it. `op=list` carries `granted=<n|full>` and an out-of-grant download logs `op=fetch-through result=not-granted`, distinct from `result=absent`. A download naming a folder logs `result=is-directory` and mints no link, distinct from both.
81
81
 
82
82
  What to watch, on the device:
83
83
 
@@ -94,6 +94,26 @@ What to watch, on the device:
94
94
 
95
95
  `op=audit` is the standing check, and it is the only one that speaks when the push does **not**. It runs in-process inside the heartbeat rather than as a spawn, and reads only local state, so it cannot be blinded by the same failure it is watching for. It fires hourly even when everything is healthy — an audit that goes quiet on success is indistinguishable from an audit that stopped running. `result=never-ran` on an account with a `data-portal.json` means no cycle has ever succeeded for it; `result=stale` with `ageMin` means cycles have stopped. Its input is written only after `op=verify`, so a stale entry means the push stopped succeeding rather than merely stopped logging.
96
96
 
97
+ ## Data-portal upload pull
98
+
99
+ The index push publishes what the device holds; the pull is the other direction. An upload lands in R2 under the person's prefix with one `manifest` row at `ingested = 0`, and the `portal-uploads-pull` loop in the admin server carries it onto the device every minute. It does not live in this plugin: it sits beside `runDataPortalAudit` in `platform/ui/server/portal-uploads-pull.ts`, because that audit already resolves the house Cloudflare credential and each account's portal database and bucket, and a second enumeration or credential path is how the two would drift.
100
+
101
+ Per pending row the order is fetch, write, verify, then claim. The object is fetched by `objectKey` through the storage broker, written to a dot-prefixed `.uploads-tmp/` partial (invisible to the index walk, so a partial can never be published as a deliverable), renamed into `uploads/<ownerName>/<filename>`, and only then is `ingested` set to 1 — and only if the written size equals `manifest.size`. A row claimed before the bytes land is a lost file carrying a flag that says it arrived. Anything that fails leaves the row at 0 and it is retried next cycle. `ownerName` and `filename` must each be a single path segment, checked on the device rather than trusted from the portal's own gate, and `fileId` must be a UUID before it reaches a statement, because `d1Query` takes raw SQL and binds nothing.
102
+
103
+ What to watch, on the device:
104
+
105
+ ```
106
+ [portal-pull] op=claim account=<id> fileId=<uuid> bytes=<n> result=written|fetch-failed|write-failed|verify-failed|claim-failed|bad-owner|bad-filename|bad-fileid
107
+ [portal-pull] op=done account=<id> pulled=<n> failed=<n>
108
+ [portal-pull] op=malformed account=<id> rows=<n>
109
+ [portal-pull] op=failed account=<id> err="<message>"
110
+ [data-portal-audit] account=<id> … oldestUningestedHrs=<n|na> pendingRows=<n|na>
111
+ ```
112
+
113
+ `[data-portal-audit]` is the standing signal, printed every cycle including at `pendingRows=0`, so a quiet line reads as healthy rather than as a stopped pull. When a client says a file never arrived: a `pendingRows` that stays non-zero with a rising `oldestUningestedHrs` means the pull runs and cannot finish, so read `result=` on `op=claim`. `pendingRows=na` means the manifest read itself failed, a different fault. No `[data-portal-audit]` line at all means the audit loop is not running, a third. Both counts are computed from the manifest alone, so they survive R2 being unreachable — which is exactly when a stalled pull matters most. `result=claim-failed` is the one outcome that leaves a file on disk with its row unclaimed; the next cycle re-fetches and overwrites the same path, which is deliberate.
114
+
115
+ Log lines carry counts and ids only. An object key is `<ownerId>/<filename>`, a real person's filename, and the brand journal is not where those belong.
116
+
97
117
  On the portal side `[data-portal] op=list` carries `account=` and `op=fetch-through` carries the outcome; on the install `[portal-fetch]` logs `op=request` then exactly one of `op=served`, `op=denied`, `op=miss`. A portal `fetch-through` with no matching install `op=request` means the request never arrived, rather than having been refused. `[portal-fetch] op=no-secret account=<id>` is an unconfigured portal, which the `op=denied reason=secret` line alone would not distinguish from a bad signature.
98
118
 
99
119
  ## Discipline
@@ -57,14 +57,23 @@ export async function readExposeFolders(accountDir) {
57
57
  }
58
58
 
59
59
  /**
60
- * Files under `exposed` dirs, at any depth. Dot-prefixed entries are skipped at
61
- * every level: `.uploads-tmp/` holds in-flight upload partials (files.ts:25) and
62
- * must never be published as a deliverable.
60
+ * Files AND directories under `exposed` dirs, at any depth, each row carrying
61
+ * `isDir`. Directories are published rather than left to be derived from file
62
+ * prefixes, because a folder holding no files at any depth has no prefix to be
63
+ * derived from and would not exist to the client at all.
64
+ *
65
+ * A directory row is emitted only after its own `readdir` succeeds, so an
66
+ * exposed name that is absent or unreadable on disk still contributes nothing
67
+ * and the portal never renders a folder the device does not have.
68
+ *
69
+ * Dot-prefixed entries are skipped at every level: `.uploads-tmp/` holds
70
+ * in-flight upload partials (files.ts:25) and must never be published as a
71
+ * deliverable, as a file row or as a folder.
63
72
  * @param {string} accountDir @param {string[]} exposed
64
- * @returns {Promise<{relPath: string, sizeBytes: number, modifiedAt: string}[]>}
73
+ * @returns {Promise<{relPath: string, sizeBytes: number, modifiedAt: string, isDir: boolean}[]>}
65
74
  */
66
75
  export async function walkExposed(accountDir, exposed) {
67
- /** @type {{relPath: string, sizeBytes: number, modifiedAt: string}[]} */
76
+ /** @type {{relPath: string, sizeBytes: number, modifiedAt: string, isDir: boolean}[]} */
68
77
  const out = []
69
78
 
70
79
  /** @param {string} rel */
@@ -75,6 +84,14 @@ export async function walkExposed(accountDir, exposed) {
75
84
  } catch {
76
85
  return // absent or unreadable dir contributes nothing
77
86
  }
87
+ try {
88
+ const st = await stat(join(accountDir, rel))
89
+ // sizeBytes 0 rather than the directory inode's own size: the column
90
+ // means "bytes a client can fetch", and a folder has none.
91
+ out.push({ relPath: rel, sizeBytes: 0, modifiedAt: st.mtime.toISOString(), isDir: true })
92
+ } catch {
93
+ // vanished between the readdir and the stat; the next cycle settles it
94
+ }
78
95
  for (const e of entries) {
79
96
  if (e.name.startsWith('.')) continue
80
97
  const childRel = `${rel}/${e.name}`
@@ -83,7 +100,12 @@ export async function walkExposed(accountDir, exposed) {
83
100
  } else if (e.isFile()) {
84
101
  try {
85
102
  const st = await stat(join(accountDir, childRel))
86
- out.push({ relPath: childRel, sizeBytes: st.size, modifiedAt: st.mtime.toISOString() })
103
+ out.push({
104
+ relPath: childRel,
105
+ sizeBytes: st.size,
106
+ modifiedAt: st.mtime.toISOString(),
107
+ isDir: false,
108
+ })
87
109
  } catch {
88
110
  // vanished mid-walk; the next cycle settles it
89
111
  }
@@ -159,8 +181,8 @@ export async function pushAccount(opts) {
159
181
  await client.query('DELETE FROM directory WHERE accountId = ?', [accountId])
160
182
  for (const row of rows) {
161
183
  await client.query(
162
- 'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES (?, ?, ?, ?, ?)',
163
- [accountId, row.relPath, row.sizeBytes, row.modifiedAt, nowIso],
184
+ 'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt, isDir) VALUES (?, ?, ?, ?, ?, ?)',
185
+ [accountId, row.relPath, row.sizeBytes, row.modifiedAt, nowIso, row.isDir ? 1 : 0],
164
186
  )
165
187
  }
166
188
  log(`${TAG} op=replace account=${accountId} deleted=all inserted=${rows.length}`)
@@ -2,19 +2,28 @@ import { describe, it, expect } from 'vitest'
2
2
  import { processFiles, entriesUnder } from '../../skills/data-portal/template/functions/api/files'
3
3
  import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
4
4
 
5
+ // Mirrors what the push writes: a row per folder as well as per file. `jobs` is
6
+ // exposed and holds nothing, which is the case that had no row at all before.
5
7
  const DIRECTORY = [
6
- { accountId: 'acc-a', relPath: 'output/report.pdf', sizeBytes: 10, modifiedAt: '2026-07-20T10:00:00.000Z' },
7
- { accountId: 'acc-a', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' },
8
- { accountId: 'acc-a', relPath: 'quotes/CR2974/quote.pdf', sizeBytes: 30, modifiedAt: '2026-07-20T12:00:00.000Z' },
9
- { accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z' },
8
+ { accountId: 'acc-a', relPath: 'output', sizeBytes: 0, modifiedAt: '2026-07-20T10:00:00.000Z', isDir: 1 },
9
+ { accountId: 'acc-a', relPath: 'output/report.pdf', sizeBytes: 10, modifiedAt: '2026-07-20T10:00:00.000Z', isDir: 0 },
10
+ { accountId: 'acc-a', relPath: 'quotes', sizeBytes: 0, modifiedAt: '2026-07-20T12:00:00.000Z', isDir: 1 },
11
+ { accountId: 'acc-a', relPath: 'quotes/CR2969', sizeBytes: 0, modifiedAt: '2026-07-20T11:00:00.000Z', isDir: 1 },
12
+ { accountId: 'acc-a', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z', isDir: 0 },
13
+ { accountId: 'acc-a', relPath: 'quotes/CR2974', sizeBytes: 0, modifiedAt: '2026-07-20T12:00:00.000Z', isDir: 1 },
14
+ { accountId: 'acc-a', relPath: 'quotes/CR2974/quote.pdf', sizeBytes: 30, modifiedAt: '2026-07-20T12:00:00.000Z', isDir: 0 },
15
+ { accountId: 'acc-a', relPath: 'jobs', sizeBytes: 0, modifiedAt: '2026-07-20T14:00:00.000Z', isDir: 1 },
16
+ { accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z', isDir: 0 },
10
17
  ]
11
18
 
12
19
  function makeEnv(
13
20
  sessions: Record<string, { ownerId: string; accountId: string; folders?: string }>,
21
+ onQuery: (query: string) => void = () => {},
14
22
  ) {
15
23
  return {
16
24
  DB: {
17
25
  prepare(query: string) {
26
+ onQuery(query)
18
27
  let bound: unknown[] = []
19
28
  const stmt = {
20
29
  bind(...v: unknown[]) {
@@ -47,9 +56,9 @@ type Entry = { name: string; kind: string; relPath: string; sizeBytes: number |
47
56
  describe('entriesUnder', () => {
48
57
  const rows = DIRECTORY.filter((d) => d.accountId === 'acc-a')
49
58
 
50
- it('synthesises top-level folders from row paths', () => {
59
+ it('lists top-level folders, including one holding no files', () => {
51
60
  const e = entriesUnder(rows, '')
52
- expect(e.map((x) => x.name)).toEqual(['output', 'quotes'])
61
+ expect(e.map((x) => x.name)).toEqual(['jobs', 'output', 'quotes'])
53
62
  expect(e.every((x) => x.kind === 'directory')).toBe(true)
54
63
  })
55
64
 
@@ -57,6 +66,42 @@ describe('entriesUnder', () => {
57
66
  expect(entriesUnder(rows, 'quotes').map((x) => x.name)).toEqual(['CR2969', 'CR2974'])
58
67
  })
59
68
 
69
+ // An empty folder must open, not read as missing. Its own row is what puts it
70
+ // in the parent listing; inside it there is nothing to show.
71
+ it('opens an empty folder to an empty listing', () => {
72
+ expect(entriesUnder(rows, 'jobs')).toEqual([])
73
+ })
74
+
75
+ // The explicit row and the row implied by a nested file name the same folder.
76
+ // Two entries for one folder would render it twice.
77
+ it('collapses an explicit folder row and one implied by a nested file', () => {
78
+ const both = [
79
+ { relPath: 'quotes', sizeBytes: 0, modifiedAt: '2026-07-20T12:00:00.000Z', isDir: 1 },
80
+ { relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z', isDir: 0 },
81
+ ]
82
+ const e = entriesUnder(both, '')
83
+ expect(e.length).toBe(1)
84
+ expect(e[0]).toEqual({
85
+ name: 'quotes',
86
+ kind: 'directory',
87
+ relPath: 'quotes',
88
+ sizeBytes: null,
89
+ modifiedAt: null,
90
+ })
91
+ })
92
+
93
+ // Without the isDir check a folder row has no slash left after the prefix and
94
+ // lands in the file list, where clicking it would try to download a folder.
95
+ it('never renders a folder row as a file', () => {
96
+ const e = entriesUnder(
97
+ [{ relPath: 'jobs', sizeBytes: 0, modifiedAt: '2026-07-20T14:00:00.000Z', isDir: 1 }],
98
+ '',
99
+ )
100
+ expect(e).toEqual([
101
+ { name: 'jobs', kind: 'directory', relPath: 'jobs', sizeBytes: null, modifiedAt: null },
102
+ ])
103
+ })
104
+
60
105
  it('lists files inside a leaf folder with size and time', () => {
61
106
  expect(entriesUnder(rows, 'quotes/CR2969')).toEqual([
62
107
  {
@@ -71,9 +116,9 @@ describe('entriesUnder', () => {
71
116
 
72
117
  it('puts folders before files and files newest first', () => {
73
118
  const mixed = [
74
- { relPath: 'output/old.pdf', sizeBytes: 1, modifiedAt: '2026-01-01T00:00:00.000Z' },
75
- { relPath: 'output/new.pdf', sizeBytes: 1, modifiedAt: '2026-09-01T00:00:00.000Z' },
76
- { relPath: 'output/sub/x.pdf', sizeBytes: 1, modifiedAt: '2026-05-01T00:00:00.000Z' },
119
+ { relPath: 'output/old.pdf', sizeBytes: 1, modifiedAt: '2026-01-01T00:00:00.000Z', isDir: 0 },
120
+ { relPath: 'output/new.pdf', sizeBytes: 1, modifiedAt: '2026-09-01T00:00:00.000Z', isDir: 0 },
121
+ { relPath: 'output/sub/x.pdf', sizeBytes: 1, modifiedAt: '2026-05-01T00:00:00.000Z', isDir: 0 },
77
122
  ]
78
123
  expect(entriesUnder(mixed, 'output').map((x) => x.name)).toEqual(['sub', 'new.pdf', 'old.pdf'])
79
124
  })
@@ -85,9 +130,18 @@ describe('entriesUnder', () => {
85
130
  })
86
131
 
87
132
  describe('processFiles directory listing', () => {
88
- it('returns synthesised folders for the session account', async () => {
133
+ it('returns the account folders, including one holding no files', async () => {
89
134
  const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
90
- expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['output', 'quotes'])
135
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['jobs', 'output', 'quotes'])
136
+ })
137
+
138
+ // The column is what tells a folder row from a file row; a SELECT that drops
139
+ // it renders every folder as an unopenable file.
140
+ it('reads isDir in the directory select', async () => {
141
+ const queries: string[] = []
142
+ const env = makeEnv(SESSIONS, (q) => queries.push(q))
143
+ await processFiles('sess-a', env, () => {}, 0, '')
144
+ expect(queries.some((q) => /FROM directory/i.test(q) && /isDir/.test(q))).toBe(true)
91
145
  })
92
146
 
93
147
  it('never leaks another account rows', async () => {
@@ -129,15 +183,23 @@ describe('processFiles directory listing', () => {
129
183
  it('an empty grant shows the full set', async () => {
130
184
  const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: '' } })
131
185
  const r = await processFiles('sess-a', env, () => {}, 0, '')
132
- expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['output', 'quotes'])
186
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['jobs', 'output', 'quotes'])
133
187
  })
134
188
 
135
189
  it('a grant naming an absent folder shows nothing', async () => {
136
- const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'jobs' } })
190
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'parts' } })
137
191
  const r = await processFiles('sess-a', env, () => {}, 0, '')
138
192
  expect(r.payload.entries).toEqual([])
139
193
  })
140
194
 
195
+ // The grant narrows the account's exposed set, and an empty folder is inside
196
+ // that set like any other: outside the grant it stays hidden.
197
+ it('an empty folder outside the grant stays hidden', async () => {
198
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } })
199
+ const r = await processFiles('sess-a', env, () => {}, 0, '')
200
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).not.toContain('jobs')
201
+ })
202
+
141
203
  it('the grant holds when browsing into a granted folder', async () => {
142
204
  const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } })
143
205
  const r = await processFiles('sess-a', env, () => {}, 0, 'quotes')