@bongos/core 1.18.0 → 1.18.18

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 (553) hide show
  1. package/.bongos-core.json +1740 -640
  2. package/.claude/skills/builder-box/SKILL.md +6 -0
  3. package/.claude/skills/builder-sequence/SKILL.md +97 -0
  4. package/.claude/skills/builder-ship/SKILL.md +8 -2
  5. package/.claude/skills/builder-start/SKILL.md +11 -0
  6. package/.claude/skills/builder-sync/SKILL.md +41 -0
  7. package/.claude/skills/fix-task/SKILL.md +64 -0
  8. package/.claude/skills/goal-create/SKILL.md +158 -0
  9. package/.claude/skills/goal-review/SKILL.md +3 -1
  10. package/.claude/skills/idea-triage/SKILL.md +14 -8
  11. package/.claude/skills/merge-mode/SKILL.md +2 -2
  12. package/.claude/skills/new-project/SKILL.md +1 -0
  13. package/.claude/skills/planning-session/SKILL.md +45 -18
  14. package/.env.local.example +4 -3
  15. package/README.md +5 -3
  16. package/bin/bongos.js +1 -0
  17. package/clients/bongos-client/README.md +2 -2
  18. package/clients/bongos-client/bongos-client.global.js +82 -18
  19. package/clients/bongos-client/index.cjs +82 -18
  20. package/clients/bongos-client/index.d.ts +131 -29
  21. package/clients/bongos-client/index.mjs +82 -18
  22. package/config/branding.neutral.json +11 -2
  23. package/config/governance.neutral.json +21 -0
  24. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
  25. package/docs/adr/0090-metic-task-authoring.md +1 -1
  26. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  27. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  28. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
  29. package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
  30. package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
  31. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
  32. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
  33. package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
  34. package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
  35. package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
  36. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
  37. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
  38. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
  39. package/docs/adr/0152-metic-task-abandonment.md +67 -0
  40. package/docs/adr/0153-sequential-chain-runner.md +51 -0
  41. package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
  42. package/docs/adr/README.md +15 -2
  43. package/docs/api/openapi.json +2648 -483
  44. package/docs/api-reference.md +73 -13
  45. package/docs/architecture.md +4 -2
  46. package/docs/branding-contract.md +3 -1
  47. package/docs/canonical-permissions.md +21 -8
  48. package/docs/file-map.md +15 -7
  49. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
  50. package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
  51. package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
  52. package/docs/onboarding/diagrams/assertions.json +4 -7
  53. package/docs/onboarding/drachmae-and-karma.md +3 -3
  54. package/docs/onboarding/primer.md +17 -16
  55. package/docs/onboarding/slash-commands.md +124 -0
  56. package/docs/recipes/builder-box-lifecycle.md +23 -0
  57. package/docs/recipes/devbox-app-release.md +14 -4
  58. package/docs/recipes/instance-first-run.md +16 -0
  59. package/docs/recipes/local-dev.md +81 -0
  60. package/docs/recipes/ops-gotchas.md +19 -1
  61. package/docs/recipes/overnight-builder.md +1 -1
  62. package/docs/recipes/packaging-the-core.md +13 -0
  63. package/docs/recipes/parallel-goal-waves.md +174 -0
  64. package/docs/recipes/ship-and-regrade.md +2 -2
  65. package/docs/recipes/standalone-live-docs.md +48 -0
  66. package/docs/recipes/upgrading-the-core.md +5 -2
  67. package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
  68. package/migrations/003_pms.sql +6 -7
  69. package/migrations/006_pms_v2.sql +3 -31
  70. package/migrations/039_newcomer_friendly_column.sql +5 -0
  71. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  72. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  73. package/migrations/core_193_task_needs_rebase.sql +28 -0
  74. package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
  75. package/migrations/core_195_idea_triage_provenance.sql +57 -0
  76. package/migrations/core_196_session_reward_topup.sql +69 -0
  77. package/modules/autonomy/pollers/routine-timers.js +9 -8
  78. package/modules/builder-settings/wandering-prefs.js +61 -18
  79. package/modules/dev-box/app/README.md +73 -0
  80. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  81. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  82. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  83. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  84. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  85. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  86. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  87. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  88. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  89. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  90. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  91. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  92. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  93. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  94. package/modules/dev-box/app/build-resources/icon.png +0 -0
  95. package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
  96. package/modules/dev-box/app/electron-builder.yml +95 -0
  97. package/modules/dev-box/app/package-lock.json +5291 -0
  98. package/modules/dev-box/app/package.json +30 -0
  99. package/modules/dev-box/app/renderer/app.js +319 -0
  100. package/modules/dev-box/app/renderer/index.html +45 -0
  101. package/modules/dev-box/app/renderer/styles.css +246 -0
  102. package/modules/dev-box/app/renderer/theme.generated.css +16 -0
  103. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
  104. package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
  105. package/modules/dev-box/app/scripts/gen-brand.js +138 -0
  106. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
  107. package/modules/dev-box/app/scripts/gen-icons.js +286 -0
  108. package/modules/dev-box/app/scripts/vendor-client.js +23 -0
  109. package/modules/dev-box/app/src/box-controller.js +287 -0
  110. package/modules/dev-box/app/src/brand.generated.json +13 -0
  111. package/modules/dev-box/app/src/brand.js +16 -0
  112. package/modules/dev-box/app/src/config.js +19 -0
  113. package/modules/dev-box/app/src/gds-client.js +90 -0
  114. package/modules/dev-box/app/src/main.js +610 -0
  115. package/modules/dev-box/app/src/pairing.js +57 -0
  116. package/modules/dev-box/app/src/preload.js +29 -0
  117. package/modules/dev-box/app/src/session-store.js +58 -0
  118. package/modules/dev-box/app/src/ssh-setup.js +569 -0
  119. package/modules/dev-box/app/src/task-picker.js +70 -0
  120. package/modules/dev-box/app/src/tray.js +99 -0
  121. package/modules/dev-box/app/src/updater.js +55 -0
  122. package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
  123. package/modules/dev-box/app/test/unit.mjs +526 -0
  124. package/modules/dev-box/box-access.js +121 -4
  125. package/modules/dev-box/boxes.js +20 -0
  126. package/modules/dev-box/module.json +1 -1
  127. package/modules/dev-box/routes/box.js +87 -3
  128. package/modules/economy/credits.js +116 -25
  129. package/modules/economy/reward.js +8 -0
  130. package/modules/economy/streak.js +2 -1
  131. package/modules/governance/CLAUDE.md +15 -0
  132. package/modules/governance/catalog.js +419 -0
  133. package/modules/governance/config.js +174 -0
  134. package/modules/governance/db.js +125 -0
  135. package/modules/governance/migrations/governance_001_tables.sql +81 -0
  136. package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
  137. package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
  138. package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
  139. package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
  140. package/modules/governance/module.json +10 -0
  141. package/modules/governance/protected-surfaces.js +88 -0
  142. package/modules/governance/protected-surfaces.json +80 -0
  143. package/modules/governance/rank-roles.js +83 -0
  144. package/modules/governance/resolver.js +221 -0
  145. package/modules/governance/routes/governance.js +89 -0
  146. package/modules/governance/routes/role-sync.js +49 -0
  147. package/modules/governance/session-scopes.js +127 -0
  148. package/modules/governance/session-scopes.json +38 -0
  149. package/modules/hall-ui/public/atlas.css +117 -0
  150. package/modules/hall-ui/public/atlas.html +3 -114
  151. package/modules/hall-ui/public/brand-apply.js +34 -6
  152. package/modules/hall-ui/public/builders.js +187 -219
  153. package/modules/hall-ui/public/diagrams.html +2 -0
  154. package/modules/hall-ui/public/dom-utils.js +27 -0
  155. package/modules/hall-ui/public/drachmae.html +2 -0
  156. package/modules/hall-ui/public/drachmae.js +54 -1
  157. package/modules/hall-ui/public/gate.css +98 -0
  158. package/modules/hall-ui/public/gate.html +3 -95
  159. package/modules/hall-ui/public/goal-inbox.js +4 -1
  160. package/modules/hall-ui/public/goals-page.js +626 -114
  161. package/modules/hall-ui/public/goals.css +79 -1
  162. package/modules/hall-ui/public/goals.html +38 -11
  163. package/modules/hall-ui/public/hall-widgets.js +62 -52
  164. package/modules/hall-ui/public/harbor.css +123 -0
  165. package/modules/hall-ui/public/harbor.html +3 -120
  166. package/modules/hall-ui/public/index.html +11 -4
  167. package/modules/hall-ui/public/instances-panel.js +4 -1
  168. package/modules/hall-ui/public/modules.css +63 -0
  169. package/modules/hall-ui/public/modules.html +3 -60
  170. package/modules/hall-ui/public/my-projects.html +78 -0
  171. package/modules/hall-ui/public/my-projects.js +142 -0
  172. package/modules/hall-ui/public/new-project-wizard.js +7 -3
  173. package/modules/hall-ui/public/not-ready.html +2 -0
  174. package/modules/hall-ui/public/pair.css +73 -0
  175. package/modules/hall-ui/public/pair.html +3 -70
  176. package/modules/hall-ui/public/palette.js +302 -0
  177. package/modules/hall-ui/public/primer.css +13 -0
  178. package/modules/hall-ui/public/primer.html +2 -0
  179. package/modules/hall-ui/public/profile.html +2 -0
  180. package/modules/hall-ui/public/ranks.html +2 -0
  181. package/modules/hall-ui/public/roadmap.css +179 -0
  182. package/modules/hall-ui/public/roadmap.html +9 -172
  183. package/modules/hall-ui/public/roadmap.js +329 -316
  184. package/modules/hall-ui/public/scouting.html +45 -0
  185. package/modules/hall-ui/public/scouting.js +73 -0
  186. package/modules/hall-ui/public/sessions.css +16 -0
  187. package/modules/hall-ui/public/sessions.html +3 -13
  188. package/modules/hall-ui/public/settings.html +2 -0
  189. package/modules/hall-ui/public/settings.js +4 -4
  190. package/modules/hall-ui/public/shell.js +143 -18
  191. package/modules/hall-ui/public/style.css +231 -39
  192. package/modules/hall-ui/public/task-detail.js +347 -26
  193. package/modules/hall-ui/public/task.html +6 -1
  194. package/modules/hall-ui/public/watch.html +2 -0
  195. package/modules/hall-ui/public/work.html +8 -6
  196. package/modules/hall-ui/public/work.js +1 -1
  197. package/modules/ideas/inbox.js +61 -11
  198. package/modules/ideas/routes/inbox.js +23 -0
  199. package/modules/lifecycle/claim-eligibility.js +72 -0
  200. package/modules/lifecycle/db.js +519 -139
  201. package/modules/lifecycle/done-when.js +18 -3
  202. package/modules/lifecycle/github-push.js +19 -2
  203. package/modules/lifecycle/goal-advisory.js +114 -0
  204. package/modules/lifecycle/goal-authz.js +62 -0
  205. package/modules/lifecycle/lifecycle.js +9 -0
  206. package/modules/lifecycle/publish-reconciler.js +109 -6
  207. package/modules/lifecycle/routes/claims.js +68 -23
  208. package/modules/lifecycle/routes/done-when.js +110 -15
  209. package/modules/lifecycle/routes/goals.js +8 -38
  210. package/modules/lifecycle/routes/tasks.js +38 -6
  211. package/modules/lifecycle/ship-card.js +6 -0
  212. package/modules/onboarding/onboarding-state.js +1 -1
  213. package/modules/platform-identity/CLAUDE.md +15 -0
  214. package/modules/platform-identity/hub-device.js +147 -0
  215. package/modules/platform-identity/hub-keys.js +151 -0
  216. package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
  217. package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
  218. package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
  219. package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
  220. package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
  221. package/modules/platform-identity/module.json +18 -0
  222. package/modules/platform-identity/platform-identity.js +531 -0
  223. package/modules/platform-identity/pollers/code-sweep.js +43 -0
  224. package/modules/platform-identity/routes/my-projects.js +140 -0
  225. package/modules/platform-identity/routes/projects.js +107 -0
  226. package/modules/platform-identity/routes/scouting.js +35 -0
  227. package/modules/platform-identity/routes/sso.js +402 -0
  228. package/modules/platform-identity/tests/platform-identity.mjs +388 -0
  229. package/modules/platform-identity/ui/.gitkeep +0 -0
  230. package/modules/security/routes/reports.js +10 -0
  231. package/modules/sessions/db.js +166 -21
  232. package/modules/sessions/module.json +1 -1
  233. package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
  234. package/modules/sessions/routes/sessions.js +33 -0
  235. package/modules/status-ui/public/explore.html +54 -0
  236. package/modules/status-ui/public/explore.js +67 -0
  237. package/package-lock.json +2 -2
  238. package/package.json +1 -1
  239. package/public-landing/assets/anim/idle/frame_00.webp +0 -0
  240. package/public-landing/assets/anim/idle/frame_01.webp +0 -0
  241. package/public-landing/assets/anim/idle/frame_02.webp +0 -0
  242. package/public-landing/assets/anim/idle/frame_03.webp +0 -0
  243. package/public-landing/assets/anim/idle/frame_04.webp +0 -0
  244. package/public-landing/assets/anim/idle/frame_05.webp +0 -0
  245. package/public-landing/assets/anim/idle/frame_06.webp +0 -0
  246. package/public-landing/assets/anim/idle/frame_07.webp +0 -0
  247. package/public-landing/assets/anim/idle/frame_08.webp +0 -0
  248. package/public-landing/assets/anim/idle/frame_09.webp +0 -0
  249. package/public-landing/assets/anim/idle/frame_10.webp +0 -0
  250. package/public-landing/assets/anim/idle/frame_11.webp +0 -0
  251. package/public-landing/assets/anim/idle/frame_12.webp +0 -0
  252. package/public-landing/assets/anim/idle/frame_13.webp +0 -0
  253. package/public-landing/assets/anim/idle/frame_14.webp +0 -0
  254. package/public-landing/assets/anim/idle/frame_15.webp +0 -0
  255. package/public-landing/assets/anim/idle/frame_16.webp +0 -0
  256. package/public-landing/assets/anim/idle/frame_17.webp +0 -0
  257. package/public-landing/assets/anim/idle/frame_18.webp +0 -0
  258. package/public-landing/assets/anim/idle/frame_19.webp +0 -0
  259. package/public-landing/assets/anim/idle/frame_20.webp +0 -0
  260. package/public-landing/assets/anim/idle/frame_21.webp +0 -0
  261. package/public-landing/assets/anim/idle/frame_22.webp +0 -0
  262. package/public-landing/assets/anim/idle/frame_23.webp +0 -0
  263. package/public-landing/assets/anim/idle/frame_24.webp +0 -0
  264. package/public-landing/assets/anim/idle/frame_25.webp +0 -0
  265. package/public-landing/assets/anim/idle/frame_26.webp +0 -0
  266. package/public-landing/assets/anim/idle/frame_27.webp +0 -0
  267. package/public-landing/assets/anim/idle/frame_28.webp +0 -0
  268. package/public-landing/assets/anim/idle/frame_29.webp +0 -0
  269. package/public-landing/assets/anim/idle/frame_30.webp +0 -0
  270. package/public-landing/assets/anim/idle/frame_31.webp +0 -0
  271. package/public-landing/assets/anim/idle/frame_32.webp +0 -0
  272. package/public-landing/assets/anim/idle/frame_33.webp +0 -0
  273. package/public-landing/assets/anim/idle/frame_34.webp +0 -0
  274. package/public-landing/assets/anim/idle/frame_35.webp +0 -0
  275. package/public-landing/assets/anim/idle/frame_36.webp +0 -0
  276. package/public-landing/assets/anim/idle/frame_37.webp +0 -0
  277. package/public-landing/assets/anim/idle/frame_38.webp +0 -0
  278. package/public-landing/assets/anim/idle/frame_39.webp +0 -0
  279. package/public-landing/assets/anim/idle/frame_40.webp +0 -0
  280. package/public-landing/assets/anim/idle/frame_41.webp +0 -0
  281. package/public-landing/assets/anim/idle/frame_42.webp +0 -0
  282. package/public-landing/assets/anim/idle/frame_43.webp +0 -0
  283. package/public-landing/create.html +252 -62
  284. package/public-landing/gate/README.md +137 -0
  285. package/public-landing/gate/dispmaps/bar.png +0 -0
  286. package/public-landing/gate/dispmaps/flat.png +0 -0
  287. package/public-landing/gate/dispmaps/key.png +0 -0
  288. package/public-landing/gate/index.html +587 -0
  289. package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
  290. package/public-landing/gate/previews/off-the-boats.jpg +0 -0
  291. package/public-landing/gate/space.css +799 -0
  292. package/public-landing/gate/space.js +1826 -0
  293. package/public-landing/gate/sprites/enter.png +0 -0
  294. package/public-landing/gate/sprites/search.png +0 -0
  295. package/public-landing/gate/sprites/terminal-blank.png +0 -0
  296. package/public-landing/gate/sprites/tile.png +0 -0
  297. package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
  298. package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
  299. package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
  300. package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
  301. package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
  302. package/public-landing/world/assets/plates/scene-establishing.png +0 -0
  303. package/scripts/gds/abandon.js +6 -4
  304. package/scripts/gds/api.js +10 -5
  305. package/scripts/gds/api.ps1 +26 -0
  306. package/scripts/gds/api.sh +6 -0
  307. package/scripts/gds/audit-ships.js +7 -1
  308. package/scripts/gds/backfill-sessions.js +78 -18
  309. package/scripts/gds/backup.js +23 -1
  310. package/scripts/gds/box-connect-lib.js +124 -31
  311. package/scripts/gds/box-sync.js +66 -0
  312. package/scripts/gds/box.js +225 -53
  313. package/scripts/gds/cf-tunnel.js +145 -6
  314. package/scripts/gds/claim.js +34 -3
  315. package/scripts/gds/cli-lib.js +161 -21
  316. package/scripts/gds/code.js +6 -2
  317. package/scripts/gds/deploy-divergence.js +209 -0
  318. package/scripts/gds/dev-lib.js +309 -0
  319. package/scripts/gds/dev.js +322 -0
  320. package/scripts/gds/doctor.js +90 -61
  321. package/scripts/gds/fetch-art-key.js +1 -1
  322. package/scripts/gds/fetch-sound-prefs.js +2 -2
  323. package/scripts/gds/fitness.js +228 -12
  324. package/scripts/gds/gds-db-backup.service +16 -1
  325. package/scripts/gds/gds-db-offsite.service +9 -1
  326. package/scripts/gds/gen-api-client.js +1 -1
  327. package/scripts/gds/gen-api-docs.js +36 -14
  328. package/scripts/gds/gen-diagrams.js +45 -28
  329. package/scripts/gds/gen-file-map.js +2 -1
  330. package/scripts/gds/gen-freshness.js +32 -0
  331. package/scripts/gds/gen-repo-map.js +3 -2
  332. package/scripts/gds/gen-session-index.js +14 -5
  333. package/scripts/gds/hooks/pre-push.js +2 -2
  334. package/scripts/gds/init.js +60 -3
  335. package/scripts/gds/install-git-hooks.js +1 -1
  336. package/scripts/gds/key-card.js +15 -6
  337. package/scripts/gds/key-status.js +1 -1
  338. package/scripts/gds/linkify-refs.js +35 -9
  339. package/scripts/gds/lint-migrations.js +102 -0
  340. package/scripts/gds/login.js +58 -11
  341. package/scripts/gds/mirror-redact.js +5 -0
  342. package/scripts/gds/package-core.js +263 -4
  343. package/scripts/gds/paste-token.js +7 -1
  344. package/scripts/gds/preflight.js +126 -0
  345. package/scripts/gds/provision.js +268 -19
  346. package/scripts/gds/publish-manifest.js +22 -1
  347. package/scripts/gds/reauth.js +7 -3
  348. package/scripts/gds/regen-instance-docs.js +47 -0
  349. package/scripts/gds/run-unit-tests.js +42 -16
  350. package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
  351. package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
  352. package/scripts/gds/sequence.js +425 -0
  353. package/scripts/gds/session-cost.js +10 -1
  354. package/scripts/gds/setup.js +72 -5
  355. package/scripts/gds/shell.js +5 -3
  356. package/scripts/gds/ship.js +433 -59
  357. package/scripts/gds/smoke-gds.js +139 -8
  358. package/scripts/gds/start.js +171 -22
  359. package/scripts/gds/status.js +1 -1
  360. package/scripts/gds/triage.js +10 -9
  361. package/scripts/gds/upgrade.js +271 -7
  362. package/scripts/migrate.sh +10 -0
  363. package/scripts/status-mirror-sync.js +114 -84
  364. package/src/bongos/api-errors.js +1 -0
  365. package/src/bongos/auth.js +809 -107
  366. package/src/bongos/db-kernel.js +27 -0
  367. package/src/bongos/db.js +105 -0
  368. package/src/bongos/github-repos.js +93 -0
  369. package/src/bongos/module-scope-map.js +27 -1
  370. package/src/bongos/permission-path-check.js +136 -102
  371. package/src/bongos/repo-info.js +47 -11
  372. package/src/bongos/route-rank-check.js +429 -46
  373. package/src/bongos/routes/_helpers.js +12 -1
  374. package/src/bongos/routes/auth.js +312 -13
  375. package/src/bongos/routes/backup.js +58 -2
  376. package/src/bongos/routes/github.js +90 -0
  377. package/src/bongos/routes/instance.js +15 -3
  378. package/src/bongos/routes/me.js +35 -1
  379. package/src/bongos/routes.js +2 -0
  380. package/src/bongos/serve-internal.js +84 -15
  381. package/src/branding.js +49 -2
  382. package/src/instance-config.js +46 -0
  383. package/src/module-api.js +140 -1
  384. package/src/platform-server.js +86 -9
  385. package/tests/abandon_task.mjs +59 -3
  386. package/tests/api_cli_flags.mjs +53 -0
  387. package/tests/auth_page_gate.mjs +13 -0
  388. package/tests/backfill_sessions.mjs +127 -1
  389. package/tests/backup_cli.mjs +54 -0
  390. package/tests/bongos_box_connect.mjs +77 -0
  391. package/tests/box_access.mjs +130 -1
  392. package/tests/box_credential.mjs +125 -0
  393. package/tests/box_host_keys.mjs +24 -0
  394. package/tests/box_infra_resolve.mjs +149 -0
  395. package/tests/box_scope_session.mjs +6 -2
  396. package/tests/box_sync.mjs +55 -0
  397. package/tests/box_task_scope.mjs +99 -0
  398. package/tests/branding.mjs +41 -23
  399. package/tests/cf_ruleset_preflight.mjs +142 -0
  400. package/tests/cf_tunnel.mjs +37 -0
  401. package/tests/claim_gate_rebase.mjs +98 -0
  402. package/tests/clawback_confirmed_backlog.mjs +103 -0
  403. package/tests/cli_lib_formatters.mjs +20 -0
  404. package/tests/confirm_attribution.mjs +30 -46
  405. package/tests/credit_grant.mjs +198 -10
  406. package/tests/criterion_authoring_gate.mjs +272 -0
  407. package/tests/criterion_progress.mjs +33 -1
  408. package/tests/dev_lib.mjs +253 -0
  409. package/tests/devbox_artifact_gate.mjs +75 -0
  410. package/tests/devbox_dmg_backdrop.mjs +137 -0
  411. package/tests/devbox_icons.mjs +304 -0
  412. package/tests/devbox_installer_brand.mjs +232 -0
  413. package/tests/devbox_key_register.mjs +234 -0
  414. package/tests/docs_openapi_route.mjs +110 -0
  415. package/tests/doctor_hooks.mjs +141 -0
  416. package/tests/extract_json.mjs +140 -0
  417. package/tests/fitness.mjs +89 -0
  418. package/tests/gen_freshness.mjs +96 -0
  419. package/tests/github_repos_helpers.mjs +91 -0
  420. package/tests/github_token_store.mjs +121 -0
  421. package/tests/goal_advisory.mjs +251 -0
  422. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  423. package/tests/goal_routes.mjs +59 -4
  424. package/tests/goal_tier.mjs +12 -3
  425. package/tests/governance_catalog.mjs +129 -0
  426. package/tests/governance_claim_eligibility.mjs +262 -0
  427. package/tests/governance_config.mjs +187 -0
  428. package/tests/governance_ownership_scope.mjs +410 -0
  429. package/tests/governance_page_gates.mjs +324 -0
  430. package/tests/governance_parity.mjs +336 -0
  431. package/tests/governance_principals.mjs +232 -0
  432. package/tests/governance_protected_surfaces.mjs +208 -0
  433. package/tests/governance_require_permission.mjs +223 -0
  434. package/tests/governance_resolver.mjs +84 -0
  435. package/tests/governance_role_sync.mjs +231 -0
  436. package/tests/governance_seed.mjs +127 -0
  437. package/tests/governance_session_scope.mjs +434 -0
  438. package/tests/governance_vanilla_boot.mjs +203 -0
  439. package/tests/grade_attribution.mjs +7 -7
  440. package/tests/hall_audit.mjs +223 -0
  441. package/tests/hall_nav.mjs +191 -0
  442. package/tests/hall_palette.mjs +206 -0
  443. package/tests/hall_signout.mjs +124 -0
  444. package/tests/hook_user_agents.mjs +10 -0
  445. package/tests/idea_triage.mjs +162 -0
  446. package/tests/idp_device_exchange.mjs +198 -0
  447. package/tests/idp_federation.mjs +100 -0
  448. package/tests/init.mjs +51 -0
  449. package/tests/instance_config.mjs +65 -0
  450. package/tests/instance_mismatch_guard.mjs +23 -6
  451. package/tests/instance_release_config.mjs +104 -0
  452. package/tests/linkify_refs.mjs +15 -2
  453. package/tests/migrations_forward_safe.mjs +86 -0
  454. package/tests/module_api.mjs +1 -1
  455. package/tests/module_contributions.mjs +55 -0
  456. package/tests/module_loader.mjs +10 -5
  457. package/tests/module_scope_active_claims.mjs +94 -0
  458. package/tests/needs_rebase.mjs +92 -0
  459. package/tests/one_active_claim_per_session.mjs +76 -0
  460. package/tests/override_attribution.mjs +10 -8
  461. package/tests/override_request_ownership.mjs +8 -4
  462. package/tests/package_core.mjs +246 -1
  463. package/tests/parse_cookie_decode.mjs +42 -0
  464. package/tests/pay_on_land.mjs +129 -0
  465. package/tests/platform_boot.mjs +100 -1
  466. package/tests/post_login_landing.mjs +42 -0
  467. package/tests/preflight_checks.mjs +72 -0
  468. package/tests/provision.mjs +289 -7
  469. package/tests/pruned_lockfile_integrity.mjs +79 -0
  470. package/tests/publish_branch_route.mjs +53 -0
  471. package/tests/publish_reconciler.mjs +187 -2
  472. package/tests/repo_info_binding.mjs +248 -0
  473. package/tests/reward_gate.mjs +11 -2
  474. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  475. package/tests/reward_mode_surfacing.mjs +168 -0
  476. package/tests/route_permission_check.mjs +301 -0
  477. package/tests/route_rank_check.mjs +254 -12
  478. package/tests/route_validation.mjs +30 -0
  479. package/tests/sequence.mjs +267 -0
  480. package/tests/session_digest.mjs +44 -0
  481. package/tests/session_index.mjs +18 -6
  482. package/tests/session_records.mjs +39 -1
  483. package/tests/session_reward_on_land.mjs +381 -0
  484. package/tests/session_reward_reconcile.mjs +229 -0
  485. package/tests/ship_api_client_pathspec.mjs +100 -0
  486. package/tests/ship_base_flag.mjs +93 -0
  487. package/tests/ship_ci_deploy.mjs +140 -3
  488. package/tests/ship_upstream_deletion.mjs +57 -0
  489. package/tests/smoke_target.mjs +233 -0
  490. package/tests/start_env_guard.mjs +87 -0
  491. package/tests/start_rebase_warning.mjs +103 -0
  492. package/tests/status_mirror_sync.mjs +122 -0
  493. package/tests/stop_refcheck.mjs +19 -1
  494. package/tests/task_goal_link.mjs +15 -6
  495. package/tests/upgrade.mjs +393 -5
  496. package/tests/web_admission_status.mjs +162 -0
  497. package/tests/write_validation_gaps.mjs +110 -0
  498. package/.gitignore +0 -91
  499. package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
  500. package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
  501. package/scripts/hall-preview/fixtures/access-requests.json +0 -20
  502. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  503. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  504. package/scripts/hall-preview/fixtures/audit-log.json +0 -68
  505. package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
  506. package/scripts/hall-preview/fixtures/box__me.json +0 -5
  507. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
  508. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  509. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  510. package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
  511. package/scripts/hall-preview/fixtures/goals.json +0 -502
  512. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  513. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  514. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  515. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  516. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  517. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  518. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  519. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  520. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
  521. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  522. package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
  523. package/scripts/hall-preview/fixtures/instance.json +0 -124
  524. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  525. package/scripts/hall-preview/fixtures/me.json +0 -476
  526. package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
  527. package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
  528. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  529. package/scripts/hall-preview/fixtures/me__render.json +0 -5
  530. package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
  531. package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
  532. package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
  533. package/scripts/hall-preview/fixtures/override-requests.json +0 -15
  534. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  535. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  536. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  537. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  538. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  539. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  540. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  541. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  542. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  543. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  544. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  545. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  546. package/scripts/hall-preview/fixtures/security__docs.json +0 -10
  547. package/scripts/hall-preview/fixtures/security__reports.json +0 -31
  548. package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
  549. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  550. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  551. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  552. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  553. package/scripts/hall-preview/fixtures/versions.json +0 -89
