@bongos/core 1.18.18 → 1.19.2

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 (251) hide show
  1. package/.bongos-core.json +515 -195
  2. package/.claude/skills/builder-box/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +1 -1
  4. package/.claude/skills/goal-create/SKILL.md +1 -1
  5. package/.claude/skills/idea-triage/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +5 -5
  7. package/.claude/skills/scan-before-install/SKILL.md +165 -0
  8. package/clients/bongos-client/README.md +1 -1
  9. package/clients/bongos-client/bongos-client.global.js +63 -41
  10. package/clients/bongos-client/index.cjs +63 -41
  11. package/clients/bongos-client/index.d.ts +78 -44
  12. package/clients/bongos-client/index.mjs +63 -41
  13. package/docs/adr/0020-security-model-public-stub.md +1 -1
  14. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  15. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  16. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  17. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  18. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  19. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  20. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  21. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  22. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  23. package/docs/adr/README.md +8 -0
  24. package/docs/api/openapi.json +1209 -233
  25. package/docs/api-reference.md +61 -46
  26. package/docs/architecture.md +1 -1
  27. package/docs/canonical-permissions.md +61 -16
  28. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  29. package/docs/file-map.md +4 -1
  30. package/docs/onboarding/diagrams/assertions.json +3 -3
  31. package/docs/recipes/core-release-pipeline.md +179 -0
  32. package/docs/recipes/packaging-the-core.md +2 -0
  33. package/docs/recipes/parallel-goal-waves.md +1 -3
  34. package/docs/recipes/private-npm-distribution.md +2 -0
  35. package/docs/recipes/upgrading-the-core.md +2 -0
  36. package/migrations/core_197_display_name_source.sql +56 -0
  37. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  38. package/modules/autonomy/routes/autonomy.js +1 -1
  39. package/modules/builder-settings/db.js +7 -1
  40. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  41. package/modules/dev-box/routes/box.js +11 -9
  42. package/modules/discord/auth.js +3 -1
  43. package/modules/discord/routes/discord.js +8 -2
  44. package/modules/discord/ship-broadcast.js +48 -1
  45. package/modules/governance/catalog.js +70 -20
  46. package/modules/governance/db.js +102 -0
  47. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  48. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  49. package/modules/governance/routes/governance.js +235 -6
  50. package/modules/grading/grader-workers/index.js +18 -9
  51. package/modules/hall-ui/public/gate.js +6 -2
  52. package/modules/hall-ui/public/goals-page.js +6 -2
  53. package/modules/hall-ui/public/governance.css +100 -0
  54. package/modules/hall-ui/public/governance.html +77 -0
  55. package/modules/hall-ui/public/governance.js +353 -0
  56. package/modules/hall-ui/public/harbor.js +14 -8
  57. package/modules/hall-ui/public/instances-panel.js +3 -1
  58. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  59. package/modules/hall-ui/public/people.html +51 -0
  60. package/modules/hall-ui/public/people.js +100 -0
  61. package/modules/hall-ui/public/profile.html +2 -1
  62. package/modules/hall-ui/public/profile.js +9 -3
  63. package/modules/hall-ui/public/sessions.js +4 -2
  64. package/modules/hall-ui/public/shell.js +28 -7
  65. package/modules/hall-ui/public/task-detail.js +6 -3
  66. package/modules/hall-ui/public/watch.js +17 -9
  67. package/modules/ideas/blockers.js +44 -7
  68. package/modules/ideas/routes/blockers.js +12 -7
  69. package/modules/ideas/routes/inbox.js +2 -2
  70. package/modules/lifecycle/db.js +84 -17
  71. package/modules/lifecycle/github-push.js +37 -10
  72. package/modules/lifecycle/publish-reconciler.js +128 -31
  73. package/modules/lifecycle/routes/claims.js +10 -4
  74. package/modules/lifecycle/routes/dependencies.js +2 -2
  75. package/modules/lifecycle/routes/done-when.js +4 -4
  76. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  77. package/modules/lifecycle/routes/goals.js +5 -5
  78. package/modules/lifecycle/routes/tasks.js +16 -16
  79. package/modules/lifecycle/routes/versions.js +13 -2
  80. package/modules/lifecycle/ship-card.js +38 -7
  81. package/modules/memory/routes/memory.js +2 -2
  82. package/modules/onboarding/routes/access-requests.js +3 -3
  83. package/modules/platform-identity/routes/projects.js +10 -6
  84. package/modules/platform-identity/routes/scouting.js +2 -2
  85. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  86. package/modules/provisioning/provisioning.js +24 -6
  87. package/modules/provisioning/routes/provisioning.js +24 -6
  88. package/modules/security/artifact-scan/aggregate.js +133 -0
  89. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  90. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  91. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  92. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  93. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  94. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  95. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  96. package/modules/security/artifact-scan/detectors/network.js +67 -0
  97. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  98. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  99. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  100. package/modules/security/artifact-scan/fence-local.js +99 -0
  101. package/modules/security/artifact-scan/fetch.js +330 -0
  102. package/modules/security/artifact-scan/floor.js +319 -0
  103. package/modules/security/artifact-scan/limits.js +66 -0
  104. package/modules/security/artifact-scan/manifest.js +203 -0
  105. package/modules/security/artifact-scan/mirror.js +158 -0
  106. package/modules/security/artifact-scan/quarantine.js +149 -0
  107. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  108. package/modules/security/artifact-scan/report.js +155 -0
  109. package/modules/security/artifact-scan/sanitize.js +317 -0
  110. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  111. package/modules/security/module.json +2 -2
  112. package/modules/security/routes/reports.js +11 -7
  113. package/modules/security/routes/scans.js +115 -0
  114. package/modules/security/scan-store.js +214 -0
  115. package/modules/security/tests/aggregate.mjs +283 -0
  116. package/modules/security/tests/artifact_scan.mjs +591 -0
  117. package/modules/security/tests/floor.mjs +549 -0
  118. package/modules/security/tests/mirror.mjs +350 -0
  119. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  120. package/modules/security/tests/scan_store.mjs +250 -0
  121. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  122. package/modules/sessions/routes/sessions.js +6 -5
  123. package/package-lock.json +2 -2
  124. package/package.json +1 -1
  125. package/public-landing/create.html +13 -3
  126. package/scripts/gds/abandon.js +1 -1
  127. package/scripts/gds/audit-authorship.sh +6 -0
  128. package/scripts/gds/box.js +1 -1
  129. package/scripts/gds/claim.js +15 -0
  130. package/scripts/gds/cli-lib.js +16 -4
  131. package/scripts/gds/gating.js +28 -13
  132. package/scripts/gds/gen-api-docs.js +1 -1
  133. package/scripts/gds/goal.js +234 -0
  134. package/scripts/gds/init.js +70 -15
  135. package/scripts/gds/main-audit.js +80 -11
  136. package/scripts/gds/mirror-redact.js +6 -0
  137. package/scripts/gds/newcomer-floor.js +2 -2
  138. package/scripts/gds/provision.js +230 -94
  139. package/scripts/gds/run-unit-tests.js +9 -1
  140. package/scripts/gds/sandbox-stage.js +66 -9
  141. package/scripts/gds/search-ingest-md.js +1 -0
  142. package/scripts/gds/sequence.js +67 -8
  143. package/scripts/gds/ship.js +183 -50
  144. package/scripts/hall-preview/server.js +1 -1
  145. package/scripts/migrate.sh +99 -14
  146. package/src/bongos/auth.js +129 -27
  147. package/src/bongos/db-kernel.js +66 -5
  148. package/src/bongos/db.js +6 -7
  149. package/src/bongos/github-repos.js +19 -7
  150. package/src/bongos/goal-scope-check.js +1 -1
  151. package/src/bongos/module-scope-map.js +3 -1
  152. package/src/bongos/permission-path-check.js +1 -1
  153. package/src/bongos/route-rank-check.js +73 -30
  154. package/src/bongos/routes/audit-log.js +4 -2
  155. package/src/bongos/routes/auth.js +8 -4
  156. package/src/bongos/routes/backup.js +7 -4
  157. package/src/bongos/routes/builders.js +23 -11
  158. package/src/bongos/routes/github.js +15 -4
  159. package/src/bongos/routes/llm-cache.js +5 -2
  160. package/src/bongos/routes/modules.js +9 -5
  161. package/src/bongos/routes/security.js +21 -12
  162. package/src/bongos/serve-internal.js +22 -7
  163. package/src/module-api.js +86 -1
  164. package/src/platform-server.js +14 -0
  165. package/tests/api_client.mjs +18 -2
  166. package/tests/api_path_404.mjs +3 -0
  167. package/tests/art_stage.mjs +2 -1
  168. package/tests/atlas.mjs +32 -4
  169. package/tests/auth_admission_gate.mjs +17 -5
  170. package/tests/auth_error_brand.mjs +41 -21
  171. package/tests/auth_resolve_failure.mjs +155 -0
  172. package/tests/blocker_link.mjs +88 -0
  173. package/tests/bongos_downloads.mjs +2 -1
  174. package/tests/box_dns_repoint.mjs +2 -0
  175. package/tests/box_ship_permissions.mjs +2 -0
  176. package/tests/box_terminal_ssrf.mjs +23 -17
  177. package/tests/claim_error_surface.mjs +23 -0
  178. package/tests/claude_materialize.mjs +4 -0
  179. package/tests/cli_box_scope.mjs +40 -0
  180. package/tests/cloud_bongos_pack.mjs +34 -4
  181. package/tests/conductor.mjs +2 -1
  182. package/tests/criterion_authoring_gate.mjs +28 -5
  183. package/tests/currency_label.mjs +10 -1
  184. package/tests/deploy_instance.mjs +2 -0
  185. package/tests/devbox_downloads.mjs +2 -1
  186. package/tests/display_name.mjs +95 -6
  187. package/tests/eviction_policy.mjs +2 -1
  188. package/tests/file_map.mjs +10 -0
  189. package/tests/first_admin_bootstrap.mjs +10 -2
  190. package/tests/first_run_engine_check.mjs +2 -1
  191. package/tests/fitness.mjs +26 -6
  192. package/tests/gate_approvals.mjs +15 -2
  193. package/tests/github_push_land_proof.mjs +50 -4
  194. package/tests/github_repos_helpers.mjs +30 -5
  195. package/tests/github_token_store.mjs +23 -0
  196. package/tests/goal_abuse_matrix.mjs +13 -3
  197. package/tests/goal_cli.mjs +89 -0
  198. package/tests/governance_abuse_matrix.mjs +386 -0
  199. package/tests/governance_fold.mjs +156 -0
  200. package/tests/governance_fold_bench.mjs +50 -0
  201. package/tests/governance_live_proof.mjs +201 -0
  202. package/tests/governance_page_gates.mjs +21 -9
  203. package/tests/governance_parity.mjs +11 -2
  204. package/tests/governance_require_permission.mjs +125 -1
  205. package/tests/governance_routes.mjs +334 -0
  206. package/tests/grader.mjs +7 -3
  207. package/tests/hall_nav.mjs +14 -4
  208. package/tests/helpers.mjs +73 -0
  209. package/tests/hook_core_resolve.mjs +2 -1
  210. package/tests/host_topology_skips.mjs +97 -0
  211. package/tests/infra_exec_paths.mjs +14 -4
  212. package/tests/init.mjs +128 -0
  213. package/tests/known_failing_ratchet.mjs +89 -0
  214. package/tests/lifecycle-card.mjs +2 -1
  215. package/tests/main_audit.mjs +72 -1
  216. package/tests/memory_isolation.mjs +56 -9
  217. package/tests/migrate_root_resolution.mjs +124 -1
  218. package/tests/mirror_redact.mjs +19 -0
  219. package/tests/module_api.mjs +3 -1
  220. package/tests/module_loader.mjs +4 -0
  221. package/tests/modules.mjs +35 -7
  222. package/tests/modules_route.mjs +29 -5
  223. package/tests/onboarding_observability.mjs +49 -11
  224. package/tests/onboarding_route_signals.mjs +1 -0
  225. package/tests/one_active_claim_per_session.mjs +80 -0
  226. package/tests/pre_compact.mjs +2 -1
  227. package/tests/preview_server.mjs +2 -1
  228. package/tests/profile_route.mjs +1 -0
  229. package/tests/provision.mjs +170 -0
  230. package/tests/provisioning_onboard_mode.mjs +180 -0
  231. package/tests/publish_manifest.mjs +16 -3
  232. package/tests/publish_reconciler.mjs +181 -10
  233. package/tests/rank_tier_single_source.mjs +205 -0
  234. package/tests/recall_hints.mjs +2 -1
  235. package/tests/repo_map.mjs +4 -0
  236. package/tests/route_permission_check.mjs +14 -5
  237. package/tests/rubric_fixtures.mjs +3 -0
  238. package/tests/run_routine_two_root.mjs +2 -0
  239. package/tests/sandbox_stage.mjs +69 -7
  240. package/tests/search_ingest.mjs +7 -0
  241. package/tests/security_docs.mjs +28 -6
  242. package/tests/sequence.mjs +64 -0
  243. package/tests/session-cards.mjs +2 -1
  244. package/tests/session_start_freshness.mjs +2 -1
  245. package/tests/ship_broadcast_server.mjs +63 -1
  246. package/tests/ship_card.mjs +66 -0
  247. package/tests/ship_ci_deploy.mjs +66 -1
  248. package/tests/ship_smoke_gate.mjs +76 -0
  249. package/tests/versions_route.mjs +15 -0
  250. package/tests/workflow_exec_paths.mjs +16 -4
  251. package/tests/worktree_guard.mjs +2 -1
