@bongos/core 1.23.21 → 1.24.1

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 (457) hide show
  1. package/.bongos-core.json +983 -428
  2. package/.claude/skills/blocker-review/SKILL.md +9 -1
  3. package/.claude/skills/blocker-solve/SKILL.md +1 -1
  4. package/.claude/skills/builder-claim/SKILL.md +1 -1
  5. package/.claude/skills/builder-reauth/SKILL.md +1 -1
  6. package/.claude/skills/builder-redteam/SKILL.md +1 -1
  7. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  8. package/.claude/skills/builder-setup/SKILL.md +1 -1
  9. package/.claude/skills/builder-ship/SKILL.md +1 -1
  10. package/.claude/skills/collab-review/SKILL.md +5 -2
  11. package/.claude/skills/design/SKILL.md +1 -1
  12. package/.claude/skills/goal-create/SKILL.md +6 -3
  13. package/.claude/skills/grade-recover/SKILL.md +1 -1
  14. package/.claude/skills/merge-mode/SKILL.md +1 -1
  15. package/.claude/skills/owner-review/SKILL.md +1 -1
  16. package/.claude/skills/planning-session/SKILL.md +11 -11
  17. package/.claude/skills/read-session-export/SKILL.md +1 -1
  18. package/.claude/skills/recall/SKILL.md +1 -1
  19. package/.claude/skills/scan-before-install/SKILL.md +1 -1
  20. package/.claude/skills/session-handoff/SKILL.md +1 -1
  21. package/.claude/skills/strand-fix/SKILL.md +13 -0
  22. package/.claude/skills/worktree-clean/SKILL.md +1 -1
  23. package/AGENTS.md +15 -0
  24. package/GEMINI.md +5 -0
  25. package/clients/bongos-client/README.md +1 -1
  26. package/clients/bongos-client/bongos-client.global.js +94 -0
  27. package/clients/bongos-client/index.cjs +94 -0
  28. package/clients/bongos-client/index.d.ts +154 -4
  29. package/clients/bongos-client/index.mjs +94 -0
  30. package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
  31. package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
  32. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
  33. package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
  34. package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
  35. package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
  36. package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
  37. package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
  38. package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
  39. package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
  40. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
  41. package/docs/adr/README.md +10 -1
  42. package/docs/api/openapi.json +8619 -5885
  43. package/docs/api-reference.md +68 -9
  44. package/docs/architecture.md +37 -1
  45. package/docs/canonical-permissions.md +1 -1
  46. package/docs/copy-inventory.md +420 -337
  47. package/docs/copy-registry.json +1321 -500
  48. package/docs/file-map.md +4 -0
  49. package/docs/module-api-changelog.md +7 -0
  50. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
  51. package/docs/onboarding/diagrams/README.md +1 -1
  52. package/docs/onboarding/drachmae-and-karma.md +15 -15
  53. package/docs/onboarding/primer.md +2 -2
  54. package/docs/page-inventory.json +55 -6
  55. package/docs/page-readings.json +1185 -1011
  56. package/docs/recipes/google-sign-in.md +60 -0
  57. package/docs/recipes/ops-gotchas.md +1 -1
  58. package/migrations/core_282_builders_lower_login_idx.sql +33 -0
  59. package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
  60. package/migrations/core_283_payout_approvals.sql +183 -0
  61. package/migrations/core_284_payout_gates.sql +179 -0
  62. package/migrations/core_285_task_needs.sql +74 -0
  63. package/modules/ai-studio/CLAUDE.md +15 -0
  64. package/modules/ai-studio/billing.js +67 -0
  65. package/modules/ai-studio/context.js +29 -0
  66. package/modules/ai-studio/db.js +172 -0
  67. package/modules/ai-studio/gemini.js +75 -0
  68. package/modules/ai-studio/generate.js +119 -0
  69. package/modules/ai-studio/keys.js +73 -0
  70. package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
  71. package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
  72. package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
  73. package/modules/ai-studio/module.json +26 -0
  74. package/modules/ai-studio/openrouter.js +90 -0
  75. package/modules/ai-studio/payments.js +105 -0
  76. package/modules/ai-studio/pricing.js +92 -0
  77. package/modules/ai-studio/routes/ai-studio.js +252 -0
  78. package/modules/ai-studio/studio.js +121 -0
  79. package/modules/autonomy/fence.js +64 -1
  80. package/modules/builder-settings/builder-needs.js +27 -1
  81. package/modules/copy-desk/routes/copy-desk.js +5 -0
  82. package/modules/economy/achievements.js +22 -10
  83. package/modules/economy/cost-classify.js +9 -0
  84. package/modules/economy/cost-write-guard.js +52 -0
  85. package/modules/economy/cost.js +2 -1
  86. package/modules/economy/credits.js +18 -15
  87. package/modules/economy/goal-reward.js +161 -0
  88. package/modules/economy/reward.js +4 -0
  89. package/modules/economy/routes/cost.js +33 -7
  90. package/modules/government/catalog.js +6 -0
  91. package/modules/government/db.js +108 -0
  92. package/modules/government/docket.js +36 -0
  93. package/modules/government/migrations/government_035_payout_approve.sql +29 -0
  94. package/modules/government/pollers/board-expiry.js +108 -20
  95. package/modules/government/protected-surfaces.json +1 -0
  96. package/modules/government/routes/government.js +50 -18
  97. package/modules/government/routing.js +39 -5
  98. package/modules/government/waiting.js +74 -0
  99. package/modules/grading/grader-subagent.js +30 -11
  100. package/modules/hall-ui/public/ai-studio-lib.js +268 -0
  101. package/modules/hall-ui/public/ai-studio.css +193 -0
  102. package/modules/hall-ui/public/ai-studio.html +98 -0
  103. package/modules/hall-ui/public/ai-studio.js +556 -0
  104. package/modules/hall-ui/public/ai-studio.states.json +94 -0
  105. package/modules/hall-ui/public/blockers-by-person.js +156 -0
  106. package/modules/hall-ui/public/blockers-lib.js +15 -1
  107. package/modules/hall-ui/public/blockers.html +8 -0
  108. package/modules/hall-ui/public/blockers.js +1 -1
  109. package/modules/hall-ui/public/city.html +15 -15
  110. package/modules/hall-ui/public/claim-action.js +3 -0
  111. package/modules/hall-ui/public/collab.html +12 -0
  112. package/modules/hall-ui/public/craft-mode.js +4 -1
  113. package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
  114. package/modules/hall-ui/public/drachmae.html +1 -1
  115. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  116. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  117. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  118. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  119. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  120. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  121. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  122. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  123. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  124. package/modules/hall-ui/public/goal-inbox.js +1 -1
  125. package/modules/hall-ui/public/goals-render.js +4 -0
  126. package/modules/hall-ui/public/goals.html +8 -0
  127. package/modules/hall-ui/public/gov-living.js +6 -2
  128. package/modules/hall-ui/public/hall-ask.css +5 -0
  129. package/modules/hall-ui/public/hall-ask.js +39 -3
  130. package/modules/hall-ui/public/notes-panel.css +20 -0
  131. package/modules/hall-ui/public/notes-panel.js +146 -0
  132. package/modules/hall-ui/public/project-settings.html +1 -1
  133. package/modules/hall-ui/public/project-settings.js +7 -5
  134. package/modules/hall-ui/public/settings-ai-keys.js +124 -0
  135. package/modules/hall-ui/public/settings.html +15 -0
  136. package/modules/hall-ui/public/street-deck.js +12 -3
  137. package/modules/hall-ui/public/studio.css +90 -8
  138. package/modules/hall-ui/public/studio.html +39 -10
  139. package/modules/hall-ui/public/studio.js +148 -18
  140. package/modules/hall-ui/public/task.html +2 -0
  141. package/modules/hall-ui/public/task.js +4 -0
  142. package/modules/hall-ui/public/tweak-editor.js +12 -3
  143. package/modules/hall-ui/public/welcome-boot.css +42 -6
  144. package/modules/hall-ui/public/welcome-boot.js +20 -6
  145. package/modules/hall-ui/public/work.js +20 -0
  146. package/modules/hall-ui/records/ai-studio.md +36 -0
  147. package/modules/hall-ui/records/blockers-people.md +24 -0
  148. package/modules/lifecycle/db-analytics.js +10 -3
  149. package/modules/lifecycle/db-claims.js +32 -4
  150. package/modules/lifecycle/db-goals.js +48 -18
  151. package/modules/lifecycle/db-shared.js +9 -0
  152. package/modules/lifecycle/db-tasks.js +15 -6
  153. package/modules/lifecycle/db-versions.js +21 -8
  154. package/modules/lifecycle/done-when.js +23 -1
  155. package/modules/lifecycle/lifecycle.js +5 -0
  156. package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
  157. package/modules/lifecycle/module.json +2 -1
  158. package/modules/lifecycle/notes.js +219 -0
  159. package/modules/lifecycle/page-notice.js +124 -0
  160. package/modules/lifecycle/page-tweak-artist.js +18 -1
  161. package/modules/lifecycle/publish-reconciler.js +67 -20
  162. package/modules/lifecycle/routes/claims.js +5 -2
  163. package/modules/lifecycle/routes/goals.js +3 -1
  164. package/modules/lifecycle/routes/notes.js +91 -0
  165. package/modules/lifecycle/routes/project-settings.js +43 -35
  166. package/modules/lifecycle/routes/task-route-helpers.js +21 -0
  167. package/modules/lifecycle/routes/task-write-routes.js +21 -3
  168. package/modules/lifecycle/routes/tasks.js +12 -12
  169. package/modules/lifecycle/ship-assurance.js +4 -1
  170. package/modules/lifecycle/ship-card.js +8 -2
  171. package/modules/lifecycle/task-needs-gate.js +70 -0
  172. package/modules/market-watch/CLAUDE.md +15 -0
  173. package/modules/market-watch/db.js +340 -0
  174. package/modules/market-watch/key.js +58 -0
  175. package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
  176. package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
  177. package/modules/market-watch/module.json +26 -0
  178. package/modules/market-watch/pollers/hub-seed.js +54 -0
  179. package/modules/market-watch/pollers/weekly-run.js +100 -0
  180. package/modules/market-watch/research.js +350 -0
  181. package/modules/market-watch/run.js +245 -0
  182. package/modules/market-watch/seed/market-data.json +38 -0
  183. package/modules/market-watch/seed/partners-data.json +497 -0
  184. package/modules/market-watch/seed.js +252 -0
  185. package/modules/market-watch/store.js +230 -0
  186. package/modules/memory/routes/memory.js +14 -2
  187. package/modules/memory/routes/search.js +8 -2
  188. package/modules/onboarding/howto-moments.js +8 -1
  189. package/modules/platform-identity/account-logins.js +192 -0
  190. package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
  191. package/modules/platform-identity/platform-identity.js +5 -4
  192. package/modules/platform-identity/routes/my-projects.js +6 -0
  193. package/modules/platform-identity/routes/profile.js +18 -0
  194. package/modules/platform-identity/routes/public-profile.js +10 -1
  195. package/modules/platform-identity/routes/sso.js +26 -1
  196. package/modules/platform-identity/tests/platform-identity.mjs +2 -1
  197. package/modules/provisioning/credential-preflight.js +13 -3
  198. package/modules/provisioning/pollers/app-liveness.js +25 -5
  199. package/modules/provisioning/routes/provisioning.js +1 -1
  200. package/modules/provisioning/starter-bundles.js +8 -2
  201. package/modules/public-landing/public/account.html +10 -2
  202. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
  203. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  204. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  205. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
  206. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  207. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
  208. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
  209. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  210. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  211. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
  212. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
  213. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  214. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  215. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
  216. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
  217. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
  218. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
  219. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
  220. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
  221. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
  222. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
  223. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
  224. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
  225. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
  226. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
  227. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
  228. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
  229. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
  230. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
  231. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
  232. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
  233. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
  234. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
  235. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
  236. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
  237. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
  238. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
  239. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
  240. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
  241. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
  242. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
  243. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
  244. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
  245. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
  246. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
  247. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
  248. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
  249. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
  250. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
  251. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
  252. package/modules/public-landing/public/community.html +1 -1
  253. package/modules/public-landing/public/connections.html +2 -2
  254. package/modules/public-landing/public/guild.html +1 -1
  255. package/modules/public-landing/public/invitations.html +2 -2
  256. package/modules/public-landing/public/projects.html +5 -5
  257. package/modules/raise/views/nda.html +2 -0
  258. package/modules/specialities/db.js +32 -1
  259. package/modules/specialities/routes/specialities.js +10 -6
  260. package/modules/status-ui/public/index.html +1 -1
  261. package/package-lock.json +2 -2
  262. package/package.json +1 -1
  263. package/release-notes.json +286 -0
  264. package/scripts/bongos/autobongos-tweak-lane.js +46 -1
  265. package/scripts/bongos/backfill-subagent-rewards.js +2 -2
  266. package/scripts/bongos/claim.js +22 -1
  267. package/scripts/bongos/db-backup-nightly.sh +29 -0
  268. package/scripts/bongos/deploy-divergence.js +7 -0
  269. package/scripts/bongos/doc-cli-guard.js +6 -0
  270. package/scripts/bongos/fitness.js +3 -2
  271. package/scripts/bongos/gen-diagrams.js +3 -3
  272. package/scripts/bongos/lib.sh +4 -5
  273. package/scripts/bongos/migration-namespace.js +13 -1
  274. package/scripts/bongos/plain-cards.js +4 -0
  275. package/scripts/bongos/provision-github-setup.js +3 -1
  276. package/scripts/bongos/provision-units.js +6 -1
  277. package/scripts/bongos/publish-manifest.js +4 -0
  278. package/scripts/bongos/release.js +2 -0
  279. package/scripts/bongos/run-unit-tests.js +268 -82
  280. package/scripts/bongos/seed-tasks.example.json +56 -0
  281. package/scripts/bongos/seed-tasks.js +222 -0
  282. package/scripts/bongos/seed-verify-criteria.js +1 -2
  283. package/scripts/bongos/ship-finish.js +2 -0
  284. package/scripts/bongos/ship-resume.js +2 -0
  285. package/scripts/bongos/strand-lease.js +250 -0
  286. package/scripts/bongos/strand-watch.js +129 -12
  287. package/scripts/bongos/version-literal-guard.js +2 -2
  288. package/scripts/discord/post.js +4 -3
  289. package/scripts/gds/seed-tasks.js +7 -0
  290. package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
  291. package/scripts/hall-preview/server.js +21 -0
  292. package/src/bongos/auth-config.js +29 -1
  293. package/src/bongos/auth-discord.js +3 -3
  294. package/src/bongos/auth-github.js +3 -6
  295. package/src/bongos/auth-google.js +217 -0
  296. package/src/bongos/device-approval.js +140 -0
  297. package/src/bongos/llm-pricing.js +17 -1
  298. package/src/bongos/metrics.js +48 -2
  299. package/src/bongos/middleware/rate-limit.js +30 -11
  300. package/src/bongos/module-scope-map.js +16 -1
  301. package/src/bongos/optimizer.js +15 -1
  302. package/src/bongos/payout-approvals.js +342 -0
  303. package/src/bongos/payout-gates.js +364 -0
  304. package/src/bongos/prelaunch-gate.js +7 -9
  305. package/src/bongos/prelaunch-switch.js +20 -0
  306. package/src/bongos/route-rank-check.js +3 -0
  307. package/src/bongos/routes/auth-google.js +246 -0
  308. package/src/bongos/routes/auth.js +38 -1
  309. package/src/bongos/routes/backup.js +33 -9
  310. package/src/bongos/routes/builders.js +57 -30
  311. package/src/bongos/routes/github.js +9 -0
  312. package/src/bongos/routes/me.js +18 -0
  313. package/src/bongos/routes/my-payouts.js +133 -0
  314. package/src/bongos/routes/payout-approvals.js +312 -0
  315. package/src/bongos/routes/payout-gates.js +179 -0
  316. package/src/bongos/routes.js +15 -1
  317. package/src/bongos/secret-box.js +18 -3
  318. package/src/bongos/serve-internal.js +47 -19
  319. package/src/bongos/sign-in-pages.js +138 -0
  320. package/src/bongos/task-needs.js +146 -0
  321. package/src/bongos-downloads.js +18 -1
  322. package/src/instance-config.js +28 -0
  323. package/src/module-api.js +14 -1
  324. package/src/platform-server.js +27 -1
  325. package/tests/account_logins.mjs +326 -0
  326. package/tests/account_logins_db.mjs +161 -0
  327. package/tests/ai_studio_billing.mjs +253 -0
  328. package/tests/ai_studio_generate.mjs +282 -0
  329. package/tests/ai_studio_keys.mjs +159 -0
  330. package/tests/ai_studio_requests.mjs +319 -0
  331. package/tests/auth_backchannel_logout_route.mjs +201 -0
  332. package/tests/auto_satisfy_criteria.mjs +10 -1
  333. package/tests/autobongos_tweak_lane.mjs +144 -0
  334. package/tests/autobongos_tweak_pin_identity.mjs +137 -0
  335. package/tests/backfill_subagent_rewards.mjs +17 -1
  336. package/tests/backup_trigger_lock.mjs +119 -0
  337. package/tests/batched_writes_parity.mjs +340 -0
  338. package/tests/blockers_people.mjs +198 -0
  339. package/tests/bounded_rate_maps.mjs +184 -0
  340. package/tests/claimable_blocker_status.mjs +1 -1
  341. package/tests/claims_batch_routes.mjs +421 -0
  342. package/tests/conductor_main_e2e.mjs +4 -0
  343. package/tests/config_read_cache.mjs +171 -0
  344. package/tests/copy_desk_page_editor.mjs +12 -0
  345. package/tests/cost_write_guard.mjs +200 -0
  346. package/tests/craft_achievements.mjs +33 -10
  347. package/tests/criterion_goal_attach.mjs +1 -1
  348. package/tests/db_backup_nightly.mjs +73 -1
  349. package/tests/deploy_divergence_line.mjs +7 -0
  350. package/tests/effective_visibility_predicate.mjs +12 -0
  351. package/tests/fitness.mjs +19 -0
  352. package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
  353. package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
  354. package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
  355. package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
  356. package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
  357. package/tests/github_one_trip.mjs +34 -0
  358. package/tests/github_repo_visibility.mjs +20 -0
  359. package/tests/goal_achievement.mjs +30 -1
  360. package/tests/goal_achievement_reward.mjs +205 -0
  361. package/tests/goal_archive_disposition.mjs +5 -5
  362. package/tests/goal_sort_staleness.mjs +2 -1
  363. package/tests/goal_tier.mjs +11 -1
  364. package/tests/google_fonts_nonblocking.mjs +84 -0
  365. package/tests/google_sign_in.mjs +401 -0
  366. package/tests/government_board_expiry.mjs +94 -0
  367. package/tests/government_protected_surfaces.mjs +1 -1
  368. package/tests/government_routes.mjs +146 -23
  369. package/tests/grader.mjs +31 -0
  370. package/tests/hall_ai_studio.mjs +347 -0
  371. package/tests/hall_city.mjs +8 -0
  372. package/tests/hall_page_gate_map.mjs +3 -0
  373. package/tests/hall_studio_home.mjs +61 -2
  374. package/tests/home_attention_cards.mjs +1 -1
  375. package/tests/hook_shell_tools.mjs +8 -0
  376. package/tests/hooks_drain.mjs +1 -1
  377. package/tests/host_topology_skips.mjs +2 -0
  378. package/tests/howto_moments.mjs +17 -0
  379. package/tests/human_signal.mjs +141 -0
  380. package/tests/install_script_reader.mjs +51 -0
  381. package/tests/integration_fixture_ids.mjs +82 -0
  382. package/tests/invite_expiry_db.mjs +13 -1
  383. package/tests/lib_sh_resolution.mjs +0 -4
  384. package/tests/market_watch_data.mjs +244 -0
  385. package/tests/market_watch_data_db.mjs +308 -0
  386. package/tests/market_watch_module.mjs +114 -0
  387. package/tests/market_watch_run.mjs +503 -0
  388. package/tests/metrics.mjs +124 -0
  389. package/tests/migration_namespace.mjs +14 -0
  390. package/tests/module_api.mjs +2 -0
  391. package/tests/module_loader.mjs +1 -1
  392. package/tests/module_skills_hook.mjs +69 -0
  393. package/tests/notes.mjs +182 -0
  394. package/tests/own_scope_guard.mjs +5 -0
  395. package/tests/page_tweak_marker_sql.mjs +76 -0
  396. package/tests/page_tweak_no_block.mjs +135 -0
  397. package/tests/page_tweak_notice.mjs +103 -0
  398. package/tests/payout_approvals.mjs +253 -0
  399. package/tests/payout_approvals_db.mjs +106 -0
  400. package/tests/payout_gates.mjs +322 -0
  401. package/tests/payout_gates_db.mjs +113 -0
  402. package/tests/planning_session_skill.mjs +13 -3
  403. package/tests/project_settings_page.mjs +29 -5
  404. package/tests/project_settings_spend_cap_route.mjs +235 -0
  405. package/tests/promotion_sweep.mjs +29 -1
  406. package/tests/provision.mjs +3 -2
  407. package/tests/provisioning_app_status.mjs +18 -0
  408. package/tests/provisioning_credential_preflight.mjs +53 -0
  409. package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
  410. package/tests/public_landing_projects.mjs +37 -0
  411. package/tests/publish_reconciler_overlap.mjs +60 -0
  412. package/tests/rank_change_broadcast_port.mjs +151 -0
  413. package/tests/seed_tasks.mjs +176 -0
  414. package/tests/seed_verify_criteria.mjs +2 -6
  415. package/tests/session_start_freshness.mjs +66 -1
  416. package/tests/shell_hooks.mjs +209 -0
  417. package/tests/ship_assurance.mjs +28 -0
  418. package/tests/ship_card.mjs +20 -5
  419. package/tests/site_classified_once.mjs +142 -0
  420. package/tests/speciality_db_shape.mjs +23 -0
  421. package/tests/speciality_session_skills.mjs +48 -2
  422. package/tests/strand_lease.mjs +249 -0
  423. package/tests/strand_watch.mjs +58 -0
  424. package/tests/task_criteria_routes.mjs +275 -0
  425. package/tests/task_criteria_unlink_db.mjs +123 -0
  426. package/tests/task_needs.mjs +233 -0
  427. package/tests/task_needs_db.mjs +110 -0
  428. package/tests/unit_runner_serial.mjs +266 -43
  429. package/tests/version_close_route.mjs +1 -1
  430. package/tests/version_literals.mjs +5 -7
  431. package/tests/welcome_boot.mjs +16 -5
  432. package/tests/welcome_boot_browser.mjs +36 -4
  433. package/docs/recipes/bongos-cli-release.md +0 -66
  434. package/scripts/bongos/backfill-disciplines.js +0 -144
  435. package/scripts/bongos/backfill-session-earnings.js +0 -123
  436. package/scripts/bongos/backfill-session-types.js +0 -87
  437. package/scripts/bongos/build-bongos.js +0 -94
  438. package/scripts/bongos/cleanup-orphans.js +0 -74
  439. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
  440. package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
  441. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
  442. package/scripts/bongos/smoke-batch-claim.sh +0 -238
  443. package/scripts/bongos/smoke-batch-release.sh +0 -316
  444. package/scripts/bongos/smoke-claim-consistency.sh +0 -222
  445. package/scripts/bongos/smoke-dependencies.sh +0 -143
  446. package/scripts/gds/backfill-disciplines.js +0 -7
  447. package/scripts/gds/backfill-session-earnings.js +0 -7
  448. package/scripts/gds/backfill-session-types.js +0 -7
  449. package/scripts/gds/cleanup-orphans.js +0 -7
  450. package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
  451. package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
  452. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
  453. package/scripts/gds/smoke-batch-claim.sh +0 -8
  454. package/scripts/gds/smoke-batch-release.sh +0 -8
  455. package/scripts/gds/smoke-claim-consistency.sh +0 -8
  456. package/scripts/gds/smoke-dependencies.sh +0 -8
  457. package/tests/smoke_dependencies_witness.mjs +0 -46