@@ -0,0 +1,419 @@
1
+ // modules/governance/catalog.js — the permission catalog + compatibility seed.
2
+ //
3
+ // The single CODE source of truth for authority atoms (ADR 0151, BV1.R94). Every
4
+ // permission is a stable string key that maps to a REAL server capability (a route,
5
+ // a claim rule, a page) — a permission no route consumes does not exist. The
6
+ // resolver (R95), the requirePermission gate (R97), R93's role seed, and the Archon
7
+ // tab (R110/R111) all read from here; the enumerated catalog it transcribes is
8
+ // docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md, grounded in
9
+ // docs/governance/authority-sweep-2026-07-24.md.
10
+ //
11
+ // Two orthogonal axes per permission:
12
+ // • system — true = trust-boundary, NON-GRANTABLE. The governance API (R110)
13
+ // rejects adding it to a CUSTOM role and the tab renders it LOCKED;
14
+ // it is deploy/protected-path/rank-set/version-close/grade-bypass/
15
+ // cross-tier/principal power, enforced by the out-of-band substrate
16
+ // (ADR 0043) or reserved to Archon. The built-in `archon` rank-role
17
+ // legitimately HOLDS the archon-floored system perms — "non-grantable"
18
+ // constrains custom roles, not the seed.
19
+ // — false = operational, freely composable into a custom role.
20
+ // • floor — the rank that holds this permission TODAY via the compat seed
21
+ // ('xenos' < 'thetes' < 'metic' < 'archon'), or null for a machine
22
+ // PRINCIPAL permission held by NO rank-role (gated on exact
23
+ // system_role, never a human/role — ADR 0151 §3). floor drives the
24
+ // seed derivation below; it is seed DATA, not grantability (the
25
+ // `system` flag is the wall). Ownership-scoped + baseline own-work
26
+ // perms floor at 'xenos' (every authenticated builder holds them).
27
+ //
28
+ // PRINCIPALS — the second authority AXIS (BV1.R98, ADR 0151 §3). A builder's
29
+ // authority is the UNION of their ROLES; a machine principal's authority is its
30
+ // IDENTITY. Those are different axes and the model keeps them apart: a principal
31
+ // permission carries `principal: '<system_role>'` and `floor: null`, so it is in
32
+ // NO rank-role (not even archon's) and no custom role may hold it (`system:true`).
33
+ // The two are a PARTITION, asserted at load: floor:null ⟺ principal set. The only
34
+ // source of a principal permission is resolver.resolvePrincipalPermissions(), keyed
35
+ // on the exact `builders.system_role` value — a column with no API writer at all
36
+ // (provisioned out-of-band by migration 065/067, constrained by the DB CHECK
37
+ // allowlist + a UNIQUE index). Nothing in this module can create, grant, or assign
38
+ // one; the Archon tab reads `PRINCIPALS` to SHOW them and has no counterpart write.
39
+ //
40
+ // OWNERSHIP SCOPE — the third AXIS (BV1.R100, ADR 0151 §3). "May act on OWN X" is a
41
+ // PARAMETERIZED permission, not a global grant: box.manage.own ≠ the cross-owner
42
+ // box authority. Two optional fields make that axis explicit DATA (the way
43
+ // `principal` made the identity axis data) rather than something inferred from a
44
+ // key's spelling:
45
+ // • scope — 'own' = ownership-scoped. Holding it authorizes acting ONLY on a
46
+ // resource the holder owns. The permission alone is NEVER
47
+ // sufficient: a second, REQUEST-scoped fact (does this
48
+ // concrete row belong to the caller?) must also hold, and
49
+ // the resolver cannot know it — only the handler that
50
+ // loaded the row does. resolver.authorizeOwned() is the one
51
+ // entry point that answers an 'own' key, and it demands the
52
+ // owner alongside the key.
53
+ // 'any' = the CROSS-OWNER counterpart over the same resource: it
54
+ // reaches every owner's row, so no predicate applies.
55
+ // null = not ownership-scoped (the resource has no owner axis).
56
+ // • resource — WHAT is owned ('claim', 'box', 'memory', …). It is what LINKS an
57
+ // 'own' key to its 'any' counterpart, which a name cannot: the
58
+ // cross-owner box authority is spelled `box.fleet.manage`, while
59
+ // `task.claim.any` ends in `.any` and means "any task in the pool",
60
+ // not "any owner's resource". Inferring the axis from the suffix
61
+ // would get both of those wrong — hence data.
62
+ // Asserted at load: scope ⟺ resource; a `.own`-suffixed key MUST declare scope
63
+ // 'own' (a namespace can never outrun the data); a scoped key needs a real rank
64
+ // floor; every 'any' needs an 'own' in its family; and no family's cross-owner
65
+ // floor sits BELOW its own-only floor (wider authority is never cheaper). The
66
+ // converse of that last pairing rule is deliberately NOT asserted — some resources
67
+ // have no cross-owner form by design (nobody manages another builder's credential,
68
+ // Archon included), and minting a key no route consumes to satisfy a symmetry
69
+ // check would violate this module's first rule.
70
+ //
71
+ // The RANK_ROLE_SEED is DERIVED from floors (cumulative nested supersets), so the
72
+ // "zero day-one behavior change" parity (R103) is structural, not hand-maintained:
73
+ // role(rank) = { p : p.floor !== null and tier(p.floor) <= tier(rank) }, and the
74
+ // resolver's UNION over a builder's one rank-role reproduces requireRank's
75
+ // at-or-above threshold exactly.
76
+
77
+ 'use strict';
78
+
79
+ // Low→high; index is the tier. Mirrors LIVE_RANK_LADDER (db-kernel.js) — the ONE
80
+ // ladder the resolver will collapse the ~13 duplicated tier maps onto (R108).
81
+ const RANK_ORDER = Object.freeze(['xenos', 'thetes', 'metic', 'archon']);
82
+
83
+ // key → capability. `guards` is a human note of the route(s)/surface the key maps to
84
+ // (the exhaustive 1:1 list lives in the authority sweep). Order: system first, then
85
+ // operational by floor (archon → metic → thetes/own/any), matching the ADR appendix.
86
+ const PERMISSIONS = [
87
+ // ── system: true — non-grantable (substrate- or Archon-enforced) ──────────────
88
+ { key: 'builder.rank.set', system: true, floor: 'archon', guards: 'PATCH /builders/:id/rank — the single rank writer' },
89
+ { key: 'version.close', system: true, floor: 'archon', guards: 'version close disposition (never delegated below Archon)' },
90
+ { key: 'task.confirm.grade_bypass', system: true, floor: 'archon', guards: 'POST /tasks/:id/confirm — ratify past a failed grade (env-relaxable, ADR 0041)' },
91
+ { key: 'dependency.cross_tier.manage', system: true, floor: 'archon', guards: 'POST|DELETE /dependencies — cross-tier structural edges (ADR 0086)' },
92
+ { key: 'goal.scope.manage', system: true, floor: 'archon', guards: 'cross-tier goal-scope edges + protected-scope admission (ADR 0086)' },
93
+ { key: 'governance.manage', system: true, floor: 'archon', guards: 'the governance tab write routes (roles/perms/assignments) — meta-escalation' },
94
+ { key: 'gate_approval.decide', system: true, floor: 'archon', guards: 'POST /gate-approvals/:pr/approve — CI deploy-gate override' },
95
+ { key: 'deploy.push_main', system: true, floor: 'archon', guards: 'git push origin main / the deploy — substrate-enforced (pre-push, main-audit)' },
96
+ { key: 'path.protected.modify', system: true, floor: 'archon', guards: 'modifying a PROTECTED_GLOBS path below the Metic floor — substrate (ADR 0043)' },
97
+ { key: 'infra.ssh', system: true, floor: 'archon', guards: 'ssh to the droplet / operator surface — substrate (credential placement)' },
98
+ { key: 'principal.bfg', system: true, floor: null, principal: 'bfg', guards: '/memory/bfg/* — requireBfgPrincipal (exact system_role=bfg; NO rank-role holds it)' },
99
+ { key: 'principal.discord_bot', system: true, floor: null, principal: 'discord_bot', guards: 'Discord bot routes — requireDiscordBot (exact system_role; dormant)' },
100
+
101
+ // ── operational — floor archon (admin ops; grantable to a custom role) ────────
102
+ { key: 'builder.status.set', system: false, floor: 'archon', guards: 'PATCH /builders/:id/status — offboard / (de)activate' },
103
+ { key: 'builder.budget.set', system: false, floor: 'archon', guards: 'PATCH /builders/:id/budget' },
104
+ { key: 'criterion.unsatisfy', system: false, floor: 'archon', guards: 'POST /done-when/:id/unsatisfy' },
105
+ { key: 'auth.session.revoke', system: false, floor: 'archon', guards: 'POST /auth/revoke, /auth/revoke-all' },
106
+ { key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id — admit a builder' },
107
+ { key: 'security.report.adjudicate', system: false, floor: 'archon', guards: 'POST /security/reports/:id/{confirm,dispute,fix,link-fix}, /security/docs CRUD' },
108
+ { key: 'provisioning.fleet.manage', system: false, floor: 'archon', guards: 'GET /provisioning/fleet[/cost-ledger], POST .../force-teardown' },
109
+ { key: 'box.fleet.manage', system: false, floor: 'archon', scope: 'any', resource: 'box', guards: 'GET /boxes[/cost-ledger], POST /boxes/:b/close, PATCH .../block' },
110
+ { key: 'autonomy.precheck', system: false, floor: 'archon', guards: 'GET /autonomy/precheck — unsupervised-runner gate' },
111
+ { key: 'discord.channels.manage', system: false, floor: 'archon', guards: 'GET /discord/channels/plan, POST .../reconcile' },
112
+ { key: 'project.curate', system: false, floor: 'archon', guards: 'platform-identity: POST /projects, /projects/invite, PATCH /projects/:id/featured, GET /scouting' },
113
+ { key: 'override_request.decide', system: false, floor: 'archon', guards: 'GET /override-requests, POST .../:id/decide' },
114
+ { key: 'claim.release.any', system: false, floor: 'archon', scope: 'any', resource: 'claim', guards: 'POST /claims/:id/release-on-behalf' },
115
+ { key: 'idea.ratify', system: false, floor: 'archon', guards: 'POST /inbox/:id/ratify' },
116
+ { key: 'task.newcomer_restock', system: false, floor: 'archon', guards: 'POST /tasks/newcomer-restock' },
117
+ { key: 'task.peer_votes.tally', system: false, floor: 'archon', guards: 'POST /tasks/peer-votes/tally' },
118
+ { key: 'goal.reorder', system: false, floor: 'archon', guards: 'POST /goals/reorder' },
119
+ { key: 'builder.roster.read', system: false, floor: 'archon', guards: 'GET /builders/roster, /grades/by-builder, /builders/:id/active-claims' },
120
+ { key: 'session.search', system: false, floor: 'archon', guards: 'GET /sessions/search' },
121
+ { key: 'memory.read.any', system: false, floor: 'archon', scope: 'any', resource: 'memory', guards: 'GET /memory/builders/:id/file[s] — cross-builder memory read' },
122
+ { key: 'audit_log.read', system: false, floor: 'archon', guards: 'GET /audit-log' },
123
+
124
+ // ── operational — floor metic (the working rank's review/triage/authoring) ────
125
+ { key: 'idea.triage', system: false, floor: 'metic', guards: 'PATCH /inbox/:id' },
126
+ { key: 'blocker.review', system: false, floor: 'metic', guards: 'POST /blockers/:id/{resolve,link}' },
127
+ { key: 'task.create', system: false, floor: 'metic', guards: 'POST /tasks (incl. newcomer_friendly) — ADR 0090' },
128
+ { key: 'task.edit', system: false, floor: 'metic', guards: 'PATCH /tasks/:id' },
129
+ { key: 'task.promote', system: false, floor: 'metic', guards: 'POST /tasks/:id/promote' },
130
+ { key: 'task.demote', system: false, floor: 'metic', guards: 'POST /tasks/:id/demote' },
131
+ { key: 'task.abandon', system: false, floor: 'metic', guards: 'POST /tasks/:id/abandon — scope removal; undo = POST /tasks/:id/promote (ADR 0152, was archon)' },
132
+ { key: 'task.vote', system: false, floor: 'metic', guards: 'POST /tasks/:id/vote' },
133
+ { key: 'task.dependency.manage', system: false, floor: 'metic', guards: 'POST|PUT|DELETE /tasks/:id/dependencies (same-tier)' },
134
+ { key: 'task.criterion.manage', system: false, floor: 'metic', guards: 'POST|DELETE /tasks/:id/criteria' },
135
+ { key: 'criterion.satisfy', system: false, floor: 'metic', guards: 'POST /done-when/:id/satisfy' },
136
+ { key: 'criterion.review', system: false, floor: 'metic', guards: 'GET /done-when/pending-review' },
137
+ // ADR 0154. Split out of criterion.review, where the route sat while it was still
138
+ // Archon-only (a slip against the sweep — ADR 0154 §5). floor 'metic' is real but
139
+ // NOT the whole gate: a non-Archon must also name a goal_id they own or manage, in
140
+ // an open goal on a planning/building version (authorizeCriterionCreate, in-handler).
141
+ { key: 'criterion.create', system: false, floor: 'metic', guards: 'POST /versions/:id/done-when (goal owner|manager)' },
142
+ { key: 'planning.run', system: false, floor: 'metic', guards: 'planning-session surface' },
143
+ { key: 'priority.run', system: false, floor: 'metic', guards: 'priority-session surface' },
144
+ { key: 'goal.create', system: false, floor: 'metic', guards: 'POST /goals (protected scope → Archon in-handler; ADR 0086)' },
145
+ { key: 'goal.archive', system: false, floor: 'metic', guards: 'POST /goals/:id/{archive,reopen}' },
146
+ { key: 'goal.scope.widen', system: false, floor: 'metic', guards: 'POST /goals/:id/scope (same-tier; cross-tier = goal.scope.manage, system)' },
147
+ { key: 'module.enable', system: false, floor: 'metic', guards: 'POST /modules/:key/enable' },
148
+ { key: 'module.submit', system: false, floor: 'metic', guards: 'POST /modules/:key/submit, GET /modules/submissions' },
149
+ { key: 'security.report.file', system: false, floor: 'metic', guards: 'POST /security/reports, GET /security/adr' },
150
+ { key: 'llm_cache.use', system: false, floor: 'metic', guards: 'POST /llm-cache/{lookup,store}' },
151
+ { key: 'backup.manage', system: false, floor: 'metic', guards: 'GET /backup/status, POST /backup/trigger' },
152
+ { key: 'source.clone.local', system: false, floor: 'metic', guards: 'Metic+ local clone / collaborator invite (ADR 0035)' },
153
+ { key: 'box.source.full', system: false, floor: 'metic', guards: 'GET /box/source-access → full (vs starter); boxScopeForRank' },
154
+
155
+ // ── ownership-scoped + baseline own-work (floor thetes / xenos) ───────────────
156
+ { key: 'task.claim.any', system: false, floor: 'thetes', guards: 'claim the general queue (per-task requires_rank floor still applies)' },
157
+ { key: 'page.view.builder', system: false, floor: 'thetes', guards: 'authenticated hall pages (requireNonXenosPage / requireBuilderPage)' },
158
+ { key: 'goal.manage.own', system: false, floor: 'metic', scope: 'own', resource: 'goal', guards: 'PATCH /goals/:id/members/:b, POST /goals/:id/transfer — owner/manager, in-handler (ADR 0106)' },
159
+ { key: 'task.claim.newcomer', system: false, floor: 'xenos', guards: 'claim a newcomer_friendly task (xenosClaimAllowed; xenos = one active claim)' },
160
+ { key: 'task.ship', system: false, floor: 'xenos', guards: '/builder-ship own claim' },
161
+ { key: 'task.act.own', system: false, floor: 'xenos', scope: 'own', resource: 'claim', guards: 'act on OWN active claim (release, cost, notes) — ownership-scoped' },
162
+ { key: 'box.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'box', guards: 'own dev-box lifecycle — ownership-scoped' },
163
+ { key: 'memory.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'memory', guards: 'own builder memory — ownership-scoped' },
164
+ // No cross-owner counterpart, deliberately: nobody manages another builder's
165
+ // credential, Archon included. A `credential.manage.any` would be a key no route
166
+ // consumes — the catalog's first rule forbids minting one for symmetry.
167
+ { key: 'credential.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'credential', guards: 'own API key / credential — ownership-scoped' },
168
+ // Own-scoped WITHOUT a `.own` suffix — the `me.` namespace is the ownership
169
+ // marker here. Exactly why scope is declared data: the suffix rule only runs the
170
+ // safe direction ('.own' ⇒ scope 'own'), never the converse.
171
+ { key: 'me.wandering.set', system: false, floor: 'xenos', scope: 'own', resource: 'builder_prefs', guards: 'PATCH /me/wandering (clamped by maxLevelForRank) — ownership-scoped' },
172
+ { key: 'cost.log', system: false, floor: 'xenos', guards: 'POST /costs' },
173
+ { key: 'learning.create', system: false, floor: 'xenos', guards: 'POST /learnings' },
174
+ { key: 'blocker.file', system: false, floor: 'xenos', guards: 'POST /blockers' },
175
+ { key: 'idea.file', system: false, floor: 'xenos', guards: 'POST /inbox (own idea capture)' },
176
+ { key: 'page.view.public', system: false, floor: 'xenos', guards: 'public read surfaces (incl. xenos)' },
177
+ { key: 'page.view.governance', system: false, floor: 'archon', guards: 'the Archon governance page (requireArchonPage; write = governance.manage)' },
178
+ ];
179
+
180
+ // ── invariants asserted at module load (fail loud on an authoring typo) ─────────
181
+ const PRINCIPAL_KEY_PREFIX = 'principal.';
182
+ const OWN_KEY_SUFFIX = '.own';
183
+ const OWNERSHIP_SCOPES = Object.freeze(['own', 'any']);
184
+ const _byKey = new Map();
185
+ const _byPrincipal = new Map();
186
+ const _byResource = new Map();
187
+ for (const p of PERMISSIONS) {
188
+ if (typeof p.key !== 'string' || !p.key) throw new Error(`governance catalog: permission missing a key`);
189
+ if (typeof p.system !== 'boolean') throw new Error(`governance catalog: ${p.key} missing boolean 'system'`);
190
+ if (p.floor !== null && !RANK_ORDER.includes(p.floor)) throw new Error(`governance catalog: ${p.key} has invalid floor "${p.floor}"`);
191
+ if (_byKey.has(p.key)) throw new Error(`governance catalog: duplicate key "${p.key}"`);
192
+ const principal = p.principal ?? null;
193
+ if (principal !== null && (typeof principal !== 'string' || !principal)) {
194
+ throw new Error(`governance catalog: ${p.key} has a non-string principal identity`);
195
+ }
196
+ // The two axes PARTITION the catalog: a permission is held by a rank-role (a real
197
+ // floor) XOR by a machine identity (floor null + a principal). Neither-nor would be
198
+ // a permission nothing can ever hold; both-and would let a role reach an identity.
199
+ if ((p.floor === null) !== (principal !== null)) {
200
+ throw new Error(`governance catalog: ${p.key} — floor:null and a principal identity must come together (roles and identities are separate axes)`);
201
+ }
202
+ if (p.key.startsWith(PRINCIPAL_KEY_PREFIX) !== (principal !== null)) {
203
+ throw new Error(`governance catalog: ${p.key} — a "${PRINCIPAL_KEY_PREFIX}*" key and a principal identity must come together`);
204
+ }
205
+ if (principal !== null) {
206
+ if (!p.system) throw new Error(`governance catalog: ${p.key} is a principal permission and must be system:true (never grantable to a role)`);
207
+ if (_byPrincipal.has(principal)) throw new Error(`governance catalog: duplicate principal identity "${principal}"`);
208
+ }
209
+ // ── the OWNERSHIP axis (R100) ─────────────────────────────────────────────
210
+ const scope = p.scope ?? null;
211
+ const resource = p.resource ?? null;
212
+ if (scope !== null && !OWNERSHIP_SCOPES.includes(scope)) {
213
+ throw new Error(`governance catalog: ${p.key} has invalid scope "${scope}" (expected 'own', 'any', or absent)`);
214
+ }
215
+ // An ownership SCOPE and the RESOURCE it is scoped over are one fact spelled in
216
+ // two fields; either alone is meaningless ('own' of what? / a resource nothing
217
+ // is scoped over). Same partition posture as floor ⟺ principal above.
218
+ if ((scope === null) !== (resource === null)) {
219
+ throw new Error(`governance catalog: ${p.key} — scope and resource must come together (an ownership scope is always scoped over a named resource)`);
220
+ }
221
+ if (resource !== null && (typeof resource !== 'string' || !resource)) {
222
+ throw new Error(`governance catalog: ${p.key} has a non-string resource family`);
223
+ }
224
+ // The namespace can never outrun the data: a key that CALLS itself own-scoped
225
+ // must BE declared own-scoped, or a gate reading the declared axis would treat a
226
+ // parameterized permission as an unconditional grant. Deliberately one-way — the
227
+ // converse would be false (me.wandering.set is own-scoped without the suffix).
228
+ if (p.key.endsWith(OWN_KEY_SUFFIX) && scope !== 'own') {
229
+ throw new Error(`governance catalog: ${p.key} ends in "${OWN_KEY_SUFFIX}" but does not declare scope:'own' — an ownership-scoped key must say so in data`);
230
+ }
231
+ // An ownership scope NARROWS a role-held permission; a machine identity has no
232
+ // owner axis, so the two never compose.
233
+ if (scope !== null && p.floor === null) {
234
+ throw new Error(`governance catalog: ${p.key} is ownership-scoped but has no rank floor — the ownership axis narrows a ROLE permission, never an identity one`);
235
+ }
236
+ const frozen = Object.freeze({ ...p, principal, scope, resource });
237
+ _byKey.set(p.key, frozen);
238
+ if (principal !== null) _byPrincipal.set(principal, frozen);
239
+ if (resource !== null) {
240
+ if (!_byResource.has(resource)) _byResource.set(resource, { own: [], any: [] });
241
+ _byResource.get(resource)[scope].push(p.key);
242
+ }
243
+ }
244
+ Object.freeze(PERMISSIONS);
245
+
246
+ // ── the ownership axis's cross-row invariants ──────────────────────────────────
247
+ // A 'any' row is BY DEFINITION the cross-owner counterpart of an 'own' one, so a
248
+ // family with no 'own' side is a mis-declaration. The converse is NOT required:
249
+ // some resources have no cross-owner form at all (credential), and minting a key
250
+ // no route consumes purely for symmetry would break this module's first rule.
251
+ const floorTier = (rank) => RANK_ORDER.indexOf(rank);
252
+ for (const [resource, fam] of _byResource) {
253
+ if (fam.any.length > 0 && fam.own.length === 0) {
254
+ throw new Error(`governance catalog: resource "${resource}" declares a cross-owner permission (${fam.any.join(', ')}) with no ownership-scoped counterpart`);
255
+ }
256
+ // Reaching EVERY owner's row must never be cheaper than reaching your own. If it
257
+ // were, a rank could manage the whole fleet but not its own box — the model
258
+ // inverted, and the governance tab would render the wider grant as the safer one.
259
+ for (const anyKey of fam.any) {
260
+ for (const ownKey of fam.own) {
261
+ if (floorTier(_byKey.get(anyKey).floor) < floorTier(_byKey.get(ownKey).floor)) {
262
+ throw new Error(`governance catalog: ${anyKey} (floor ${_byKey.get(anyKey).floor}) sits BELOW ${ownKey} (floor ${_byKey.get(ownKey).floor}) — cross-owner authority must never be cheaper than own-only`);
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ // resource → { own: [keys], any: [keys] }. The pairing data a gate, a test, or the
269
+ // Archon tab reads to relate "act on own X" to "act on any X"; DERIVED from the
270
+ // rows above so it cannot drift from them.
271
+ const RESOURCE_FAMILIES = Object.freeze(Object.fromEntries(
272
+ [..._byResource.entries()].map(([resource, fam]) => [resource, Object.freeze({
273
+ own: Object.freeze([...fam.own]),
274
+ any: Object.freeze([...fam.any]),
275
+ })]),
276
+ ));
277
+
278
+ // The principal registry — the read-only projection the Archon tab renders (R110/
279
+ // R111) and the resolver's identity axis reads. Derived from the catalog so it
280
+ // cannot drift, and deliberately WITHOUT a counterpart writer: a principal is
281
+ // provisioned out-of-band in `builders.system_role` (migration-only, DB CHECK
282
+ // allowlist), so `grantable: false` here is a statement of fact, not a policy flag.
283
+ const PRINCIPALS = Object.freeze(
284
+ [..._byPrincipal.values()].map((p) => Object.freeze({
285
+ system_role: p.principal,
286
+ permission: p.key,
287
+ guards: p.guards,
288
+ grantable: false,
289
+ provisioned: 'out-of-band: builders.system_role (no API writes it)',
290
+ })),
291
+ );
292
+ const _principalByRole = new Map(PRINCIPALS.map((p) => [p.system_role, p]));
293
+
294
+ function permissionKeys() {
295
+ return PERMISSIONS.map((p) => p.key);
296
+ }
297
+ function byKey(key) {
298
+ return _byKey.get(key) || null;
299
+ }
300
+ function isSystem(key) {
301
+ const p = _byKey.get(key);
302
+ return p ? p.system : null; // null = unknown key (caller fails closed)
303
+ }
304
+ // Grantable to a CUSTOM role = exists and not system. Unknown key → false (fail-closed).
305
+ function isGrantable(key) {
306
+ const p = _byKey.get(key);
307
+ return !!p && !p.system;
308
+ }
309
+ function systemKeys() {
310
+ return PERMISSIONS.filter((p) => p.system).map((p) => p.key);
311
+ }
312
+ function grantableKeys() {
313
+ return PERMISSIONS.filter((p) => !p.system).map((p) => p.key);
314
+ }
315
+
316
+ // ── the principal axis (identity, not roles) ────────────────────────────────────
317
+ // Reads only. Unknown/blank input fails closed to null/false — the same posture the
318
+ // rest of the catalog takes, and the one the identity gates depend on.
319
+ function principals() {
320
+ return PRINCIPALS;
321
+ }
322
+ function principalRoles() {
323
+ return PRINCIPALS.map((p) => p.system_role);
324
+ }
325
+ function principalKeys() {
326
+ return PRINCIPALS.map((p) => p.permission);
327
+ }
328
+ // Is this key held by a machine identity rather than by any role? Unknown → false.
329
+ function isPrincipalKey(key) {
330
+ const p = _byKey.get(key);
331
+ return !!p && p.principal !== null;
332
+ }
333
+ // EXACT match on the system_role string — never a prefix, case-fold, or coercion.
334
+ // That exactness is the security property: it is what makes an Archon token (whose
335
+ // system_role is NULL) unable to resolve a principal permission.
336
+ function principalByRole(systemRole) {
337
+ if (typeof systemRole !== 'string' || !systemRole) return null;
338
+ return _principalByRole.get(systemRole) || null;
339
+ }
340
+
341
+ // ── the ownership axis (scope, not identity) ───────────────────────────────────
342
+ // Reads only, and fail-closed on an unknown key in the direction that matters:
343
+ // isOwnershipScoped() answers FALSE for a key it does not know, but the resolver
344
+ // never reaches a gate decision off that alone — resolver.authorizeOwned() looks
345
+ // the row up itself and denies an unknown key outright.
346
+ function scopeOf(key) {
347
+ const p = _byKey.get(key);
348
+ return p ? p.scope : null;
349
+ }
350
+ function resourceOf(key) {
351
+ const p = _byKey.get(key);
352
+ return p ? p.resource : null;
353
+ }
354
+ // Does holding this key require an ownership predicate against the concrete row?
355
+ // This is the question a gate must ask BEFORE treating a held permission as an
356
+ // admit — an ownership-scoped key is never sufficient on its own.
357
+ function isOwnershipScoped(key) {
358
+ return scopeOf(key) === 'own';
359
+ }
360
+ // The cross-owner counterpart: reaches every owner's row, so no predicate applies.
361
+ function isCrossOwner(key) {
362
+ return scopeOf(key) === 'any';
363
+ }
364
+ function ownershipScopedKeys() {
365
+ return PERMISSIONS.filter((p) => p.scope === 'own').map((p) => p.key);
366
+ }
367
+ function crossOwnerKeys() {
368
+ return PERMISSIONS.filter((p) => p.scope === 'any').map((p) => p.key);
369
+ }
370
+ // The cross-owner keys over the SAME resource as `key` — what a route offers as
371
+ // the escalation path ("your own claim, or claim.release.any"). Empty when the
372
+ // resource has no cross-owner form by design.
373
+ const NO_KEYS = Object.freeze([]);
374
+ function crossOwnerKeysFor(key) {
375
+ const resource = resourceOf(key);
376
+ const fam = resource === null ? null : RESOURCE_FAMILIES[resource];
377
+ return fam ? fam.any : NO_KEYS;
378
+ }
379
+
380
+ // The compat seed, DERIVED from floors (cumulative supersets). A rank-role holds
381
+ // every permission whose floor is a real rank at-or-below it; principals (floor
382
+ // null) are held by NO rank-role. This is the "zero day-one change" guarantee —
383
+ // role(rank) reproduces requireRank(rank)'s at-or-above threshold exactly (R103).
384
+ function seedForRank(rank) {
385
+ const tier = RANK_ORDER.indexOf(rank);
386
+ if (tier < 0) return []; // unknown/reserved rank → no role → no permissions (fail-closed; ADR 0151)
387
+ return PERMISSIONS.filter((p) => p.floor !== null && RANK_ORDER.indexOf(p.floor) <= tier).map((p) => p.key);
388
+ }
389
+
390
+ const RANK_ROLE_SEED = Object.freeze(
391
+ Object.fromEntries(RANK_ORDER.map((r) => [r, Object.freeze(seedForRank(r))])),
392
+ );
393
+
394
+ module.exports = {
395
+ RANK_ORDER,
396
+ PERMISSIONS,
397
+ permissionKeys,
398
+ byKey,
399
+ isSystem,
400
+ isGrantable,
401
+ systemKeys,
402
+ grantableKeys,
403
+ seedForRank,
404
+ RANK_ROLE_SEED,
405
+ PRINCIPALS,
406
+ principals,
407
+ principalRoles,
408
+ principalKeys,
409
+ isPrincipalKey,
410
+ principalByRole,
411
+ RESOURCE_FAMILIES,
412
+ scopeOf,
413
+ resourceOf,
414
+ isOwnershipScoped,
415
+ isCrossOwner,
416
+ ownershipScopedKeys,
417
+ crossOwnerKeys,
418
+ crossOwnerKeysFor,
419
+ };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ // modules/governance/config.js — the governance config contract (ADR 0151, BV1.R96).
4
+ //
5
+ // Role TEMPLATES and their display labels are per-instance CONFIG; live grants and
6
+ // assignments are DB rows. This file resolves the config half, mirroring
7
+ // src/branding.js precedence exactly:
8
+ //
9
+ // env override -> instance config (config/governance.json) -> neutral
10
+ // starter (config/governance.neutral.json)
11
+ //
12
+ // A fresh instance with NO config resolves to the PLAIN neutral role set — never to
13
+ // a host's labels (fail-to-vanilla, the same posture branding takes). R102 proves
14
+ // that boot.
15
+ //
16
+ // WHAT LIVES HERE vs WHAT LIVES IN THE DB — get this wrong and config becomes a
17
+ // second source of authority truth, which is exactly what ADR 0016 forbids:
18
+ // • config — TEMPLATES/DEFAULTS: a role's display label, its description, and the
19
+ // permission list a starting CUSTOM role template proposes. Shapes only.
20
+ // • DB — LIVE STATE: governance_roles / governance_role_permissions /
21
+ // governance_builder_roles (R93). WHO HOLDS WHAT is a row, resolved server-side
22
+ // per request by resolver.js. Nothing in this file grants anything to anybody.
23
+ //
24
+ // Three fail-closed rules, in the same spirit as resolver.js's drop-unknown-key rule:
25
+ // 1. A template naming a `system: true` catalog key is STRIPPED. An instance's
26
+ // config file is editable by anyone who can write the checkout, so it must never
27
+ // be able to hand out deploy / rank-set / protected-path power. catalog
28
+ // .isGrantable() is that wall — the same one the governance API and the Archon
29
+ // tab use, so this cannot drift from the catalog.
30
+ // 2. A template naming a key the catalog does not define is DROPPED (the catalog is
31
+ // the source of truth for what a permission IS — resolver.js, same rule).
32
+ // 3. A `permissions` list on one of the four SEEDED rank-roles is DROPPED wholesale.
33
+ // Their grants are DERIVED from catalog floors and materialised by
34
+ // governance_002; config may relabel a seeded role, never re-grant it.
35
+ //
36
+ // Stripping rather than throwing matches resolver.js: a typo in a config file must
37
+ // fail CLOSED, not brick the boot.
38
+ //
39
+ // SLICE BOUNDARY: this file is the config CONTRACT, and deliberately has no production
40
+ // caller yet — R96 is the "what shape is a role template, and what can config never
41
+ // do" decision, landed before the surfaces that read it. R110/R111 (the Archon
42
+ // governance tab) is the first intended consumer, and R102 (the vanilla-boot proof)
43
+ // asserts against the neutral starter. Wiring a speculative caller now would guess at
44
+ // their needs; the tests pin the contract until they land.
45
+
46
+ const fs = require('node:fs');
47
+ const path = require('node:path');
48
+ const api = require('../../src/module-api');
49
+ const catalog = require('./catalog');
50
+
51
+ // Both the neutral starter and the instance pack sit at the repo root's config/
52
+ // today (src/instance-config.js resolveCoreRoot() === resolveInstanceRoot() === this
53
+ // checkout), so this is the established module idiom for a repo-root read
54
+ // (modules/lifecycle, modules/onboarding, modules/discord). When ADR 0108's root
55
+ // split lands, the INSTANCE path routes through resolveInstanceRoot() — the module
56
+ // doorway does not re-export the root resolvers yet.
57
+ const ROOT = path.resolve(__dirname, '..', '..');
58
+ const NEUTRAL_PATH = path.join(ROOT, 'config', 'governance.neutral.json');
59
+ const INSTANCE_PATH = path.join(ROOT, 'config', 'governance.json');
60
+
61
+ const SEEDED_RANK_ROLES = new Set(catalog.RANK_ORDER);
62
+
63
+ const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
64
+
65
+ // Instance roles merge over neutral PER ROLE KEY and per field, so an instance can
66
+ // relabel `metic` without restating the other three. The shape is exactly two levels
67
+ // deep, so this is a targeted merge rather than branding's generic deepMerge.
68
+ function mergeRoles(neutral, instance) {
69
+ const out = {};
70
+ const keys = new Set([...Object.keys(neutral || {}), ...Object.keys(instance || {})]);
71
+ for (const key of keys) {
72
+ const n = isObj((neutral || {})[key]) ? neutral[key] : {};
73
+ const i = isObj((instance || {})[key]) ? instance[key] : {};
74
+ out[key] = { ...n, ...i };
75
+ }
76
+ return out;
77
+ }
78
+
79
+ // The env layer overrides LABELS ONLY, deliberately: an env var must never be able to
80
+ // widen authority, so there is no env knob that adds a permission. It exists for the
81
+ // co-tenant case branding's AUTH_IDP override exists for — several hosted instances
82
+ // sharing the neutral pack, differentiated only by env, with no config file of their
83
+ // own. Format: "<PREFIX>_GOVERNANCE_ROLE_LABELS=metic=Trusted,archon=Owner". It can
84
+ // only RELABEL a role the merged config already defines — a typo names no role and is
85
+ // inert, rather than quietly growing the role set from an env string.
86
+ function applyLabelOverrides(roles, env) {
87
+ const raw = api.resolveEnv('GOVERNANCE_ROLE_LABELS', { env });
88
+ if (!raw) return roles;
89
+ const out = { ...roles };
90
+ for (const pair of String(raw).split(',')) {
91
+ const eq = pair.indexOf('=');
92
+ if (eq <= 0) continue;
93
+ const key = pair.slice(0, eq).trim();
94
+ const label = pair.slice(eq + 1).trim();
95
+ if (!label || !isObj(out[key])) continue;
96
+ out[key] = { ...out[key], label };
97
+ }
98
+ return out;
99
+ }
100
+
101
+ // Filter a template's proposed permissions against the code catalog. isGrantable() is
102
+ // false for BOTH an unknown key and a `system: true` key, so rules 1 and 2 above are
103
+ // one call — and they can never drift from the catalog. Returns a frozen sorted array.
104
+ function grantableOnly(keys) {
105
+ const seen = new Set();
106
+ for (const k of Array.isArray(keys) ? keys : []) {
107
+ if (typeof k === 'string' && catalog.isGrantable(k)) seen.add(k);
108
+ }
109
+ return Object.freeze([...seen].sort());
110
+ }
111
+
112
+ function sanitizeRole(key, raw) {
113
+ const seeded = SEEDED_RANK_ROLES.has(key);
114
+ return Object.freeze({
115
+ key,
116
+ seeded,
117
+ label: typeof raw.label === 'string' && raw.label ? raw.label : key,
118
+ description: typeof raw.description === 'string' ? raw.description : '',
119
+ // Rule 3: a seeded rank-role's grants come from catalog floors via governance_002.
120
+ permissions: seeded ? Object.freeze([]) : grantableOnly(raw.permissions),
121
+ });
122
+ }
123
+
124
+ // Pure resolver — all inputs injected, so tests drive it with zero I/O (the
125
+ // resolveBranding pattern).
126
+ function resolveGovernanceConfig({ neutral, instance, env = process.env } = {}) {
127
+ const merged = applyLabelOverrides(mergeRoles((neutral || {}).roles, (instance || {}).roles), env);
128
+ const roles = {};
129
+ for (const key of Object.keys(merged).sort()) {
130
+ if (!isObj(merged[key])) continue; // a malformed entry is not a role, and grants nothing
131
+ roles[key] = sanitizeRole(key, merged[key]);
132
+ }
133
+ // The four rank-roles ALWAYS exist (governance_002 seeds them unconditionally), so a
134
+ // config that omits or mangles one falls back to the canonical rank name rather than
135
+ // dropping the role out of the display set.
136
+ for (const key of catalog.RANK_ORDER) {
137
+ if (!roles[key]) roles[key] = sanitizeRole(key, {});
138
+ }
139
+ return Object.freeze({ roles: Object.freeze(roles) });
140
+ }
141
+
142
+ // READ, never require(): a bare require('../../config/X.json') of a boundary-excluded
143
+ // config crashes a vanilla boot with MODULE_NOT_FOUND (fitness Check 16 / task 1824).
144
+ function readJson(p) {
145
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
146
+ catch (e) { if (e.code === 'ENOENT') return null; throw e; }
147
+ }
148
+
149
+ // Disk loader with the documented precedence. BOTH files are optional: the instance
150
+ // pack because a vanilla instance ships only the neutral starter, and the neutral
151
+ // starter because the four rank-roles still resolve to their canonical names without
152
+ // it. Unlike branding — where a missing identity is fatal — no value here is required,
153
+ // so the loader fails to sane defaults rather than throwing.
154
+ //
155
+ // CALL THIS AT BOOT, NOT PER REQUEST. The two reads are synchronous, which is correct
156
+ // for a startup read of a committed file and wrong inside a request handler. If a
157
+ // consumer needs it on a hot path, resolve once at boot and pass the frozen result —
158
+ // do NOT add a cache here (this file sits next to resolver.js, whose no-cache rule is
159
+ // absolute; keeping config cache-free too means nobody has to check which is which).
160
+ function loadGovernanceConfig({ neutralPath = NEUTRAL_PATH, instancePath = INSTANCE_PATH, env = process.env } = {}) {
161
+ return resolveGovernanceConfig({
162
+ neutral: readJson(neutralPath) || {},
163
+ instance: readJson(instancePath) || {},
164
+ env,
165
+ });
166
+ }
167
+
168
+ module.exports = {
169
+ resolveGovernanceConfig,
170
+ loadGovernanceConfig,
171
+ grantableOnly,
172
+ NEUTRAL_PATH,
173
+ INSTANCE_PATH,
174
+ };