@@ -116,10 +116,112 @@ async function syncRankRole(client, { builderId, rank } = {}) {
116
116
  return { target: plan.target, added: [...plan.add], removed: [...plan.remove] };
117
117
  }
118
118
 
119
+ // --- the R110 CRUD surface (roles / grants / assignments) --------------------
120
+ //
121
+ // The db half of the governance API routes. Deliberately DUMB writes: the
122
+ // authority invariants (system perms non-grantable, seeded rank-roles immutable
123
+ // through the API) are enforced in routes/governance.js against the catalog —
124
+ // the same split governance_001 documents (permission_key is validated app-side,
125
+ // not a FK). Grant/assign are ON CONFLICT DO NOTHING so the tab's toggle is
126
+ // idempotent; each returns whether a row actually changed.
127
+
128
+ async function listRolesWithGrants() {
129
+ const { rows } = await pool.query(
130
+ `SELECT r.role_key, r.label, r.description, r.is_system, r.seeded_rank,
131
+ r.created_by, r.created_at,
132
+ COALESCE(array_agg(rp.permission_key ORDER BY rp.permission_key)
133
+ FILTER (WHERE rp.permission_key IS NOT NULL), '{}') AS permissions
134
+ FROM governance_roles r
135
+ LEFT JOIN governance_role_permissions rp ON rp.role_key = r.role_key
136
+ GROUP BY r.id
137
+ ORDER BY r.is_system DESC, r.role_key`,
138
+ );
139
+ return rows;
140
+ }
141
+
142
+ async function getRoleByKey(roleKey) {
143
+ const { rows } = await pool.query(
144
+ `SELECT role_key, label, description, is_system, seeded_rank, created_by, created_at
145
+ FROM governance_roles
146
+ WHERE role_key = $1`,
147
+ [roleKey],
148
+ );
149
+ return rows[0] ?? null;
150
+ }
151
+
152
+ async function createRole({ roleKey, label, description, createdBy }) {
153
+ const { rows } = await pool.query(
154
+ `INSERT INTO governance_roles (role_key, label, description, is_system, seeded_rank, created_by)
155
+ VALUES ($1, $2, $3, false, NULL, $4)
156
+ RETURNING role_key, label, description, is_system, seeded_rank, created_by, created_at`,
157
+ [roleKey, label, description ?? null, createdBy],
158
+ );
159
+ return rows[0];
160
+ }
161
+
162
+ async function grantRolePermission({ roleKey, permissionKey }) {
163
+ const { rowCount } = await pool.query(
164
+ `INSERT INTO governance_role_permissions (role_key, permission_key)
165
+ VALUES ($1, $2)
166
+ ON CONFLICT (role_key, permission_key) DO NOTHING`,
167
+ [roleKey, permissionKey],
168
+ );
169
+ return rowCount > 0;
170
+ }
171
+
172
+ async function revokeRolePermission({ roleKey, permissionKey }) {
173
+ const { rowCount } = await pool.query(
174
+ `DELETE FROM governance_role_permissions
175
+ WHERE role_key = $1 AND permission_key = $2`,
176
+ [roleKey, permissionKey],
177
+ );
178
+ return rowCount > 0;
179
+ }
180
+
181
+ async function listAssignments() {
182
+ const { rows } = await pool.query(
183
+ `SELECT br.builder_id, b.github_login, b.display_name, b.rank,
184
+ br.role_key, r.label AS role_label, r.is_system, r.seeded_rank,
185
+ br.assigned_by, br.assigned_at
186
+ FROM governance_builder_roles br
187
+ JOIN builders b ON b.id = br.builder_id
188
+ JOIN governance_roles r ON r.role_key = br.role_key
189
+ ORDER BY br.builder_id, br.role_key`,
190
+ );
191
+ return rows;
192
+ }
193
+
194
+ async function assignRole({ builderId, roleKey, assignedBy }) {
195
+ const { rowCount } = await pool.query(
196
+ `INSERT INTO governance_builder_roles (builder_id, role_key, assigned_by)
197
+ VALUES ($1, $2, $3)
198
+ ON CONFLICT (builder_id, role_key) DO NOTHING`,
199
+ [builderId, roleKey, assignedBy],
200
+ );
201
+ return rowCount > 0;
202
+ }
203
+
204
+ async function unassignRole({ builderId, roleKey }) {
205
+ const { rowCount } = await pool.query(
206
+ `DELETE FROM governance_builder_roles
207
+ WHERE builder_id = $1 AND role_key = $2`,
208
+ [builderId, roleKey],
209
+ );
210
+ return rowCount > 0;
211
+ }
212
+
119
213
  module.exports = {
120
214
  getPermissionKeysForBuilder,
121
215
  getRoleKeysForBuilder,
122
216
  getSeededRankRoles,
123
217
  getRoleAssignmentsForBuilder,
124
218
  syncRankRole,
219
+ listRolesWithGrants,
220
+ getRoleByKey,
221
+ createRole,
222
+ grantRolePermission,
223
+ revokeRolePermission,
224
+ listAssignments,
225
+ assignRole,
226
+ unassignRole,
125
227
  };