@@ -20,20 +20,38 @@ const express = require('express');
20
20
  // intra-module require — a module never requires a core internal.
21
21
  const api = require('../../../src/module-api');
22
22
  const cost = require('../cost');
23
+ const { checkCostWrite, MAX_ROW_USD } = require('../cost-write-guard');
23
24
  const { validateOrRespond, LIMITS } = api;
24
25
 
25
26
  const COST_CATEGORIES = ['api', 'compute', 'infra', 'domain', 'art', 'other'];
26
27
 
27
- module.exports = function buildCostRouter() {
28
+ // Has this builder ever claimed this task? A released claim still counts: a
29
+ // session's cost lands after its ship frees the claim (task 1004959). Served by
30
+ // idx_claims_task_all (claims (task_id), core_263): a task carries a handful of
31
+ // claims ever, so the builder_id filter runs over those rows, never the table.
32
+ async function builderHasClaimed(taskId, builderId) {
33
+ const { rows } = await api.pool.query(
34
+ 'SELECT 1 FROM claims WHERE task_id = $1 AND builder_id = $2 LIMIT 1',
35
+ [taskId, builderId],
36
+ );
37
+ return rows.length > 0;
38
+ }
39
+
40
+ // deps.logCost / deps.hasClaimed are test seams; production uses the real ones.
41
+ module.exports = function buildCostRouter(deps = {}) {
28
42
  // The Treasury's floors and record in the Governor City (task 1004527).
29
43
  require('../governor-docket').contributeEconomyToDocket();
30
44
  const router = express.Router();
45
+ const logCost = deps.logCost || ((...a) => cost.logCost(...a));
46
+ const hasClaimed = deps.hasClaimed || builderHasClaimed;
31
47
 
32
48
  // rank: any-builder — log cost on own claim (idempotent via source/source_ref).
49
+ // Who may book what (reserved sources, the caller's own task, the per-row cap)
50
+ // is cost-write-guard.js — security report 1000029.
33
51
  router.post('/cost', api.requireBuilder, async (req, res) => {
34
52
  if (validateOrRespond(req, res, {
35
53
  task_id: { type: 'integer', min: 1 },
36
- amount_usd: { required: true, type: 'number', min: 0, max: 1000000 },
54
+ amount_usd: { required: true, type: 'number', min: 0, max: MAX_ROW_USD },
37
55
  // Don't enum-restrict category — the existing skills set categories that
38
56
  // may legitimately drift; just cap the length. (CLAUDE.md mentions
39
57
  // api|compute|infra|domain|art|other as the canonical set.)
@@ -45,10 +63,10 @@ module.exports = function buildCostRouter() {
45
63
  // System 1 / task 1344: optional per-tier USD split (cost_log columns
46
64
  // added by migration 123). session-cost.js sends these so spend can be
47
65
  // attributed by tier (cache_read is the marathon sink). Omitted → NULL.
48
- fresh_input_usd: { type: 'number', min: 0, max: 1000000 },
49
- cache_read_usd: { type: 'number', min: 0, max: 1000000 },
50
- cache_write_usd: { type: 'number', min: 0, max: 1000000 },
51
- output_usd: { type: 'number', min: 0, max: 1000000 },
66
+ fresh_input_usd: { type: 'number', min: 0, max: MAX_ROW_USD },
67
+ cache_read_usd: { type: 'number', min: 0, max: MAX_ROW_USD },
68
+ cache_write_usd: { type: 'number', min: 0, max: MAX_ROW_USD },
69
+ output_usd: { type: 'number', min: 0, max: MAX_ROW_USD },
52
70
  // V3.R11 (#91): builder_id is optional. When absent we default to the
53
71
  // authed builder (the right answer 99% of the time). When provided it
54
72
  // must match the authed builder — we don't let one builder attribute
@@ -85,7 +103,15 @@ module.exports = function buildCostRouter() {
85
103
  recordedAtDate = parsed;
86
104
  }
87
105
  try {
88
- const out = await cost.logCost({
106
+ const refused = await checkCostWrite({
107
+ source,
108
+ taskId: taskId ?? null,
109
+ rank: req.builder?.rank,
110
+ rankMeets: api.rankMeetsThreshold,
111
+ callerHasClaimed: (id) => hasClaimed(id, authedBuilderId),
112
+ });
113
+ if (refused) return res.fail(refused.code, { status: refused.status, message: refused.message });
114
+ const out = await logCost({
89
115
  taskId: taskId ?? null,
90
116
  amountUsd,
91
117
  category,
@@ -238,6 +238,12 @@ const PERMISSIONS = [
238
238
  // Review the store's proposed sub-categories (task 1004355, ADR 0342 D3): approve,
239
239
  // rename then approve, or merge into an existing one (a merge moves its modules).
240
240
  { key: 'module.subcategory.review', system: false, floor: 'metic', guards: 'GET /store/subcategories/queue, POST /store/subcategories/:id/approve, POST /store/subcategories/:id/merge' },
241
+ // The project's authorised person for money (task 1004913, ADR 0385 D4): sign,
242
+ // narrow or revoke a standing payout rule, hold a noticed run, approve one payout,
243
+ // sign one equity grant or contract, and read the approval log. Archon by default;
244
+ // not a system key, so a project may hand it to a treasurer rank of its own. The
245
+ // routes additionally refuse anything but a person's own web session.
246
+ { key: 'payout.approve', system: false, floor: 'archon', guards: 'GET /payouts/approvals, GET /payouts/rules, POST /payouts/rules[/:id/{narrow,revoke,runs,runs/:runKey/hold}], POST /payouts/{approve,grants,contracts}' },
241
247
  // Take a module out of the store (task 1003797, ADR 0338 D2). Stops new installs,
242
248
  // versions and downloads; never reaches an instance that already holds it.
243
249
  { key: 'module.delist', system: false, floor: 'metic', guards: 'POST /store/modules/:key/delist, POST /store/modules/:key/versions/:version/release (a version the promotion gate held on tester — task 1003804), POST /store/modules/:key/versions/:version/withdraw (take a version back — task 1004895)' },
@@ -169,6 +169,79 @@ async function grantRankPermission({ rankKey, permissionKey }) {
169
169
  return rowCount > 0;
170
170
  }
171
171
 
172
+ // Add a recommended rank set in ONE transaction and three statements (task
173
+ // 1004929) — the batched form of "createRank, then grantRankPermission per key",
174
+ // which used to cost a read, an insert and one insert per grant for every rank,
175
+ // each its own round trip and none of them atomic. A rank that already exists is
176
+ // LEFT ALONE: it is neither inserted nor granted, and a rank a concurrent call
177
+ // created between the read and the insert is caught by ON CONFLICT DO NOTHING —
178
+ // the batched twin of the old per-row 23505 catch. Grants go only to the ranks
179
+ // THIS call created. Still a dumb write: `permissionKeys` arrive screened against
180
+ // the catalog by the route (routes/government.js), which owns that decision.
181
+ //
182
+ // `ranks`: [{ rankKey, label, description, permissionKeys }], in set order.
183
+ // Returns a Map of rank_key → the permission keys actually granted, in input
184
+ // order, for each rank this call created; a key absent from the Map was skipped.
185
+ async function applyRankSet({ ranks, createdBy }) {
186
+ const firstByKey = new Map();
187
+ for (const r of ranks) if (!firstByKey.has(r.rankKey)) firstByKey.set(r.rankKey, r);
188
+ const wanted = [...firstByKey.values()];
189
+ const created = new Map();
190
+ if (wanted.length === 0) return created;
191
+ const client = await pool.connect();
192
+ try {
193
+ await client.query('BEGIN');
194
+ const { rows: existing } = await client.query(
195
+ `SELECT rank_key FROM government_ranks WHERE rank_key = ANY($1::text[])`,
196
+ [wanted.map((r) => r.rankKey)],
197
+ );
198
+ const have = new Set(existing.map((row) => row.rank_key));
199
+ const fresh = wanted.filter((r) => !have.has(r.rankKey));
200
+ if (fresh.length > 0) {
201
+ // WITH ORDINALITY + ORDER BY keeps the ids in set order, as the loop did.
202
+ const { rows: inserted } = await client.query(
203
+ `INSERT INTO government_ranks (rank_key, label, description, is_system, seeded_rank, created_by)
204
+ SELECT r.rank_key, r.label, r.description, false, NULL, $4::bigint
205
+ FROM unnest($1::text[], $2::text[], $3::text[]) WITH ORDINALITY AS r(rank_key, label, description, ord)
206
+ ORDER BY r.ord
207
+ ON CONFLICT DO NOTHING
208
+ RETURNING rank_key`,
209
+ [fresh.map((r) => r.rankKey), fresh.map((r) => r.label), fresh.map((r) => r.description ?? null), createdBy],
210
+ );
211
+ const made = new Set(inserted.map((row) => row.rank_key));
212
+ const grantRanks = [];
213
+ const grantKeys = [];
214
+ for (const r of fresh) {
215
+ if (!made.has(r.rankKey)) continue;
216
+ created.set(r.rankKey, []);
217
+ for (const key of new Set(r.permissionKeys)) { grantRanks.push(r.rankKey); grantKeys.push(key); }
218
+ }
219
+ if (grantKeys.length > 0) {
220
+ const { rows: granted } = await client.query(
221
+ `INSERT INTO government_rank_permissions (rank_key, permission_key)
222
+ SELECT g.rank_key, g.permission_key
223
+ FROM unnest($1::text[], $2::text[]) WITH ORDINALITY AS g(rank_key, permission_key, ord)
224
+ ORDER BY g.ord
225
+ ON CONFLICT (rank_key, permission_key) DO NOTHING
226
+ RETURNING rank_key, permission_key`,
227
+ [grantRanks, grantKeys],
228
+ );
229
+ const landed = new Set(granted.map((row) => `${row.rank_key}\u0000${row.permission_key}`));
230
+ grantRanks.forEach((rk, i) => {
231
+ if (landed.has(`${rk}\u0000${grantKeys[i]}`)) created.get(rk).push(grantKeys[i]);
232
+ });
233
+ }
234
+ }
235
+ await client.query('COMMIT');
236
+ } catch (err) {
237
+ await client.query('ROLLBACK').catch(() => {});
238
+ throw err;
239
+ } finally {
240
+ client.release();
241
+ }
242
+ return created;
243
+ }
244
+
172
245
  async function revokeRankPermission({ rankKey, permissionKey }) {
173
246
  const { rowCount } = await pool.query(
174
247
  `DELETE FROM government_rank_permissions
@@ -356,9 +429,26 @@ async function openBoardItem({ subjectType, subjectId, openedBy = null, constitu
356
429
  RETURNING *`,
357
430
  [subjectType, subjectId, openedBy, windowMinutes, JSON.stringify(constitution || {})],
358
431
  );
432
+ // Tell the expiry poller (task 1004960): it sleeps until the soonest deadline
433
+ // it knows of, so a window shorter than its idle wait must wake it sooner.
434
+ // closes_at is now() + the window, so the window IS the time until close.
435
+ // A listener the poller registers (not a require: the poller requires this
436
+ // file). Never let a hint fail an open.
437
+ if (rows[0] && windowMinutes != null) {
438
+ for (const fn of deadlineListeners) {
439
+ try { fn(Number(windowMinutes) * 60_000); } catch (_) { /* best-effort */ }
440
+ }
441
+ }
359
442
  return rows[0] || null;
360
443
  }
361
444
 
445
+ // Who wants to hear that a timed window opened (the expiry poller's start()).
446
+ const deadlineListeners = new Set();
447
+ function onBoardDeadline(fn) {
448
+ deadlineListeners.add(fn);
449
+ return () => deadlineListeners.delete(fn);
450
+ }
451
+
362
452
  async function getOpenBoardItem(subjectType, subjectId) {
363
453
  const { rows } = await pool.query(
364
454
  `SELECT * FROM government_board_items
@@ -1041,6 +1131,21 @@ async function getExpiredOpenBoardItems(limit = 100) {
1041
1131
  return rows;
1042
1132
  }
1043
1133
 
1134
+ // The expiry poller's alarm clock (task 1004960): how many milliseconds until
1135
+ // the soonest OPEN deadline, measured on the DATABASE clock (so a skewed
1136
+ // process clock can never make the poller wake early or late), or null when no
1137
+ // open item has a clock. ONE read, served by the same partial index as the
1138
+ // sweep — the poller sleeps until this deadline instead of asking every minute.
1139
+ async function msUntilSoonestOpenDeadline() {
1140
+ const { rows } = await pool.query(
1141
+ `SELECT EXTRACT(EPOCH FROM (MIN(closes_at) - now())) * 1000 AS ms
1142
+ FROM government_board_items
1143
+ WHERE closed_at IS NULL AND closes_at IS NOT NULL`,
1144
+ );
1145
+ const ms = rows[0] ? rows[0].ms : null;
1146
+ return ms == null ? null : Number(ms);
1147
+ }
1148
+
1044
1149
  // The summons read (task 1003271): every OPEN item this builder could still
1045
1150
  // vote on, with the two facts that decide it — who authored the subject, and
1046
1151
  // whether this builder has already cast. Membership is NOT decided here: it
@@ -1165,6 +1270,7 @@ module.exports = {
1165
1270
  getRankByKey,
1166
1271
  createRank,
1167
1272
  grantRankPermission,
1273
+ applyRankSet,
1168
1274
  revokeRankPermission,
1169
1275
  resetRankGrants,
1170
1276
  deleteCustomRank,
@@ -1186,6 +1292,8 @@ module.exports = {
1186
1292
  withdrawBoardItem,
1187
1293
  getIdeaCompleteness,
1188
1294
  getExpiredOpenBoardItems,
1295
+ msUntilSoonestOpenDeadline,
1296
+ onBoardDeadline,
1189
1297
  getLatestReturnedItemWithObjection,
1190
1298
  getLatestPassedItem,
1191
1299
  listPassedSubjectIds,
@@ -85,6 +85,7 @@ const docketFloors = require('./docket-floors');
85
85
  const governmentConfig = require('./config');
86
86
  const asksDb = require('./asks-db');
87
87
  const routing = require('./routing');
88
+ const waiting = require('./waiting');
88
89
  const { ownerOf } = require('./leads');
89
90
 
90
91
  const log = api.logger('government');
@@ -847,6 +848,40 @@ async function loadSeats(government) {
847
848
  }
848
849
  }
849
850
 
851
+ // OPEN BLOCKERS UNDER THE PEOPLE THEY WAIT ON (task 1004999). The blockers source is
852
+ // read exactly as the docket reads it (the ideas module's contribution, under its own
853
+ // `anyBuilder` gate — the list is public), each one carries its stored ask, and the
854
+ // seats are read once, uncached (ADR 0016). waiting.js groups them with routing.js's
855
+ // rule, so this answers "whose is it" exactly as the City does. A board seat's holders
856
+ // are read only when some blocker names one. → { available, people, nobody, yours }
857
+ // `available: false` names why nothing could be grouped — never an empty list
858
+ // standing in for "nobody is waiting".
859
+ async function blockersByPerson(builder, { contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS } = {}) {
860
+ const c = contributions.sources.get('blockers');
861
+ if (!c) return { available: false, reason: 'off', people: [], nobody: [], yours: waiting.forViewer(null, builder.id) };
862
+ const permissions = await resolver.resolveBuilderPermissions(builder.id);
863
+ const held = new Set(permissions || []);
864
+ const ctx = Object.freeze({ builderId: String(builder.id), rank: builder.rank || null, has: (p) => held.has(p) });
865
+ if (!gateAllows(c.gate, ctx)) return { available: false, reason: 'not_yours', people: [], nobody: [], yours: waiting.forViewer(null, builder.id) };
866
+ let matters;
867
+ try {
868
+ const raw = await withTimeout(Promise.resolve(c.read(ctx, { now })), timeoutMs);
869
+ matters = (Array.isArray(raw) ? raw : []).map((m) => normalizeMatter(m, { source: 'blockers', building: c.building || 'planning' })).filter(Boolean);
870
+ } catch (err) {
871
+ log.warn({ err: err && err.message }, 'blockers by person: blockers unreadable');
872
+ return { available: false, reason: 'unavailable', people: [], nobody: [], yours: waiting.forViewer(null, builder.id) };
873
+ }
874
+ await attachAsks(matters, asksDb.asksFor, timeoutMs);
875
+ const boardSeat = matters.some((m) => /^board:/.test(routing.seatOf(m)));
876
+ const government = boardSeat
877
+ ? await loadGovernment({ decisions: await board.decisionRulesView().catch(() => null) })
878
+ : { holders: {} };
879
+ const seats = await loadSeats(government);
880
+ if (!seats) return { available: false, reason: 'unavailable', people: [], nobody: [], yours: waiting.forViewer(null, builder.id) };
881
+ const grouped = waiting.groupByPerson(matters, seats);
882
+ return { available: true, ...grouped, yours: waiting.forViewer(grouped, builder.id) };
883
+ }
884
+
850
885
  // One matter with its stored ask and the seats, for passing it on to `toSeat`
851
886
  // (task 1004705): → { matter, seats } | { reason } as findMatter answers. A
852
887
  // board's holders are read only when a board seat is involved.
@@ -927,5 +962,6 @@ module.exports = {
927
962
  findMatter,
928
963
  findMatterFor,
929
964
  loadSeats,
965
+ blockersByPerson,
930
966
  matterWithSeats,
931
967
  };
@@ -0,0 +1,29 @@
1
+ -- government_035_payout_approve.sql — grant the new `payout.approve` atom to
2
+ -- Archon (task 1004913, goal 1000092 N9; ADR 0385 D4).
3
+ --
4
+ -- WHAT IT IS. The project's authorised person for money: signs a standing payout
5
+ -- rule, narrows or revokes it, holds a run in its pause window, approves a single
6
+ -- payout, signs an equity grant or a contract, and reads the approval log
7
+ -- (core_283_payout_approvals). The routes also demand the person's own web
8
+ -- session, so holding the atom on a CLI token is not enough to sign.
9
+ --
10
+ -- WHY THIS FILE EXISTS. RANK_SEED is derived from the catalog's floors, and the
11
+ -- drift guard (tests/government_seed.mjs) compares it against the union of the
12
+ -- run-once seed plus every later grant migration, per rank. An archon-floor key
13
+ -- is held by archon only.
14
+ --
15
+ -- Additive only: one new permission key granted to one rank; nothing is revoked.
16
+ -- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
17
+ --
18
+ -- The `SELECT '<rank>', unnest(ARRAY[…])` shape is load-bearing: the drift guard
19
+ -- scans for it literally.
20
+
21
+ BEGIN;
22
+
23
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
24
+ SELECT 'archon', unnest(ARRAY[
25
+ 'payout.approve'
26
+ ])
27
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
28
+
29
+ COMMIT;
@@ -19,17 +19,32 @@
19
19
  // minute-cadence poller logging every tick would bury the journal (the
20
20
  // reconciler's own warn-once lesson).
21
21
  //
22
- // CADENCE: windows are minutes-granular (the recommended sitting is 90
23
- // minutes), so a one-minute sweep bounds lateness at ~1 minute — tight enough
24
- // that "closes on time" is honest, cheap enough that the query (one indexed
25
- // read; government_board_items_open_by_deadline exists for exactly this, per
26
- // government_003's own comment) is negligible. Concurrent windows are normal
22
+ // CADENCE (task 1004960): the poller SLEEPS UNTIL THE SOONEST DEADLINE instead
23
+ // of asking every minute. After each sweep it makes one MIN(closes_at) read
24
+ // (msUntilSoonestOpenDeadline, the same partial index) and arms its next run
25
+ // for just after that deadline — capped at MAX_WAIT_MS (5 minutes) so an item
26
+ // opened by some other process is still caught. A window opened in THIS process
27
+ // wakes it sooner through noteDeadline (db.openBoardItem calls it), so a
28
+ // one-minute window still closes on time. An idle instance with no open clock
29
+ // therefore reads twice per 5 minutes, not once a minute; a vote still closes
30
+ // within ~1 second of its deadline. While something is OVERDUE after a sweep
31
+ // (its close threw and rolled back) the retry waits RETRY_MS (the old one-minute
32
+ // cadence), never a hot loop. Runs never overlap: a tick that fires while the
33
+ // previous sweep is still in flight is skipped. Concurrent windows are normal
27
34
  // (owner decision 10): the sweep closes every due item, oldest deadline first.
28
35
 
29
36
  const board = require('../board');
30
37
  const db = require('../db');
31
38
 
32
- const INTERVAL_MS = Number(process.env.BOARD_EXPIRY_SWEEP_MS) || 60_000;
39
+ // The longest the poller ever sleeps (BOARD_EXPIRY_SWEEP_MS overrides it). It
40
+ // only bounds an item opened by ANOTHER process; this process's opens wake it.
41
+ const MAX_WAIT_MS = Number(process.env.BOARD_EXPIRY_SWEEP_MS) || 5 * 60_000;
42
+ // Kept as an alias: INTERVAL_MS was the fixed cadence before task 1004960.
43
+ const INTERVAL_MS = MAX_WAIT_MS;
44
+ // While an item is overdue after a sweep (its close failed and rolled back).
45
+ const RETRY_MS = 60_000;
46
+ // Wake just AFTER a deadline, since the sweep reads closes_at < now().
47
+ const GRACE_MS = 1_000;
33
48
  // A first sweep shortly after boot, so windows that expired while the process
34
49
  // was down (a deploy restart mid-sitting) close promptly rather than one full
35
50
  // interval late.
@@ -38,8 +53,21 @@ const FIRST_SWEEP_DELAY_MS = Number(process.env.BOARD_EXPIRY_FIRST_MS) || 15_000
38
53
  // handful of open windows (the board's subjects are rare by construction).
39
54
  const SWEEP_LIMIT = 100;
40
55
 
41
- let intervalTimer = null;
42
- let firstTimer = null;
56
+ let timer = null;
57
+ let dueAt = null; // when the armed timer fires (process clock, ms)
58
+ let started = false;
59
+ let running = false;
60
+ let hintAt = null; // soonest deadline (process clock) noted mid-sweep
61
+ let startDeps = {};
62
+ let unlisten = null;
63
+
64
+ // When to run next, given the ms until the soonest open deadline (null: none).
65
+ function nextDelayMs(msUntilSoonest) {
66
+ if (msUntilSoonest == null || !Number.isFinite(Number(msUntilSoonest))) return MAX_WAIT_MS;
67
+ const ms = Number(msUntilSoonest);
68
+ if (ms <= 0) return RETRY_MS; // still overdue after a sweep: retry, never spin
69
+ return Math.min(MAX_WAIT_MS, ms + GRACE_MS);
70
+ }
43
71
 
44
72
  // One sweep. Injectable deps keep it DB-free testable: listFn yields the due
45
73
  // items, closeFn closes one, log receives the disposition lines.
@@ -82,20 +110,80 @@ function start(deps = {}) {
82
110
  console.log('[board-expiry] disabled (BOARD_EXPIRY_DISABLED=1)');
83
111
  return;
84
112
  }
85
- if (intervalTimer) return; // already started
86
- const tick = () => {
87
- runSweep(deps).catch((e) => console.error(`[board-expiry] sweep crashed: ${e && e.message}`));
88
- };
89
- firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
90
- if (firstTimer.unref) firstTimer.unref();
91
- intervalTimer = setInterval(tick, INTERVAL_MS);
92
- if (intervalTimer.unref) intervalTimer.unref(); // never hold the process open on a restart
93
- console.log(`[board-expiry] started (every ${Math.round(INTERVAL_MS / 1000)}s; first sweep in ${Math.round(FIRST_SWEEP_DELAY_MS / 1000)}s)`);
113
+ if (started) return; // already started
114
+ started = true;
115
+ startDeps = deps;
116
+ unlisten = db.onBoardDeadline(noteDeadline); // a window opened here wakes us sooner
117
+ arm(FIRST_SWEEP_DELAY_MS);
118
+ console.log(`[board-expiry] started (sleeps until the soonest deadline, at most ${Math.round(MAX_WAIT_MS / 1000)}s; first sweep in ${Math.round(FIRST_SWEEP_DELAY_MS / 1000)}s)`);
119
+ }
120
+
121
+ function arm(delay) {
122
+ if (timer) clearTimeout(timer);
123
+ dueAt = Date.now() + delay;
124
+ timer = setTimeout(() => {
125
+ timer = null;
126
+ dueAt = null;
127
+ tick().catch((e) => console.error(`[board-expiry] sweep crashed: ${e && e.message}`));
128
+ }, delay);
129
+ if (timer.unref) timer.unref(); // never hold the process open on a restart
130
+ }
131
+
132
+ // One run: the sweep, then the one MIN(closes_at) read that sets the alarm.
133
+ // A run that starts while the previous one is still in flight is skipped —
134
+ // the running one re-arms when it finishes, so nothing is lost.
135
+ async function tick(deps = startDeps) {
136
+ if (running) return { skipped: true };
137
+ running = true;
138
+ let soonest = null;
139
+ try {
140
+ const out = await runSweep(deps);
141
+ try {
142
+ soonest = await (deps.soonestFn || db.msUntilSoonestOpenDeadline)();
143
+ } catch (e) {
144
+ (deps.error || console.error)(`[board-expiry] soonest-deadline read failed: ${e && e.message}`);
145
+ soonest = 0; // unknown: fall back to the one-minute retry, not the 5-minute cap
146
+ }
147
+ return out;
148
+ } finally {
149
+ running = false;
150
+ if (hintAt != null) {
151
+ const hint = hintAt - Date.now();
152
+ soonest = soonest == null ? hint : Math.min(soonest, hint);
153
+ hintAt = null;
154
+ }
155
+ if (started) arm(nextDelayMs(soonest));
156
+ }
157
+ }
158
+
159
+ // A window was opened that closes msUntilClose from now: wake sooner if the
160
+ // armed alarm is later than that deadline. Mid-sweep, remember it for the re-arm.
161
+ function noteDeadline(msUntilClose) {
162
+ if (!started || msUntilClose == null || !Number.isFinite(Number(msUntilClose))) return;
163
+ const ms = Math.max(0, Number(msUntilClose));
164
+ if (running) {
165
+ const at = Date.now() + ms;
166
+ hintAt = hintAt == null ? at : Math.min(hintAt, at);
167
+ return;
168
+ }
169
+ const delay = Math.min(MAX_WAIT_MS, ms + GRACE_MS);
170
+ if (dueAt == null || Date.now() + delay < dueAt) arm(delay);
94
171
  }
95
172
 
96
173
  function stop() {
97
- if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
98
- if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null; }
174
+ started = false;
175
+ hintAt = null;
176
+ if (unlisten) { unlisten(); unlisten = null; }
177
+ if (timer) { clearTimeout(timer); timer = null; }
178
+ dueAt = null;
179
+ }
180
+
181
+ // For tests: what is armed right now.
182
+ function _state() {
183
+ return { started, running, msUntilNextRun: dueAt == null ? null : dueAt - Date.now() };
99
184
  }
100
185
 
101
- module.exports = { start, stop, runSweep, INTERVAL_MS, SWEEP_LIMIT };
186
+ module.exports = {
187
+ start, stop, runSweep, tick, noteDeadline, nextDelayMs, _state,
188
+ INTERVAL_MS, MAX_WAIT_MS, RETRY_MS, GRACE_MS, SWEEP_LIMIT,
189
+ };
@@ -30,6 +30,7 @@
30
30
  { "glob": "src/bongos/auth-config.js", "group": "authz", "why": "task 1004024. Carved out of auth.js (task 1003206): the OAuth flows, session-token minting, admission and IdP checks auth.js now delegates to. Registering auth.js alone protected the facade and none of them." },
31
31
  { "glob": "src/bongos/auth-discord.js", "group": "authz", "why": "task 1004024. Carved out of auth.js (task 1003206): the OAuth flows, session-token minting, admission and IdP checks auth.js now delegates to. Registering auth.js alone protected the facade and none of them." },
32
32
  { "glob": "src/bongos/auth-github.js", "group": "authz", "why": "task 1004024. Carved out of auth.js (task 1003206): the OAuth flows, session-token minting, admission and IdP checks auth.js now delegates to. Registering auth.js alone protected the facade and none of them." },
33
+ { "glob": "src/bongos/auth-google.js", "group": "authz", "why": "task 1004993 (ADR 0396). The Google sign-in beside auth-github.js: it checks Google's ID token, runs the admission gate for a Google person and mints their web session, so it is the same trust surface as the GitHub flow." },
33
34
  { "glob": "src/bongos/auth-idp.js", "group": "authz", "why": "task 1004024. Carved out of auth.js (task 1003206): the OAuth flows, session-token minting, admission and IdP checks auth.js now delegates to. Registering auth.js alone protected the facade and none of them." },
34
35
  { "glob": "src/bongos/db.js", "group": "authz", "why": "rank tiers, REQUIRES_RANK_TIER — the CASE edited in the SEC #863 incident" },
35
36
  { "glob": "src/bongos/db-kernel.js", "group": "authz", "why": "task 1004024. The kernel db slice carved out of db.js (BV1.R69 / ADR 0091): builder identity and the rank reads the SEC #863 entry above exists for." },
@@ -434,40 +434,50 @@ module.exports = function buildGovernmentRouter() {
434
434
 
435
435
  // rank: perm government.manage — add a RECOMMENDED RANK SET (task 1004400):
436
436
  // the custom ranks recommendations.js names for a project type and size, each
437
- // with its grants. It is the create form and the grant toggle run in a loop,
438
- // under the same checks: a rank that already exists is LEFT ALONE (never
437
+ // with its grants. It does what the create form and the grant toggle do, for
438
+ // every rank in the set, under the same checks: a rank that already exists is LEFT ALONE (never
439
439
  // re-granted, never relabelled — an owner may have edited it), and every grant
440
440
  // is re-checked with catalog.isGrantableTo, so a set can never write what the
441
441
  // toggle would refuse. Re-applying is safe; the reply says what was added.
442
+ //
443
+ // task 1004929: the whole set is written by db.applyRankSet in ONE transaction
444
+ // and three statements, instead of a read + insert + one insert per grant for
445
+ // every rank. A rank this route creates is always a CUSTOM rank (is_system
446
+ // false, no seeded_rank — what createRank returns), so each grant is screened
447
+ // against that shape before the write, exactly as the toggle would screen it.
448
+ // A failure now writes nothing, so a 500 reports empty created/skipped lists.
442
449
  router.post('/government/rank-sets/apply', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
443
450
  if (validateOrRespond(req, res, {
444
451
  type: { type: 'string', maxLength: 32 },
445
452
  headcount: { required: true, type: 'integer', min: 0, max: 100000 },
446
453
  })) return;
447
454
  const set = recommendations.recommendedRankSet(req.body.type, req.body.headcount);
448
- const created = [];
449
- const skipped = [];
455
+ const customRank = { is_system: false, seeded_rank: null };
450
456
  try {
457
+ const granted = await db.applyRankSet({
458
+ createdBy: req.builder.id,
459
+ ranks: set.ranks.map((r) => ({
460
+ rankKey: r.rank_key,
461
+ label: r.label,
462
+ description: r.description,
463
+ permissionKeys: r.permissions.filter((key) => catalog.byKey(key) && catalog.isGrantableTo(key, customRank)),
464
+ })),
465
+ });
466
+ const created = [];
467
+ const skipped = [];
451
468
  for (const r of set.ranks) {
452
- if (await db.getRankByKey(r.rank_key)) { skipped.push({ rank_key: r.rank_key, label: r.label }); continue; }
453
- let rank;
454
- try {
455
- rank = await db.createRank({ rankKey: r.rank_key, label: r.label, description: r.description, createdBy: req.builder.id });
456
- } catch (err) {
457
- if (err && err.code === '23505') { skipped.push({ rank_key: r.rank_key, label: r.label }); continue; }
458
- throw err;
469
+ // delete-on-read: a key named twice in a set is created once, then skipped.
470
+ if (granted.has(r.rank_key)) {
471
+ created.push({ rank_key: r.rank_key, label: r.label, permissions: granted.get(r.rank_key) });
472
+ granted.delete(r.rank_key);
473
+ } else {
474
+ skipped.push({ rank_key: r.rank_key, label: r.label });
459
475
  }
460
- const granted = [];
461
- for (const key of r.permissions) {
462
- if (!catalog.byKey(key) || !catalog.isGrantableTo(key, rank)) continue;
463
- if (await db.grantRankPermission({ rankKey: r.rank_key, permissionKey: key })) granted.push(key);
464
- }
465
- created.push({ rank_key: r.rank_key, label: r.label, permissions: granted });
466
476
  }
467
477
  res.json({ ok: true, type: set.type, size: set.size, created, skipped });
468
478
  } catch (err) {
469
479
  log.error('[gds] POST /government/rank-sets/apply', err);
470
- res.fail('rank_set_apply_failed', { status: 500, message: 'internal error', details: { created, skipped } });
480
+ res.fail('rank_set_apply_failed', { status: 500, message: 'internal error', details: { created: [], skipped: [] } });
471
481
  }
472
482
  });
473
483
 
@@ -757,6 +767,28 @@ module.exports = function buildGovernmentRouter() {
757
767
  // treasury; `rule` ∈ decide first consent majority unanimous signoff rule (or
758
768
  // null); `state` ∈ needs moving settled. Nothing is ever sample data: a source
759
769
  // with nothing recorded yet is `coming`, never a placeholder row.
770
+ // OPEN BLOCKERS UNDER PEOPLE (task 1004999): every open blocker grouped under each
771
+ // person its seat resolves to (routing.js, the City's own rule), and the viewer's own
772
+ // group. The docket's atom: it floors at xenos, so every builder can see whose a
773
+ // blocker is; resolving one keeps its own blocker.review gate.
774
+ router.get('/government/blockers/people', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
775
+ try {
776
+ res.json(await docket.blockersByPerson(req.builder));
777
+ } catch (err) {
778
+ log.error('[gds] GET /government/blockers/people', err);
779
+ res.fail('blockers_people_read_failed', { status: 500, message: 'internal error' });
780
+ }
781
+ });
782
+ router.get('/government/blockers/for-me', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
783
+ try {
784
+ const r = await docket.blockersByPerson(req.builder);
785
+ res.json({ available: r.available, reason: r.reason || null, ...r.yours });
786
+ } catch (err) {
787
+ log.error('[gds] GET /government/blockers/for-me', err);
788
+ res.fail('blockers_for_me_read_failed', { status: 500, message: 'internal error' });
789
+ }
790
+ });
791
+
760
792
  router.get('/government/docket', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
761
793
  try {
762
794
  res.json(await docket.docketFor(req.builder));