@@ -0,0 +1,38 @@
1
+ -- governance_006_version_create.sql — grant the new `version.create` atom to the
2
+ -- Archon rank-role (BV1.R105; task 1001847).
3
+ --
4
+ -- `POST /versions` was the ONE rank-gated module write route the R105 migration could
5
+ -- not finish: no catalog atom named version CREATION. `version.close` is a different
6
+ -- capability (the close disposition), so the route stayed on requireRank while the
7
+ -- other 60 moved. catalog.js now carries `version.create`, floor `archon`,
8
+ -- system:false — see the comment beside the row for why it is NOT system:true.
9
+ --
10
+ -- Same shape as governance_004 (a BRAND-NEW key, not a floor move): no role holds it
11
+ -- yet. But floor `archon` is the TOP rung, so the cumulative superset RANK_ROLE_SEED
12
+ -- builds contains it for exactly ONE role — hence a single grant block here, where 004
13
+ -- needed two.
14
+ --
15
+ -- THIS MIGRATION IS LOAD-BEARING, not bookkeeping. The seed (governance_002) is
16
+ -- run-once, so a database that has already migrated would never learn the new key:
17
+ -- the archon rank-role would not hold `version.create`, and the freshly-migrated route
18
+ -- would 403 every Archon — locking the instance out of creating versions. The grant
19
+ -- has to arrive as its own migration for the change to be behaviour-identical in
20
+ -- practice and not just on a fresh install.
21
+ --
22
+ -- Additive only: one new permission key granted to one role; nothing is revoked.
23
+ -- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
24
+ --
25
+ -- One block PER ROLE, in the governance_003/004 shape: the drift guard
26
+ -- (tests/governance_seed.mjs) scans for `SELECT '<role>', unnest(ARRAY[…])` literally,
27
+ -- so a combined `unnest(ARRAY['metic','archon']), 'key'` form parses as zero grants
28
+ -- and reds it.
29
+
30
+ BEGIN;
31
+
32
+ INSERT INTO governance_role_permissions (role_key, permission_key)
33
+ SELECT 'archon', unnest(ARRAY[
34
+ 'version.create'
35
+ ])
36
+ ON CONFLICT (role_key, permission_key) DO NOTHING;
37
+
38
+ COMMIT;
@@ -0,0 +1,72 @@
1
+ -- governance_007_delegate_archon_ops_to_metic.sql — grant the Metic rank-role the
2
+ -- twenty permissions ADR 0157 lowered archon→metic, plus the new `gate_approval.read`
3
+ -- (task 1002584).
4
+ --
5
+ -- Owner decision (2026-08-05): the Archon rank is responsible for RANK and IDENTITY
6
+ -- only. `catalog.js` moved twenty operational floors archon→metic to match, and split
7
+ -- `gate_approval.read` out of `gate_approval.decide` so the Gate page can open to Metic
8
+ -- while approving a held PR stays Archon.
9
+ --
10
+ -- The compat seed (governance_002) is RUN-ONCE, so a floor change cannot be made by
11
+ -- editing it — every already-migrated database would keep the old grant. This is the
12
+ -- follow-on grant migration the 002 header calls for, and it follows
13
+ -- governance_003_task_abandon_metic.sql (the ADR 0152 worked example) exactly.
14
+ --
15
+ -- Additive only: `archon` already holds all twenty (the seeded roles are cumulative
16
+ -- supersets), so lowering each floor adds exactly one grant — metic — and revokes
17
+ -- nothing. `gate_approval.read` is genuinely new, so it is granted to BOTH metic and
18
+ -- archon to keep archon ⊇ metic. Idempotent (ON CONFLICT DO NOTHING); forward-safe
19
+ -- (INSERT only, no dropped or retyped state).
20
+ --
21
+ -- The drift guard (tests/governance_seed.mjs) reads the UNION of the seed and every
22
+ -- later governance_* grant migration, so this file is what keeps the SQL snapshot equal
23
+ -- to catalog.js's floor-derived RANK_ROLE_SEED.
24
+ --
25
+ -- NOT here, deliberately: `version.close` and the four escalation keys
26
+ -- (`task.confirm.grade_bypass`, `gate_approval.decide`, `goal.scope.manage`,
27
+ -- `governance.manage`) stay Archon — see ADR 0157 for why moving any one of them would
28
+ -- make this delegation self-reversible. `dependency.cross_tier.manage` also stays: it is
29
+ -- system:true, and granting a system key to metic violates the catalog's own invariant.
30
+
31
+ BEGIN;
32
+
33
+ INSERT INTO governance_role_permissions (role_key, permission_key)
34
+ SELECT 'metic', unnest(ARRAY[
35
+ -- admin ops (spend, adjudicate, operate) — none decides who holds authority
36
+ 'builder.budget.set',
37
+ 'builder.roster.read',
38
+ 'security.report.adjudicate',
39
+ 'provisioning.fleet.manage',
40
+ 'box.fleet.manage',
41
+ 'autonomy.precheck',
42
+ 'discord.channels.manage',
43
+ 'project.curate',
44
+ -- work disposition
45
+ 'criterion.unsatisfy',
46
+ 'version.create',
47
+ 'override_request.decide',
48
+ 'claim.release.any',
49
+ 'idea.ratify',
50
+ 'task.newcomer_restock',
51
+ 'task.peer_votes.tally',
52
+ 'goal.reorder',
53
+ -- reads
54
+ 'session.search',
55
+ 'memory.read.any',
56
+ 'audit_log.read',
57
+ -- surfaces
58
+ 'page.view.governance',
59
+ 'gate_approval.read'
60
+ ])
61
+ ON CONFLICT (role_key, permission_key) DO NOTHING;
62
+
63
+ -- `gate_approval.read` is a NEW catalog key, so archon does not already hold it from
64
+ -- governance_002. Granted here to preserve the cumulative-superset invariant
65
+ -- (tests/governance_catalog.mjs: archon ⊇ metic ⊇ thetes ⊇ xenos).
66
+ INSERT INTO governance_role_permissions (role_key, permission_key)
67
+ SELECT 'archon', unnest(ARRAY[
68
+ 'gate_approval.read'
69
+ ])
70
+ ON CONFLICT (role_key, permission_key) DO NOTHING;
71
+
72
+ COMMIT;
@@ -14,8 +14,8 @@
14
14
  // rebuilds the GDS router, so registering here keeps the port present for the
15
15
  // route-building tests too. Registration is hasProvider-guarded so
16
16
  // re-instantiation never trips the seam's single-provider duplicate guard.
17
- // (Mirrors modules/economy/routes/reward.js.) The governance CRUD endpoints land
18
- // on this same router in R110.
17
+ // (Mirrors modules/economy/routes/reward.js.) The R110 governance CRUD
18
+ // endpoints live on this same router, below.
19
19
 
20
20
  const express = require('express');
21
21
  const api = require('../../../src/module-api');
@@ -52,6 +52,11 @@ const sessionScopes = require('../session-scopes');
52
52
  // session's real authority rather than the builder's.
53
53
  const governancePort = {
54
54
  resolveBuilderPermissions: resolver.resolveBuilderPermissions,
55
+ // The pure catalog-drop half, for the session-load fold (task 1002556): the
56
+ // kernel feeds it the raw grant keys its own session query carried, so a
57
+ // permission gate needs no second round-trip. Same answer as
58
+ // resolveBuilderPermissions given the same rows — DB-free by construction.
59
+ effectivePermissions: resolver.effectivePermissions,
55
60
  builderHasPermissions: resolver.builderHasPermissions,
56
61
  getRoleKeysForBuilder: db.getRoleKeysForBuilder,
57
62
  isSystem: catalog.isSystem,
@@ -76,14 +81,238 @@ function registerSeams() {
76
81
  if (!api.hasProvider('governance')) {
77
82
  api.registerProvider('governance', governancePort);
78
83
  }
84
+ // The session-load fold (task 1002556): the kernel's lookupSession embeds this
85
+ // fragment so the SAME round-trip carries the builder's raw grant keys, and
86
+ // requirePermission runs the pure effectivePermissions instead of a second
87
+ // query. Registered HERE — capability-named, module-agnostic on the kernel
88
+ // side (ADR 0083 §Decision #4) — because this module owns the governance SQL.
89
+ // Contract: a boot-time CONSTANT (never user input) yielding one
90
+ // `raw_permission_keys` column against the kernel query's alias `s`
91
+ // (builder_sessions). Raw keys only — the catalog-drop stays in the resolver.
92
+ if (!api.hasProvider('session-grants-sql')) {
93
+ api.registerProvider('session-grants-sql', {
94
+ selectFragment: `,
95
+ (SELECT COALESCE(array_agg(DISTINCT rp.permission_key), '{}')
96
+ FROM governance_builder_roles br
97
+ JOIN governance_role_permissions rp ON rp.role_key = br.role_key
98
+ WHERE br.builder_id = s.builder_id) AS raw_permission_keys`,
99
+ });
100
+ }
79
101
  }
80
102
 
103
+ // ── the R110 CRUD surface (ADR 0151 §2 — the governance tab's server half) ────
104
+ //
105
+ // Every route — the three reads included — gates on the `governance.manage`
106
+ // atom (system, floor archon): this surface EDITS the role/permission graph
107
+ // itself, the meta-escalation the catalog flags non-grantable, and even listing
108
+ // it exposes the full authority map. Two invariants are enforced HERE, against
109
+ // the catalog, not in the db layer (governance_001's app-side-validation split):
110
+ // • a `system:true` permission is NON-GRANTABLE to a custom role (R94's wall —
111
+ // "The governance API (R110) rejects granting it");
112
+ // • the four seeded rank-roles are IMMUTABLE through this API: their grants
113
+ // derive from catalog floors (a change is a migration — R94) and their
114
+ // assignment derives from builders.rank via the R95b sync. A second writer
115
+ // for either would fork authority truth.
116
+ const { validateOrRespond, LIMITS, parseId } = api;
117
+ const ROLE_KEY_RE = /^[a-z][a-z0-9-]*$/;
118
+ const ROLE_KEY_MAX = 64;
119
+
81
120
  module.exports = function buildGovernanceRouter() {
82
121
  registerSeams();
83
- // No HTTP surface yet — the resolver is a pure kernel port. The empty router is
84
- // only the loader's contributes.routes mount vehicle; R110 adds the roles /
85
- // permissions / assignments CRUD here behind `governance.manage`.
86
- return express.Router();
122
+ const router = express.Router();
123
+
124
+ // Param guard for :roleKey. Responds bad_role_key and returns null on a
125
+ // malformed slug (seeded keys like 'metic' pass shape and are refused later,
126
+ // by policy, with the honest code).
127
+ function roleKeyParam(req, res) {
128
+ const roleKey = req.params.roleKey;
129
+ if (roleKey.length > ROLE_KEY_MAX || !ROLE_KEY_RE.test(roleKey)) {
130
+ res.fail('bad_role_key', 400, { role_key: roleKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
131
+ return null;
132
+ }
133
+ return roleKey;
134
+ }
135
+
136
+ // Load a role that this API may MUTATE: 404 on a missing role, refuse a seeded
137
+ // rank-role with the caller's `immutableCode`. Returns null after responding.
138
+ async function loadMutableRole(res, roleKey, immutableCode, reason) {
139
+ const role = await db.getRoleByKey(roleKey);
140
+ if (!role) {
141
+ res.fail('role_not_found', 404, { role_key: roleKey });
142
+ return null;
143
+ }
144
+ if (role.is_system) {
145
+ res.fail(immutableCode, 403, { role_key: roleKey, reason });
146
+ return null;
147
+ }
148
+ return role;
149
+ }
150
+ const GRANTS_REASON = 'a seeded rank-role\'s grants derive from catalog floors; changing one is a migration (R94), never an API write';
151
+ const ASSIGN_REASON = 'a seeded rank-role is assigned by rank (R95b sync); change the builder\'s rank instead';
152
+
153
+ // rank: perm governance.manage — the full role list with each role's grants.
154
+ router.get('/governance/roles', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
155
+ try {
156
+ const roles = await db.listRolesWithGrants();
157
+ res.json({ roles });
158
+ } catch (err) {
159
+ console.error('[gds] GET /governance/roles', err);
160
+ res.fail('roles_list_failed', { status: 500, message: 'internal error' });
161
+ }
162
+ });
163
+
164
+ // rank: perm governance.manage — the permission catalog + principals, straight
165
+ // from code (the catalog is CODE, not a table); no DB read. `grantable` is the
166
+ // flag the tab renders LOCKED from; principals are read-only by construction.
167
+ router.get('/governance/permissions', api.requireBuilder, api.requirePermission('governance.manage'), (req, res) => {
168
+ const permissions = catalog.PERMISSIONS.map((p) => ({
169
+ key: p.key,
170
+ system: p.system,
171
+ floor: p.floor,
172
+ guards: p.guards,
173
+ scope: p.scope,
174
+ resource: p.resource,
175
+ grantable: catalog.isGrantable(p.key),
176
+ }));
177
+ res.json({ permissions, principals: catalog.principals() });
178
+ });
179
+
180
+ // rank: perm governance.manage — every builder↔role assignment row.
181
+ router.get('/governance/assignments', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
182
+ try {
183
+ const assignments = await db.listAssignments();
184
+ res.json({ assignments });
185
+ } catch (err) {
186
+ console.error('[gds] GET /governance/assignments', err);
187
+ res.fail('assignments_list_failed', { status: 500, message: 'internal error' });
188
+ }
189
+ });
190
+
191
+ // rank: perm governance.manage — create a CUSTOM role (is_system false, no
192
+ // seeded_rank — the seeded four exist only via governance_002). Grants are
193
+ // added one at a time via the toggle below, so creation carries none.
194
+ router.post('/governance/roles', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
195
+ if (validateOrRespond(req, res, {
196
+ role_key: { required: true, type: 'string', minLength: 2, maxLength: ROLE_KEY_MAX },
197
+ label: { required: true, type: 'string', minLength: 1, maxLength: LIMITS.TITLE },
198
+ description: { type: 'string', maxLength: 2000 },
199
+ })) return;
200
+ const roleKey = req.body.role_key;
201
+ if (!ROLE_KEY_RE.test(roleKey)) {
202
+ return res.fail('bad_role_key', 400, { role_key: roleKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
203
+ }
204
+ try {
205
+ const role = await db.createRole({
206
+ roleKey,
207
+ label: req.body.label,
208
+ description: req.body.description ?? null,
209
+ createdBy: req.builder.id,
210
+ });
211
+ res.status(201).json({ role: { ...role, permissions: [] } });
212
+ } catch (err) {
213
+ if (err && err.code === '23505') {
214
+ return res.fail('role_exists', 409, { role_key: roleKey });
215
+ }
216
+ console.error('[gds] POST /governance/roles', err);
217
+ res.fail('role_create_failed', { status: 500, message: 'internal error' });
218
+ }
219
+ });
220
+
221
+ // rank: perm governance.manage — grant one OPERATIONAL permission to a custom
222
+ // role (the toggle's ON half). The order of refusals is the authority order:
223
+ // role exists → role is custom → the key exists → the key is grantable.
224
+ router.put('/governance/roles/:roleKey/permissions/:permissionKey', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
225
+ const roleKey = roleKeyParam(req, res);
226
+ if (roleKey === null) return;
227
+ const permissionKey = req.params.permissionKey;
228
+ try {
229
+ const role = await loadMutableRole(res, roleKey, 'seeded_role_immutable', GRANTS_REASON);
230
+ if (role === null) return;
231
+ if (!catalog.byKey(permissionKey)) {
232
+ return res.fail('unknown_permission', 400, { permission_key: permissionKey });
233
+ }
234
+ if (!catalog.isGrantable(permissionKey)) {
235
+ return res.fail('permission_not_grantable', 400, {
236
+ permission_key: permissionKey,
237
+ reason: 'system (trust-boundary) permissions are non-grantable to a custom role (ADR 0151)',
238
+ });
239
+ }
240
+ const granted = await db.grantRolePermission({ roleKey, permissionKey });
241
+ res.json({ ok: true, role_key: roleKey, permission_key: permissionKey, granted });
242
+ } catch (err) {
243
+ console.error('[gds] PUT /governance/roles/:roleKey/permissions/:permissionKey', err);
244
+ res.fail('grant_failed', { status: 500, message: 'internal error' });
245
+ }
246
+ });
247
+
248
+ // rank: perm governance.manage — revoke a grant (the toggle's OFF half).
249
+ // Deliberately NOT validated against the catalog: a grant whose key the
250
+ // catalog since retired is exactly the row an Archon most needs to remove
251
+ // (the resolver already drops it — this is hygiene, not authority).
252
+ router.delete('/governance/roles/:roleKey/permissions/:permissionKey', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
253
+ const roleKey = roleKeyParam(req, res);
254
+ if (roleKey === null) return;
255
+ const permissionKey = req.params.permissionKey;
256
+ try {
257
+ const role = await loadMutableRole(res, roleKey, 'seeded_role_immutable', GRANTS_REASON);
258
+ if (role === null) return;
259
+ const removed = await db.revokeRolePermission({ roleKey, permissionKey });
260
+ res.json({ ok: true, role_key: roleKey, permission_key: permissionKey, removed });
261
+ } catch (err) {
262
+ console.error('[gds] DELETE /governance/roles/:roleKey/permissions/:permissionKey', err);
263
+ res.fail('revoke_failed', { status: 500, message: 'internal error' });
264
+ }
265
+ });
266
+
267
+ // rank: perm governance.manage — assign a CUSTOM role to a builder.
268
+ // assigned_by is ALWAYS the session builder, never input (the audit trail's
269
+ // "who did this" must be the authenticated actor).
270
+ router.post('/governance/assignments', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
271
+ if (validateOrRespond(req, res, {
272
+ builder_id: { required: true, type: 'integer', min: 1 },
273
+ role_key: { required: true, type: 'string', minLength: 2, maxLength: ROLE_KEY_MAX },
274
+ })) return;
275
+ const roleKey = req.body.role_key;
276
+ if (!ROLE_KEY_RE.test(roleKey)) {
277
+ return res.fail('bad_role_key', 400, { role_key: roleKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
278
+ }
279
+ try {
280
+ const role = await loadMutableRole(res, roleKey, 'seeded_role_assignment_forbidden', ASSIGN_REASON);
281
+ if (role === null) return;
282
+ const assigned = await db.assignRole({
283
+ builderId: req.body.builder_id,
284
+ roleKey,
285
+ assignedBy: req.builder.id,
286
+ });
287
+ res.status(assigned ? 201 : 200).json({ ok: true, builder_id: req.body.builder_id, role_key: roleKey, assigned });
288
+ } catch (err) {
289
+ if (err && err.code === '23503') {
290
+ return res.fail('builder_not_found', 404, { builder_id: req.body.builder_id });
291
+ }
292
+ console.error('[gds] POST /governance/assignments', err);
293
+ res.fail('assign_failed', { status: 500, message: 'internal error' });
294
+ }
295
+ });
296
+
297
+ // rank: perm governance.manage — remove a custom-role assignment. Idempotent:
298
+ // `removed` says whether a row went away.
299
+ router.delete('/governance/assignments/:builderId/:roleKey', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
300
+ const builderId = parseId(req, res, { param: 'builderId', code: 'bad_builder_id', positiveInt: true });
301
+ if (builderId === null) return;
302
+ const roleKey = roleKeyParam(req, res);
303
+ if (roleKey === null) return;
304
+ try {
305
+ const role = await loadMutableRole(res, roleKey, 'seeded_role_assignment_forbidden', ASSIGN_REASON);
306
+ if (role === null) return;
307
+ const removed = await db.unassignRole({ builderId, roleKey });
308
+ res.json({ ok: true, builder_id: builderId, role_key: roleKey, removed });
309
+ } catch (err) {
310
+ console.error('[gds] DELETE /governance/assignments/:builderId/:roleKey', err);
311
+ res.fail('unassign_failed', { status: 500, message: 'internal error' });
312
+ }
313
+ });
314
+
315
+ return router;
87
316
  };
88
317
 
89
318
  module.exports.governancePort = governancePort;
@@ -47,18 +47,27 @@ const TRANSIENT_WORKER_ERRORS = new Set([
47
47
 
48
48
  // #467: per-worker subagent timeout. The global 240s applied to EVERY worker,
49
49
  // which timed out the Read-heavy ones (Narc walks ACs + reads files — hence
50
- // Phase 2.0.5's 5-read workaround; Quality is next-heaviest) while the light
51
- // ones (Hacker, Efficiency) never needed that long. Per-worker budgets let the
52
- // heavy lenses breathe without globally inflating every ship's worst-case wall
53
- // time. A worker module may override by exporting its own MAX_TIMEOUT_MS;
54
- // otherwise this map (keyed by KIND) is the single tuning surface. Falls back
55
- // to runOpts.timeoutMs (then runSubagent's own 240s default) for any unlisted
56
- // worker. Tune from the calibration data (#292) once it accrues.
50
+ // Phase 2.0.5's 5-read workaround; Quality is next-heaviest). Per-worker budgets
51
+ // let the heavy lenses breathe without globally inflating every ship's
52
+ // worst-case wall time. A worker module may override by exporting its own
53
+ // MAX_TIMEOUT_MS; otherwise this map (keyed by KIND) is the single tuning
54
+ // surface. Falls back to runOpts.timeoutMs (then runSubagent's own 240s default)
55
+ // for any unlisted worker.
56
+ //
57
+ // task 1002580 — calibration data (#292): the earlier assumption that Hacker and
58
+ // Efficiency are "light" INVERTS when the diff is itself security- or
59
+ // performance-heavy code (a security module, a hot path). On task 1002544's
60
+ // ~92k-char security-module diff the Hacker worker hit SUBAGENT_TIMEOUT at 240s
61
+ // three runs straight while Narc/Quality completed at 360s — and because a
62
+ // gating-worker timeout HARD-BLOCKS (security is non-optional), a fully-passing
63
+ // 9/10 feature could not land. All four gating/advisory workers now share the
64
+ // 360s heavy-tier budget. Worst-case wall time is unchanged: workers run in
65
+ // parallel, so the panel's wall time was already max()=360s from Narc/Quality.
57
66
  const WORKER_MAX_TIMEOUT_MS = {
58
67
  narc: 360_000, // Read-heavy: walks every acceptance criterion
59
68
  quality: 360_000, // Read-heavy: reads changed files for cleanliness review
60
- hacker: 240_000, // lighter: focused security scan
61
- efficiency: 240_000, // lighter: focused performance scan
69
+ hacker: 360_000, // Read-heavy on a security diff: exploit-surface scan of every changed file
70
+ efficiency: 360_000, // Read-heavy on a large diff: traces perf across every changed file
62
71
  };
63
72
 
64
73
  // Resolve the timeout for a worker: explicit module override > KIND map >
@@ -10,7 +10,7 @@
10
10
  // Like The Watch + The Harbor, this reads /api/bongos/me to show a Sealed notice for
11
11
  // non-Archons — DEFENCE IN DEPTH only. GET /gate-approvals + POST
12
12
  // /gate-approvals/:pr/approve are ALSO archon-gated server-side (ADR 0016), and
13
- // the page itself is gated by server.js requireArchonPage. On 401 → web sign-in.
13
+ // the page itself is gated by server.js requireGovernancePage. On 401 → web sign-in.
14
14
 
15
15
  (() => {
16
16
  'use strict';
@@ -217,7 +217,11 @@
217
217
  me = null;
218
218
  }
219
219
  const rank = (me && (me.builder || me).rank || '').toLowerCase();
220
- if (rank !== 'archon') { showSealed(); return; }
220
+ // ADR 0157: the Gate page sits at the `page.view.governance` floor (metic). The
221
+ // server's requireGovernancePage is the wall — this only decides whether to render the
222
+ // body or the sealed notice, so it must agree or a Metic sees "sealed" on a page
223
+ // they were just admitted to.
224
+ if (rank !== 'metic' && rank !== 'archon') { showSealed(); return; }
221
225
 
222
226
  document.getElementById('gate-sub').textContent = 'Changes touching protected build surfaces, held for your approval.';
223
227
  document.getElementById('gate-body').hidden = false;
@@ -224,7 +224,7 @@
224
224
 
225
225
  // task 1002164 — confirming a criterion is Metic+ (POST /done-when/:id/satisfy,
226
226
  // ADR 0086 §6). Client-side this only decides whether to SHOW the control; the
227
- // server's requireRank('metic','archon') is the real gate.
227
+ // server's requirePermission('criterion.satisfy') is the real gate (BV1.R105).
228
228
  const RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
229
229
  const canConfirmCriteria = () =>
230
230
  (RANK_ORDER[(state.me?.builder?.rank || '').toLowerCase()] ?? 0) >= RANK_ORDER.metic;
@@ -1091,7 +1091,11 @@
1091
1091
  } catch (err) {
1092
1092
  if (err && err.message === 'redirecting to auth') return;
1093
1093
  const code = err && err.data && err.data.error;
1094
- const reason = (code && (code.code || code)) === 'rank_forbidden'
1094
+ // POST /done-when/:id/satisfy gates on criterion.satisfy since BV1.R105, so the
1095
+ // denial code is now 'permission_forbidden'; 'rank_forbidden' is kept for an
1096
+ // instance still on a pre-R105 core (the doorway is versioned, ADR 0083).
1097
+ const denial = code && (code.code || code);
1098
+ const reason = (denial === 'permission_forbidden' || denial === 'rank_forbidden')
1095
1099
  ? 'Confirming a criterion needs Metic rank or higher.'
1096
1100
  : `Could not confirm that criterion (${(err && err.status) || 'error'}).`;
1097
1101
  toast(reason);