@bongos/core 1.18.19 → 1.19.3

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 (259) hide show
  1. package/.bongos-core.json +547 -197
  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/bin/bongos.js +33 -7
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +63 -41
  11. package/clients/bongos-client/index.cjs +63 -41
  12. package/clients/bongos-client/index.d.ts +78 -44
  13. package/clients/bongos-client/index.mjs +63 -41
  14. package/docs/adr/0020-security-model-public-stub.md +1 -1
  15. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  16. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
  17. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  18. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  19. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  20. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  21. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  22. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  23. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  24. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  25. package/docs/adr/0161-publish-on-merge.md +34 -0
  26. package/docs/adr/README.md +9 -0
  27. package/docs/api/openapi.json +1209 -233
  28. package/docs/api-reference.md +61 -46
  29. package/docs/architecture.md +1 -1
  30. package/docs/canonical-permissions.md +61 -16
  31. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  32. package/docs/file-map.md +4 -1
  33. package/docs/onboarding/diagrams/assertions.json +3 -3
  34. package/docs/recipes/core-release-pipeline.md +181 -0
  35. package/docs/recipes/packaging-the-core.md +2 -0
  36. package/docs/recipes/parallel-goal-waves.md +1 -3
  37. package/docs/recipes/private-npm-distribution.md +6 -2
  38. package/docs/recipes/upgrading-the-core.md +2 -0
  39. package/migrations/core_197_display_name_source.sql +56 -0
  40. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  41. package/migrations/core_205_versions_track_default.sql +28 -0
  42. package/modules/autonomy/routes/autonomy.js +1 -1
  43. package/modules/builder-settings/db.js +7 -1
  44. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  45. package/modules/dev-box/routes/box.js +11 -9
  46. package/modules/discord/auth.js +3 -1
  47. package/modules/discord/routes/discord.js +8 -2
  48. package/modules/discord/ship-broadcast.js +48 -1
  49. package/modules/governance/catalog.js +70 -20
  50. package/modules/governance/db.js +102 -0
  51. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  52. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  53. package/modules/governance/routes/governance.js +235 -6
  54. package/modules/grading/grader-workers/index.js +18 -9
  55. package/modules/hall-ui/public/gate.js +6 -2
  56. package/modules/hall-ui/public/goals-page.js +6 -2
  57. package/modules/hall-ui/public/governance.css +100 -0
  58. package/modules/hall-ui/public/governance.html +77 -0
  59. package/modules/hall-ui/public/governance.js +353 -0
  60. package/modules/hall-ui/public/harbor.js +14 -8
  61. package/modules/hall-ui/public/instances-panel.js +3 -1
  62. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  63. package/modules/hall-ui/public/people.html +51 -0
  64. package/modules/hall-ui/public/people.js +100 -0
  65. package/modules/hall-ui/public/profile.html +2 -1
  66. package/modules/hall-ui/public/profile.js +9 -3
  67. package/modules/hall-ui/public/sessions.js +4 -2
  68. package/modules/hall-ui/public/shell.js +28 -7
  69. package/modules/hall-ui/public/task-detail.js +6 -3
  70. package/modules/hall-ui/public/watch.js +17 -9
  71. package/modules/ideas/blockers.js +44 -7
  72. package/modules/ideas/routes/blockers.js +12 -7
  73. package/modules/ideas/routes/inbox.js +2 -2
  74. package/modules/lifecycle/db.js +84 -17
  75. package/modules/lifecycle/github-push.js +37 -10
  76. package/modules/lifecycle/publish-reconciler.js +128 -31
  77. package/modules/lifecycle/routes/claims.js +10 -4
  78. package/modules/lifecycle/routes/dependencies.js +2 -2
  79. package/modules/lifecycle/routes/done-when.js +4 -4
  80. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  81. package/modules/lifecycle/routes/goals.js +5 -5
  82. package/modules/lifecycle/routes/tasks.js +99 -69
  83. package/modules/lifecycle/routes/versions.js +13 -2
  84. package/modules/lifecycle/ship-card.js +38 -7
  85. package/modules/memory/routes/memory.js +2 -2
  86. package/modules/onboarding/routes/access-requests.js +3 -3
  87. package/modules/platform-identity/routes/projects.js +10 -6
  88. package/modules/platform-identity/routes/scouting.js +2 -2
  89. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  90. package/modules/provisioning/provisioning.js +24 -6
  91. package/modules/provisioning/routes/provisioning.js +24 -6
  92. package/modules/security/artifact-scan/aggregate.js +133 -0
  93. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  94. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  95. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  96. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  97. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  98. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  99. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  100. package/modules/security/artifact-scan/detectors/network.js +67 -0
  101. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  102. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  103. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  104. package/modules/security/artifact-scan/fence-local.js +99 -0
  105. package/modules/security/artifact-scan/fetch.js +330 -0
  106. package/modules/security/artifact-scan/floor.js +319 -0
  107. package/modules/security/artifact-scan/limits.js +66 -0
  108. package/modules/security/artifact-scan/manifest.js +203 -0
  109. package/modules/security/artifact-scan/mirror.js +158 -0
  110. package/modules/security/artifact-scan/quarantine.js +149 -0
  111. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  112. package/modules/security/artifact-scan/report.js +155 -0
  113. package/modules/security/artifact-scan/sanitize.js +317 -0
  114. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  115. package/modules/security/module.json +2 -2
  116. package/modules/security/routes/reports.js +11 -7
  117. package/modules/security/routes/scans.js +115 -0
  118. package/modules/security/scan-store.js +214 -0
  119. package/modules/security/tests/aggregate.mjs +283 -0
  120. package/modules/security/tests/artifact_scan.mjs +591 -0
  121. package/modules/security/tests/floor.mjs +549 -0
  122. package/modules/security/tests/mirror.mjs +350 -0
  123. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  124. package/modules/security/tests/scan_store.mjs +250 -0
  125. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  126. package/modules/sessions/routes/sessions.js +6 -5
  127. package/package-lock.json +2 -2
  128. package/package.json +1 -1
  129. package/public-landing/create.html +13 -3
  130. package/scripts/gds/abandon.js +1 -1
  131. package/scripts/gds/audit-authorship.sh +6 -0
  132. package/scripts/gds/box.js +1 -1
  133. package/scripts/gds/bump-version.js +76 -0
  134. package/scripts/gds/claim.js +15 -0
  135. package/scripts/gds/cli-lib.js +16 -4
  136. package/scripts/gds/gating.js +28 -13
  137. package/scripts/gds/gen-api-docs.js +1 -1
  138. package/scripts/gds/goal.js +234 -0
  139. package/scripts/gds/init.js +77 -15
  140. package/scripts/gds/main-audit.js +80 -11
  141. package/scripts/gds/mirror-redact.js +6 -0
  142. package/scripts/gds/newcomer-floor.js +2 -2
  143. package/scripts/gds/provision.js +230 -94
  144. package/scripts/gds/run-unit-tests.js +9 -1
  145. package/scripts/gds/sandbox-stage.js +66 -9
  146. package/scripts/gds/search-ingest-md.js +1 -0
  147. package/scripts/gds/sequence.js +67 -8
  148. package/scripts/gds/ship.js +183 -50
  149. package/scripts/hall-preview/server.js +1 -1
  150. package/scripts/migrate.sh +5 -5
  151. package/src/bongos/auth.js +129 -27
  152. package/src/bongos/db-kernel.js +66 -5
  153. package/src/bongos/db.js +6 -7
  154. package/src/bongos/github-repos.js +19 -7
  155. package/src/bongos/goal-scope-check.js +1 -1
  156. package/src/bongos/module-scope-map.js +3 -1
  157. package/src/bongos/permission-path-check.js +1 -1
  158. package/src/bongos/route-rank-check.js +73 -30
  159. package/src/bongos/routes/audit-log.js +4 -2
  160. package/src/bongos/routes/auth.js +8 -4
  161. package/src/bongos/routes/backup.js +7 -4
  162. package/src/bongos/routes/builders.js +23 -11
  163. package/src/bongos/routes/github.js +15 -4
  164. package/src/bongos/routes/llm-cache.js +5 -2
  165. package/src/bongos/routes/modules.js +9 -5
  166. package/src/bongos/routes/security.js +21 -12
  167. package/src/bongos/serve-internal.js +22 -7
  168. package/src/module-api.js +96 -13
  169. package/src/platform-server.js +14 -0
  170. package/tests/api_client.mjs +18 -2
  171. package/tests/api_path_404.mjs +3 -0
  172. package/tests/art_stage.mjs +2 -1
  173. package/tests/atlas.mjs +32 -4
  174. package/tests/auth_admission_gate.mjs +17 -5
  175. package/tests/auth_error_brand.mjs +41 -21
  176. package/tests/auth_resolve_failure.mjs +155 -0
  177. package/tests/blocker_link.mjs +88 -0
  178. package/tests/bongos_cli.mjs +91 -0
  179. package/tests/bongos_downloads.mjs +2 -1
  180. package/tests/box_dns_repoint.mjs +2 -0
  181. package/tests/box_ship_permissions.mjs +2 -0
  182. package/tests/box_terminal_ssrf.mjs +23 -17
  183. package/tests/bump_version.mjs +71 -0
  184. package/tests/claim_error_surface.mjs +23 -0
  185. package/tests/claude_materialize.mjs +4 -0
  186. package/tests/cli_box_scope.mjs +40 -0
  187. package/tests/cloud_bongos_pack.mjs +34 -4
  188. package/tests/conductor.mjs +2 -1
  189. package/tests/criterion_authoring_gate.mjs +28 -5
  190. package/tests/currency_label.mjs +10 -1
  191. package/tests/deploy_instance.mjs +2 -0
  192. package/tests/devbox_downloads.mjs +2 -1
  193. package/tests/display_name.mjs +95 -6
  194. package/tests/eviction_policy.mjs +2 -1
  195. package/tests/file_map.mjs +10 -0
  196. package/tests/first_admin_bootstrap.mjs +10 -2
  197. package/tests/first_run_engine_check.mjs +2 -1
  198. package/tests/fitness.mjs +26 -6
  199. package/tests/gate_approvals.mjs +15 -2
  200. package/tests/github_push_land_proof.mjs +50 -4
  201. package/tests/github_repos_helpers.mjs +30 -5
  202. package/tests/github_token_store.mjs +23 -0
  203. package/tests/goal_abuse_matrix.mjs +13 -3
  204. package/tests/goal_cli.mjs +89 -0
  205. package/tests/governance_abuse_matrix.mjs +386 -0
  206. package/tests/governance_fold.mjs +156 -0
  207. package/tests/governance_fold_bench.mjs +50 -0
  208. package/tests/governance_live_proof.mjs +201 -0
  209. package/tests/governance_page_gates.mjs +21 -9
  210. package/tests/governance_parity.mjs +11 -2
  211. package/tests/governance_require_permission.mjs +125 -1
  212. package/tests/governance_routes.mjs +334 -0
  213. package/tests/grader.mjs +7 -3
  214. package/tests/hall_nav.mjs +14 -4
  215. package/tests/helpers.mjs +73 -0
  216. package/tests/hook_core_resolve.mjs +2 -1
  217. package/tests/host_topology_skips.mjs +97 -0
  218. package/tests/infra_exec_paths.mjs +14 -4
  219. package/tests/init.mjs +147 -0
  220. package/tests/known_failing_ratchet.mjs +89 -0
  221. package/tests/lifecycle-card.mjs +2 -1
  222. package/tests/main_audit.mjs +72 -1
  223. package/tests/memory_isolation.mjs +56 -9
  224. package/tests/migrate_root_resolution.mjs +1 -1
  225. package/tests/mirror_redact.mjs +19 -0
  226. package/tests/module_api.mjs +3 -1
  227. package/tests/module_loader.mjs +4 -0
  228. package/tests/modules.mjs +35 -7
  229. package/tests/modules_route.mjs +29 -5
  230. package/tests/onboarding_observability.mjs +49 -11
  231. package/tests/onboarding_route_signals.mjs +1 -0
  232. package/tests/one_active_claim_per_session.mjs +80 -0
  233. package/tests/patch_gate_ordering.mjs +102 -0
  234. package/tests/pre_compact.mjs +2 -1
  235. package/tests/preview_server.mjs +2 -1
  236. package/tests/profile_route.mjs +1 -0
  237. package/tests/provision.mjs +170 -0
  238. package/tests/provisioning_onboard_mode.mjs +180 -0
  239. package/tests/publish_manifest.mjs +16 -3
  240. package/tests/publish_reconciler.mjs +181 -10
  241. package/tests/rank_tier_single_source.mjs +205 -0
  242. package/tests/recall_hints.mjs +2 -1
  243. package/tests/repo_map.mjs +4 -0
  244. package/tests/route_permission_check.mjs +14 -5
  245. package/tests/rubric_fixtures.mjs +3 -0
  246. package/tests/run_routine_two_root.mjs +2 -0
  247. package/tests/sandbox_stage.mjs +69 -7
  248. package/tests/search_ingest.mjs +7 -0
  249. package/tests/security_docs.mjs +28 -6
  250. package/tests/sequence.mjs +64 -0
  251. package/tests/session-cards.mjs +2 -1
  252. package/tests/session_start_freshness.mjs +2 -1
  253. package/tests/ship_broadcast_server.mjs +63 -1
  254. package/tests/ship_card.mjs +66 -0
  255. package/tests/ship_ci_deploy.mjs +66 -1
  256. package/tests/ship_smoke_gate.mjs +76 -0
  257. package/tests/versions_route.mjs +15 -0
  258. package/tests/workflow_exec_paths.mjs +16 -4
  259. package/tests/worktree_guard.mjs +2 -1
@@ -309,7 +309,7 @@ module.exports = function buildTasksRouter() {
309
309
  // BACKSTOP for the on-claim restock (the live mechanism is the post-claim hook
310
310
  // in routes/claims.js). Archon-gated like all task creation (ADR 0016). Must
311
311
  // be registered BEFORE GET /tasks/:id so ':id' doesn't swallow the literal.
312
- router.post('/tasks/newcomer-restock', auth.requireBuilder, auth.requireRank('archon'), async (_req, res) => {
312
+ router.post('/tasks/newcomer-restock', auth.requireBuilder, auth.requirePermission('task.newcomer_restock'), async (_req, res) => {
313
313
  try {
314
314
  // The newcomer-chore restock carved into modules/onboarding/ (BV1.R81 / ADR
315
315
  // 0093 §1): resolve the `onboarding` kernel port — never a direct require.
@@ -368,7 +368,7 @@ module.exports = function buildTasksRouter() {
368
368
  // { depends_on_task_ids: [N, ...] }
369
369
  // Returns 409 with code='cycle' if accepting would create a cycle.
370
370
  // rank: metic+archon — task-shaping; Metic may wire deps of a task it can author (ADR 0090).
371
- router.post('/tasks/:id/dependencies', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
371
+ router.post('/tasks/:id/dependencies', auth.requireBuilder, auth.requirePermission('task.dependency.manage'), async (req, res) => {
372
372
  if (validateOrRespond(req, res, {
373
373
  depends_on_task_ids: { type: 'array', maxItems: 200 },
374
374
  // depends_on_task_id can be a number or a string-coerced number — leave
@@ -412,7 +412,7 @@ module.exports = function buildTasksRouter() {
412
412
 
413
413
  // DELETE /tasks/:id/dependencies/:depId — removes the edge.
414
414
  // rank: metic+archon — removes a dep edge; task-shaping, Metic+ (ADR 0090).
415
- router.delete('/tasks/:id/dependencies/:depId', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
415
+ router.delete('/tasks/:id/dependencies/:depId', auth.requireBuilder, auth.requirePermission('task.dependency.manage'), async (req, res) => {
416
416
  const id = Number(req.params.id);
417
417
  const depId = Number(req.params.depId);
418
418
  if (!Number.isFinite(id) || !Number.isFinite(depId)) {
@@ -425,7 +425,7 @@ module.exports = function buildTasksRouter() {
425
425
  // PUT /tasks/:id/dependencies — replace the full dep set.
426
426
  // body: { depends_on_task_ids: [N, ...] }
427
427
  // rank: metic+archon — replaces the full dep set; task-shaping, Metic+ (ADR 0090).
428
- router.put('/tasks/:id/dependencies', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
428
+ router.put('/tasks/:id/dependencies', auth.requireBuilder, auth.requirePermission('task.dependency.manage'), async (req, res) => {
429
429
  if (validateOrRespond(req, res, {
430
430
  depends_on_task_ids: { required: true, type: 'array', maxItems: 200 },
431
431
  })) return;
@@ -467,7 +467,7 @@ module.exports = function buildTasksRouter() {
467
467
  // POST /tasks/:id/criteria — body: { criterion_ids: [ref, ...] } OR
468
468
  // { criterion_id: ref }. Metic+.
469
469
  // rank: metic+archon — links a task it can author to a criterion; mirrors POST deps (ADR 0090).
470
- router.post('/tasks/:id/criteria', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
470
+ router.post('/tasks/:id/criteria', auth.requireBuilder, auth.requirePermission('task.criterion.manage'), async (req, res) => {
471
471
  if (validateOrRespond(req, res, {
472
472
  criterion_ids: { type: 'array', maxItems: 100 },
473
473
  // criterion_id is the singular alt-form (a numeric id, "Cn" token, or slug)
@@ -509,7 +509,7 @@ module.exports = function buildTasksRouter() {
509
509
  // DELETE /tasks/:id/criteria/:criterionId — removes one link (criterionId is
510
510
  // the numeric done_when_criteria.id, mirroring DELETE deps/:depId).
511
511
  // rank: metic+archon — removes a criterion↔task edge; task-shaping, Metic+ (ADR 0090).
512
- router.delete('/tasks/:id/criteria/:criterionId', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
512
+ router.delete('/tasks/:id/criteria/:criterionId', auth.requireBuilder, auth.requirePermission('task.criterion.manage'), async (req, res) => {
513
513
  const id = Number(req.params.id);
514
514
  const criterionId = Number(req.params.criterionId);
515
515
  if (!Number.isFinite(id) || !Number.isFinite(criterionId)) {
@@ -525,7 +525,7 @@ module.exports = function buildTasksRouter() {
525
525
  // ADR 0018). A Xenos/Thetes still cannot author — they file ideas. Setting rank
526
526
  // and closing versions stay Archon. The every-route audit is task #231 (V3.R06).
527
527
  // rank: metic+archon — create work (plan/scope mutation); ADR 0090 (was ADR 0016/0018 archon).
528
- router.post('/tasks', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
528
+ router.post('/tasks', auth.requireBuilder, auth.requirePermission('task.create'), async (req, res) => {
529
529
  if (validateOrRespond(req, res, {
530
530
  version_id: { required: true, type: 'string', maxLength: 64 },
531
531
  title: { required: true, type: 'string', maxLength: LIMITS.TITLE, minLength: 1 },
@@ -747,7 +747,7 @@ module.exports = function buildTasksRouter() {
747
747
  // accepts true|false|null; newcomer_friendly is a boolean. Validation lives
748
748
  // in hierarchy.js for parent; the rest are validated inline.
749
749
  // rank: metic+archon — edit work (incl. newcomer_friendly flag); ADR 0090 (was ADR 0016 archon).
750
- router.patch('/tasks/:id', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
750
+ router.patch('/tasks/:id', auth.requireBuilder, auth.requirePermission('task.edit'), async (req, res) => {
751
751
  if (validateOrRespond(req, res, {
752
752
  kind: { type: 'string', maxLength: 64 },
753
753
  discipline: { type: 'string', maxLength: 64 },
@@ -797,18 +797,20 @@ module.exports = function buildTasksRouter() {
797
797
  if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasRequiresRank && !hasModuleKey && !hasGoalId) {
798
798
  return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, parallel_safe, security_sensitive, automation_tag, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
799
799
  }
800
- let updated = null;
801
- if (hasParallelSafe) {
802
- if (body.parallel_safe !== null && typeof body.parallel_safe !== 'boolean') {
803
- return res.fail('bad_parallel_safe', { status: 400, message: 'parallel_safe must be true, false, or null' });
804
- }
805
- try {
806
- updated = await db.updateTaskParallelSafe(id, body.parallel_safe);
807
- if (!updated) return res.fail('task_not_found', 404);
808
- } catch (err) {
809
- console.error('[gds] PATCH /tasks/:id parallel_safe', err);
810
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
811
- }
800
+ // ---- task 1002609 (G-22): settle EVERY gate and validation before the ----
801
+ // ---- FIRST write. The previous shape validated-and-wrote per field in ----
802
+ // sequence, so a body mixing a valid early field with a refused later one
803
+ // committed the early write and then 403/400'd — state changed on a request
804
+ // the caller was told was refused (a Metic sending { parallel_safe,
805
+ // security_sensitive } had parallel_safe committed before rank_forbidden).
806
+ // Authorization, type/enum checks, and the goal existence/version reads all
807
+ // run here; writes begin only after the last validation `return`.
808
+ // (Documented residual: parent_task_id validates inside hierarchy.attachParent
809
+ // at the end — its checks are atomic with its OWN write, but a refusal there
810
+ // still lands after the field writes below. No authorization gate is
811
+ // involved in that path; the handler's only rank gate is hoisted here.)
812
+ if (hasParallelSafe && body.parallel_safe !== null && typeof body.parallel_safe !== 'boolean') {
813
+ return res.fail('bad_parallel_safe', { status: 400, message: 'parallel_safe must be true, false, or null' });
812
814
  }
813
815
  if (hasSecuritySensitive) {
814
816
  // task 990: explicit Archon-only-broadcast flag. Boolean only (no null) —
@@ -826,41 +828,19 @@ module.exports = function buildTasksRouter() {
826
828
  if (typeof body.security_sensitive !== 'boolean') {
827
829
  return res.fail('bad_security_sensitive', { status: 400, message: 'security_sensitive must be a boolean' });
828
830
  }
829
- try {
830
- updated = await db.updateTaskSecuritySensitive(id, body.security_sensitive);
831
- if (!updated) return res.fail('task_not_found', 404);
832
- } catch (err) {
833
- console.error('[gds] PATCH /tasks/:id security_sensitive', err);
834
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
835
- }
836
831
  }
832
+ const VALID_AUTOMATION_TAGS = new Set(['overnight-safe', 'live-only', 'needs-input']);
837
833
  if (hasAutomationTag) {
838
834
  const tag = body.automation_tag;
839
- const VALID_AUTOMATION_TAGS = new Set(['overnight-safe', 'live-only', 'needs-input']);
840
835
  if (tag !== null && (typeof tag !== 'string' || !VALID_AUTOMATION_TAGS.has(tag))) {
841
836
  return res.fail('bad_automation_tag', { status: 400, message: 'automation_tag must be one of overnight-safe | live-only | needs-input, or null to clear', details: { valid: Array.from(VALID_AUTOMATION_TAGS) } });
842
837
  }
843
- try {
844
- updated = await db.updateTaskAutomationTag(id, tag);
845
- if (!updated) return res.fail('task_not_found', 404);
846
- } catch (err) {
847
- console.error('[gds] PATCH /tasks/:id automation_tag', err);
848
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
849
- }
850
838
  }
851
839
  if (hasModuleKey) {
852
840
  // BV1.R53 (task 1422): assign or clear the module affinity label. null clears.
853
- const key = body.module_key;
854
- if (key !== null && !isModuleKey(key)) {
841
+ if (body.module_key !== null && !isModuleKey(body.module_key)) {
855
842
  return res.fail('bad_module_key', { status: 400, message: `module_key must be one of the known module keys, or null to clear. Valid keys: ${moduleKeys().join(', ')}.`, details: { valid: moduleKeys() } });
856
843
  }
857
- try {
858
- updated = await db.updateTaskModuleKey(id, key ?? null);
859
- if (!updated) return res.fail('task_not_found', 404);
860
- } catch (err) {
861
- console.error('[gds] PATCH /tasks/:id module_key', err);
862
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
863
- }
864
844
  }
865
845
  if (hasGoalId) {
866
846
  // task 1763: (re)assign the task's goal — the documented "Archon/Metic
@@ -882,8 +862,6 @@ module.exports = function buildTasksRouter() {
882
862
  if (String(goal.version_id) !== String(task.version_id)) {
883
863
  return res.fail('goal_version_mismatch', { status: 409, message: `Goal #${goalId} is in version '${goal.version_id}' but task #${id} is in '${task.version_id}'. A task can only join a goal in its own version.` });
884
864
  }
885
- updated = await db.updateTaskGoal(id, goalId);
886
- if (!updated) return res.fail('task_not_found', 404);
887
865
  } catch (err) {
888
866
  console.error('[gds] PATCH /tasks/:id goal_id', err);
889
867
  return res.fail('patch_failed', { status: 500, message: 'internal error' });
@@ -893,13 +871,77 @@ module.exports = function buildTasksRouter() {
893
871
  // task 1498 / ADR 0084: the Archon override of the per-task rank floor.
894
872
  // Validate the enum here; db.updateTaskRequiresRank clamps the value UP to
895
873
  // the auto-derived floor (touches + security_sensitive), so a PATCH can
896
- // only RAISE the gate — never open a sensitive task to a lower rank. Runs
897
- // AFTER the security_sensitive block above, so a same-request flag flip is
898
- // already reflected in the derived floor.
874
+ // only RAISE the gate — never open a sensitive task to a lower rank.
899
875
  const LIVE_RANKS = new Set(db.LIVE_RANK_LADDER);
900
876
  if (typeof body.requires_rank !== 'string' || !LIVE_RANKS.has(body.requires_rank)) {
901
877
  return res.fail('bad_requires_rank', { status: 400, message: `requires_rank must be one of ${db.LIVE_RANK_LADDER.join(' | ')}. An override can only raise the floor; it is clamped up to the auto-derived minimum.`, details: { valid: db.LIVE_RANK_LADDER } });
902
878
  }
879
+ }
880
+ if (hasKind && !VALID_KINDS.has(body.kind)) {
881
+ return badKind(res);
882
+ }
883
+ if (hasDiscipline && !VALID_DISCIPLINES.has(body.discipline)) {
884
+ return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
885
+ }
886
+ if (hasNewcomerOrXenos) {
887
+ // V3.R15 #239: prefer newcomer_friendly (canonical); fall back to
888
+ // xenos_claimable (pre-rename alias). Both map to the same DB column.
889
+ const incoming = hasNewcomerFriendly ? body.newcomer_friendly : body.xenos_claimable;
890
+ if (typeof incoming !== 'boolean') {
891
+ const field = hasNewcomerFriendly ? 'newcomer_friendly' : 'xenos_claimable';
892
+ return res.fail(`bad_${field}`, { status: 400, message: `${field} must be a boolean` });
893
+ }
894
+ }
895
+ // ---- all gates passed: apply the writes. Order preserved from the old ----
896
+ // shape — requires_rank still runs AFTER security_sensitive so a
897
+ // same-request flag flip is already reflected in the derived floor.
898
+ let updated = null;
899
+ if (hasParallelSafe) {
900
+ try {
901
+ updated = await db.updateTaskParallelSafe(id, body.parallel_safe);
902
+ if (!updated) return res.fail('task_not_found', 404);
903
+ } catch (err) {
904
+ console.error('[gds] PATCH /tasks/:id parallel_safe', err);
905
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
906
+ }
907
+ }
908
+ if (hasSecuritySensitive) {
909
+ try {
910
+ updated = await db.updateTaskSecuritySensitive(id, body.security_sensitive);
911
+ if (!updated) return res.fail('task_not_found', 404);
912
+ } catch (err) {
913
+ console.error('[gds] PATCH /tasks/:id security_sensitive', err);
914
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
915
+ }
916
+ }
917
+ if (hasAutomationTag) {
918
+ try {
919
+ updated = await db.updateTaskAutomationTag(id, body.automation_tag);
920
+ if (!updated) return res.fail('task_not_found', 404);
921
+ } catch (err) {
922
+ console.error('[gds] PATCH /tasks/:id automation_tag', err);
923
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
924
+ }
925
+ }
926
+ if (hasModuleKey) {
927
+ try {
928
+ updated = await db.updateTaskModuleKey(id, body.module_key ?? null);
929
+ if (!updated) return res.fail('task_not_found', 404);
930
+ } catch (err) {
931
+ console.error('[gds] PATCH /tasks/:id module_key', err);
932
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
933
+ }
934
+ }
935
+ if (hasGoalId) {
936
+ try {
937
+ updated = await db.updateTaskGoal(id, body.goal_id);
938
+ if (!updated) return res.fail('task_not_found', 404);
939
+ } catch (err) {
940
+ console.error('[gds] PATCH /tasks/:id goal_id', err);
941
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
942
+ }
943
+ }
944
+ if (hasRequiresRank) {
903
945
  try {
904
946
  updated = await db.updateTaskRequiresRank(id, body.requires_rank);
905
947
  if (!updated) return res.fail('task_not_found', 404);
@@ -909,9 +951,6 @@ module.exports = function buildTasksRouter() {
909
951
  }
910
952
  }
911
953
  if (hasKind) {
912
- if (!VALID_KINDS.has(body.kind)) {
913
- return badKind(res);
914
- }
915
954
  try {
916
955
  updated = await db.updateTaskKind(id, body.kind);
917
956
  if (!updated) return res.fail('task_not_found', 404);
@@ -921,9 +960,6 @@ module.exports = function buildTasksRouter() {
921
960
  }
922
961
  }
923
962
  if (hasDiscipline) {
924
- if (!VALID_DISCIPLINES.has(body.discipline)) {
925
- return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
926
- }
927
963
  try {
928
964
  updated = await db.updateTaskDiscipline(id, body.discipline);
929
965
  if (!updated) return res.fail('task_not_found', 404);
@@ -933,13 +969,7 @@ module.exports = function buildTasksRouter() {
933
969
  }
934
970
  }
935
971
  if (hasNewcomerOrXenos) {
936
- // V3.R15 #239: prefer newcomer_friendly (canonical); fall back to
937
- // xenos_claimable (pre-rename alias). Both map to the same DB column.
938
972
  const incoming = hasNewcomerFriendly ? body.newcomer_friendly : body.xenos_claimable;
939
- if (typeof incoming !== 'boolean') {
940
- const field = hasNewcomerFriendly ? 'newcomer_friendly' : 'xenos_claimable';
941
- return res.fail(`bad_${field}`, { status: 400, message: `${field} must be a boolean` });
942
- }
943
973
  try {
944
974
  updated = await db.updateTaskNewcomerFriendly(id, incoming);
945
975
  if (!updated) return res.fail('task_not_found', 404);
@@ -977,7 +1007,7 @@ module.exports = function buildTasksRouter() {
977
1007
  // PATCH refuses `status` outright). A restore lands in **backlog**, never straight
978
1008
  // to ready: the task was retired for a reason, so it re-enters as un-workable and
979
1009
  // takes a second, deliberate promote to become claimable again.
980
- router.post('/tasks/:id/promote', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1010
+ router.post('/tasks/:id/promote', auth.requireBuilder, auth.requirePermission('task.promote'), async (req, res) => {
981
1011
  const id = Number(req.params.id);
982
1012
  const task = await db.getTask(id);
983
1013
  if (!task) return res.fail('task_not_found', 404);
@@ -1020,7 +1050,7 @@ module.exports = function buildTasksRouter() {
1020
1050
  // task out from under its claim holder. No reward side-effects (the reward gate
1021
1051
  // is a promote-only concern; backlog is the pre-workable state).
1022
1052
  // rank: metic+archon — ready→backlog demotion; scope mutation; mirrors /promote (ADR 0090).
1023
- router.post('/tasks/:id/demote', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1053
+ router.post('/tasks/:id/demote', auth.requireBuilder, auth.requirePermission('task.demote'), async (req, res) => {
1024
1054
  const id = Number(req.params.id);
1025
1055
  const task = await db.getTask(id);
1026
1056
  if (!task) return res.fail('task_not_found', 404);
@@ -1091,7 +1121,7 @@ module.exports = function buildTasksRouter() {
1091
1121
  // GRADER_BYPASS_ENABLED==='1'; archon-only otherwise).
1092
1122
  const confirmRankGate = (req, res, next) => {
1093
1123
  if (graderBypassEnabled()) return next();
1094
- return auth.requireRank('archon')(req, res, next);
1124
+ return auth.requirePermission('task.confirm.grade_bypass')(req, res, next);
1095
1125
  };
1096
1126
  router.post('/tasks/:id/confirm', auth.requireBuilder, confirmRankGate, async (req, res) => {
1097
1127
  if (validateOrRespond(req, res, {
@@ -1148,7 +1178,7 @@ module.exports = function buildTasksRouter() {
1148
1178
  // triage they already delegated. Not one-way any more — /promote restores an
1149
1179
  // abandoned task to backlog at the same rank — and the guards below (shipped
1150
1180
  // refused, active claim refused) plus the audit_log entry are unchanged.
1151
- router.post('/tasks/:id/abandon', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1181
+ router.post('/tasks/:id/abandon', auth.requireBuilder, auth.requirePermission('task.abandon'), async (req, res) => {
1152
1182
  if (validateOrRespond(req, res, {
1153
1183
  reason: { type: 'string' }, // free-text retire reason (sliced to 2000 below)
1154
1184
  })) return;
@@ -1228,7 +1258,7 @@ module.exports = function buildTasksRouter() {
1228
1258
  // reaches it via the doorway api.tallyPeerVotes — BV1.R83) share one code path
1229
1259
  // and can never diverge. This route keeps the Archon gate (the HTTP surface
1230
1260
  // stays Archon-only); the in-process timer has no requester to authorize.
1231
- router.post('/tasks/peer-votes/tally', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
1261
+ router.post('/tasks/peer-votes/tally', auth.requireBuilder, auth.requirePermission('task.peer_votes.tally'), async (req, res) => {
1232
1262
  try {
1233
1263
  const result = await db.tallyPeerVotes();
1234
1264
  res.json(result);
@@ -1244,7 +1274,7 @@ module.exports = function buildTasksRouter() {
1244
1274
  // - 409 if the task isn't votable, or the vote was already folded into karma
1245
1275
  // - vote: 0 clears (deletes the row, only while still untallied)
1246
1276
  // rank: metic+archon — a trusted-builder reputation action.
1247
- router.post('/tasks/:id/vote', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1277
+ router.post('/tasks/:id/vote', auth.requireBuilder, auth.requirePermission('task.vote'), async (req, res) => {
1248
1278
  if (validateOrRespond(req, res, {
1249
1279
  vote: { required: true, type: 'integer', enum: [-1, 0, 1] },
1250
1280
  })) return;
@@ -1685,7 +1715,7 @@ module.exports = function buildTasksRouter() {
1685
1715
  // GET /override-requests?status=open — Archon queue. Status defaults to open;
1686
1716
  // pass ?status=approved or ?status=denied for the resolved history.
1687
1717
  // rank: archon — same gate as the audit log + Archon view.
1688
- router.get('/override-requests', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
1718
+ router.get('/override-requests', auth.requireBuilder, auth.requirePermission('override_request.decide'), async (req, res) => {
1689
1719
  const status = req.query.status || 'open';
1690
1720
  if (!['open', 'approved', 'denied'].includes(status)) {
1691
1721
  return res.fail('bad_status', 400);
@@ -1705,7 +1735,7 @@ module.exports = function buildTasksRouter() {
1705
1735
  // updates; the task stays at completed and the builder must release or
1706
1736
  // re-ship after fixing whatever the grader caught.
1707
1737
  // rank: archon — grader-bypass authority; same gate as POST /tasks/:id/confirm.
1708
- router.post('/override-requests/:id/decide', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
1738
+ router.post('/override-requests/:id/decide', auth.requireBuilder, auth.requirePermission('override_request.decide'), async (req, res) => {
1709
1739
  const id = parseId(req, res, { code: 'bad_request_id' });
1710
1740
  if (id === null) return;
1711
1741
  if (validateOrRespond(req, res, {
@@ -11,6 +11,11 @@ const { asyncHandler, validateOrRespond } = api;
11
11
  // 'building'; 'shipped'/'frozen' are end states reached via the close flow.
12
12
  const VALID_VERSION_STATUSES = new Set(['planning', 'building', 'shipped', 'frozen']);
13
13
 
14
+ // versions.track CHECK (migration 003) — NOT NULL with no DB default, so the
15
+ // create path must always supply one. 'product' is the caller-facing default:
16
+ // an instance seeding its first version is seeding its product track.
17
+ const VALID_VERSION_TRACKS = new Set(['product', 'internal']);
18
+
14
19
  module.exports = function buildVersionsRouter() {
15
20
  const router = express.Router();
16
21
 
@@ -62,11 +67,12 @@ module.exports = function buildVersionsRouter() {
62
67
  // no hand-written seed-*.js script. Archon-only: creating a version is a
63
68
  // plan/scope mutation, same gate as POST /tasks (ADR 0016).
64
69
  // rank: archon — creates a version + its done-when criteria.
65
- router.post('/versions', auth.requireBuilder, auth.requireRank('archon'), asyncHandler('POST /versions', async (req, res) => {
70
+ router.post('/versions', auth.requireBuilder, auth.requirePermission('version.create'), asyncHandler('POST /versions', async (req, res) => {
66
71
  if (validateOrRespond(req, res, {
67
72
  id: { required: true, type: 'string', maxLength: 64, minLength: 1 },
68
73
  name: { required: true, type: 'string', maxLength: 200, minLength: 1 },
69
74
  status: { type: 'string', maxLength: 32 },
75
+ track: { type: 'string', maxLength: 16 },
70
76
  done_when: { type: 'string', maxLength: 8000 },
71
77
  scope_doc_path: { type: 'string', maxLength: 256 },
72
78
  criteria: { type: 'array', maxItems: 100 },
@@ -81,6 +87,11 @@ module.exports = function buildVersionsRouter() {
81
87
  return res.fail('bad_status', 400, { valid: Array.from(VALID_VERSION_STATUSES) });
82
88
  }
83
89
 
90
+ const track = body.track || 'product';
91
+ if (!VALID_VERSION_TRACKS.has(track)) {
92
+ return res.fail('bad_track', 400, { valid: Array.from(VALID_VERSION_TRACKS) });
93
+ }
94
+
84
95
  if (await db.getVersion(id)) {
85
96
  return res.fail('version_exists', { status: 409, message: `Version '${id}' already exists.` });
86
97
  }
@@ -104,7 +115,7 @@ module.exports = function buildVersionsRouter() {
104
115
  let version, created;
105
116
  try {
106
117
  ({ version, criteria: created } = await db.createVersion({
107
- id, name, status,
118
+ id, name, status, track,
108
119
  doneWhen: body.done_when ?? null,
109
120
  scopeDocPath: body.scope_doc_path ?? null,
110
121
  criteria,
@@ -195,7 +195,21 @@ function safeBrand() {
195
195
  // Stage-specific field selection lives HERE; the renderers are generic.
196
196
  function buildCardModel(input = {}) {
197
197
  const stage = input.stage || stageFor(input.postGradeStatus);
198
- const cfg = STAGES[stage] || STAGES.failed;
198
+ const baseCfg = STAGES[stage] || STAGES.failed;
199
+ // task 1002541: did the land actually bail? `input.land` is the ship chain's
200
+ // outcome ({ ok, pending, reason, nextStep }). A task sits at 'confirmed' both
201
+ // when it IS landing on its own (pending — CI/the reconciler will finish it)
202
+ // and when the merge gave up. Only the second is a lie worth shouting about,
203
+ // so this needs an explicit reason — never a bare "not ok".
204
+ // ABSENT `land` (every pre-existing caller, and breadcrumbs written by older
205
+ // versions) ⇒ false ⇒ the card renders exactly as it did before.
206
+ const land = input.land || null;
207
+ const landBailed = !!(stage === 'confirmed' && land && land.ok === false && !land.pending && land.reason);
208
+ // A bailed land is not "confirmed — landing". Recolor the banner to match the
209
+ // truth; the underlying STAGES entry is left untouched (it's frozen + shared).
210
+ const cfg = landBailed
211
+ ? { ...baseCfg, tone: 'warning', icon: 'ti-alert-triangle', statusWord: 'Not landed', headline: (id) => `Task #${id} confirmed — NOT landed` }
212
+ : baseCfg;
199
213
  const brand = input.brand || safeBrand();
200
214
  const taskId = input.taskId;
201
215
  const grade = input.grade || {};
@@ -285,11 +299,20 @@ function buildCardModel(input = {}) {
285
299
  deployCommit = input.deploy && input.deploy.commit ? String(input.deploy.commit) : null;
286
300
  deployText = deployCommit ? `live · ${deployCommit}` : 'live';
287
301
  } else if (stage === 'confirmed') {
288
- // The server lands confirmed tasks automatically (publish-reconciler +
289
- // merge driver + ADR 0082 resolver); /merge-mode is a rare manual
290
- // fallback, kept as the card's optional 'Land it' override — so the fact
291
- // states the automatic reality rather than implying a required command.
292
- deployText = 'landing automatically';
302
+ // task 1002541: this line used to be unconditional, driven by the STATUS
303
+ // alone — so a merge that bailed still reported "landing automatically".
304
+ // Nothing was landing, no PR existed, and under ADR 0120 (pay on land) the
305
+ // builder was unpaid: the failure announced itself as success, which is
306
+ // why these strands survived for days. Say what actually happened.
307
+ if (landBailed) {
308
+ deployText = `NOT landed — ${land.reason}`;
309
+ } else {
310
+ // The server lands confirmed tasks automatically (publish-reconciler +
311
+ // merge driver + ADR 0082 resolver); /merge-mode is a rare manual
312
+ // fallback, kept as the card's optional 'Land it' override — so the fact
313
+ // states the automatic reality rather than implying a required command.
314
+ deployText = 'landing automatically';
315
+ }
293
316
  } else {
294
317
  deployText = 'not deployed';
295
318
  }
@@ -300,12 +323,20 @@ function buildCardModel(input = {}) {
300
323
  addFact('', changed);
301
324
  if (gradeText) addFact('grade', gradeText, { cls: gradeCls });
302
325
  if (stage === 'shipped') addFact('live', deployCommit, { mono: true });
326
+ // The reason can be a full sentence — too long for a fact chip, so the chip
327
+ // carries the alarm and the reason rides in its tooltip (the Deploy row above
328
+ // always shows it in full).
329
+ else if (landBailed) addFact('', 'NOT landed', { cls: 'cc-bad', title: land.reason });
303
330
  else addFact('', deployText);
304
331
  addFact('', usageText, usageTitle ? { title: usageTitle } : {});
305
332
  if (creditsText) addFact('', creditsText);
306
333
  }
307
334
 
308
- const action = nextActionFor(stage, { taskId, discipline: input.discipline, sandboxUrl: input.sandboxUrl });
335
+ // task 1002541: a bail knows the precise recovery command (re-run the ship,
336
+ // re-auth, pass --db-only…), which beats the stage's generic '/merge-mode'.
337
+ const action = (landBailed && land.nextStep)
338
+ ? { kind: 'prompt', label: 'Land it', cmd: land.nextStep }
339
+ : nextActionFor(stage, { taskId, discipline: input.discipline, sandboxUrl: input.sandboxUrl });
309
340
 
310
341
  return {
311
342
  stage, cfg, brand, taskId,
@@ -556,7 +556,7 @@ module.exports = function buildMemoryRouter() {
556
556
  router.get(
557
557
  '/memory/builders/:builderId/files',
558
558
  auth.requireBuilder,
559
- auth.requireRank('archon'),
559
+ auth.requirePermission('memory.read.any'),
560
560
  readRateLimit,
561
561
  async (req, res) => {
562
562
  const targetId = Number(req.params.builderId);
@@ -607,7 +607,7 @@ module.exports = function buildMemoryRouter() {
607
607
  router.get(
608
608
  '/memory/builders/:builderId/file',
609
609
  auth.requireBuilder,
610
- auth.requireRank('archon'),
610
+ auth.requirePermission('memory.read.any'),
611
611
  readRateLimit,
612
612
  async (req, res) => {
613
613
  const targetId = Number(req.params.builderId);
@@ -26,7 +26,7 @@ const express = require('express');
26
26
  // core file a module may require, ADR 0083); approval-broadcast travels WITH this
27
27
  // admission route (its sole consumer) and is an intra-module require.
28
28
  const api = require('../../../src/module-api');
29
- const auth = { requireBuilder: api.requireBuilder, requireRank: api.requireRank };
29
+ const auth = { requireBuilder: api.requireBuilder, requirePermission: api.requirePermission };
30
30
  const { pool } = api;
31
31
  const { validateOrRespond, parseId } = api;
32
32
  const approvalBroadcast = require('../approval-broadcast');
@@ -145,7 +145,7 @@ module.exports = function buildAccessRequestsRouter() {
145
145
  // GET /access-requests?status=pending — Archon queue. Defaults to pending;
146
146
  // pass ?status=invited|dismissed for the resolved history.
147
147
  // rank: archon — the queue exposes would-be builders' usernames.
148
- router.get('/access-requests', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
148
+ router.get('/access-requests', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
149
149
  const status = req.query.status || 'pending';
150
150
  if (!['pending', 'invited', 'dismissed'].includes(status)) {
151
151
  return res.fail('bad_status', 400);
@@ -174,7 +174,7 @@ module.exports = function buildAccessRequestsRouter() {
174
174
  // (the only sign-in gate — no repo access needed); 'dismissed' = declined /
175
175
  // spam / dup. The row stays as the audit trail either way.
176
176
  // rank: archon — same gate as the rest of the roster management surface.
177
- router.patch('/access-requests/:id', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
177
+ router.patch('/access-requests/:id', auth.requireBuilder, auth.requirePermission('access_request.review'), async (req, res) => {
178
178
  const id = parseId(req, res, { code: 'bad_request_id' });
179
179
  if (id === null) return;
180
180
  if (validateOrRespond(req, res, {
@@ -14,13 +14,17 @@ const express = require('express');
14
14
  const api = require('../../../src/module-api');
15
15
  const pi = require('../platform-identity');
16
16
 
17
- const { pool, requireBuilder, requireRank, validateOrRespond, parseId, corsPublicGet } = api;
17
+ const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
18
18
 
19
19
  module.exports = function projectsRoutes() {
20
20
  const router = express.Router();
21
21
 
22
- // rank: archon — the full curation list (includes unfeatured + owner attribution).
23
- router.get('/projects', requireBuilder, requireRank('archon'), async (_req, res) => {
22
+ // rank: metic+archon — the full curation list (includes unfeatured + owner attribution).
23
+ // BV1.R105 left this on requireRank because `project.curate` was declared over the
24
+ // writes + GET /scouting, not over this read. ADR 0157 extends that atom's guards to
25
+ // name GET /projects and lowers its floor archon→metic, so the mapping now exists and
26
+ // the audience is the working rank.
27
+ router.get('/projects', requireBuilder, requirePermission('project.curate'), async (_req, res) => {
24
28
  try {
25
29
  res.json({ projects: await pi.listAllProjects({ pool }) });
26
30
  } catch (err) {
@@ -31,7 +35,7 @@ module.exports = function projectsRoutes() {
31
35
 
32
36
  // rank: archon — create / update a catalog entry (manual seed for hand-wired
33
37
  // instances; federated projects auto-catalogue at registration).
34
- router.post('/projects', requireBuilder, requireRank('archon'), async (req, res) => {
38
+ router.post('/projects', requireBuilder, requirePermission('project.curate'), async (req, res) => {
35
39
  if (validateOrRespond(req, res, {
36
40
  origin: { required: true, type: 'string', maxLength: 512 },
37
41
  name: { required: true, type: 'string', maxLength: 200 },
@@ -57,7 +61,7 @@ module.exports = function projectsRoutes() {
57
61
  // by github_login and requires the invitee to already have a platform account (they
58
62
  // build on some project); inviting someone entirely new to the platform (GitHub-id
59
63
  // resolution) is a follow-up.
60
- router.post('/projects/invite', requireBuilder, requireRank('archon'), async (req, res) => {
64
+ router.post('/projects/invite', requireBuilder, requirePermission('project.curate'), async (req, res) => {
61
65
  if (validateOrRespond(req, res, {
62
66
  client_id: { required: true, type: 'string', maxLength: 200 },
63
67
  github_login: { required: true, type: 'string', maxLength: 200 },
@@ -76,7 +80,7 @@ module.exports = function projectsRoutes() {
76
80
  });
77
81
 
78
82
  // rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate).
79
- router.patch('/projects/:id/featured', requireBuilder, requireRank('archon'), async (req, res) => {
83
+ router.patch('/projects/:id/featured', requireBuilder, requirePermission('project.curate'), async (req, res) => {
80
84
  const id = parseId(req, res, { code: 'bad_project_id', positiveInt: true });
81
85
  if (id === null) return;
82
86
  if (validateOrRespond(req, res, { featured: { required: true, type: 'boolean' } })) return;
@@ -12,7 +12,7 @@ const express = require('express');
12
12
  const api = require('../../../src/module-api');
13
13
  const pi = require('../platform-identity');
14
14
 
15
- const { pool, requireBuilder, requireRank, parsePagination } = api;
15
+ const { pool, requireBuilder, requirePermission, parsePagination } = api;
16
16
 
17
17
  module.exports = function scoutingRoutes() {
18
18
  const router = express.Router();
@@ -20,7 +20,7 @@ module.exports = function scoutingRoutes() {
20
20
  // rank: archon — the internal talent directory across every project. The Archon
21
21
  // gate here is the load-bearing protection ("NOT shown to builders"): a non-Archon
22
22
  // is 403'd before any cross-builder data is read.
23
- router.get('/scouting', requireBuilder, requireRank('archon'), async (req, res) => {
23
+ router.get('/scouting', requireBuilder, requirePermission('project.curate'), async (req, res) => {
24
24
  const { limit } = parsePagination(req, { defaultLimit: 100, maxLimit: 500 });
25
25
  try {
26
26
  const builders = await pi.listScoutingRollup({ limit, pool });
@@ -0,0 +1,30 @@
1
+ -- provisioning_004_onboard_mode.sql — record HOW an instance was onboarded:
2
+ -- greenfield (a fresh empty repo) vs adopt (layer onto a repo the owner already has).
3
+ -- ADR 0121 / task 1002562, BONGOS-V1 goal 32.
4
+ --
5
+ -- WHY THIS COLUMN EXISTS. `bongos init --adopt` (ADR 0121, tasks 2042-2046) built the
6
+ -- LOCAL half of brownfield onboarding, and `GET /provisioning/onboard-plan?mode=…`
7
+ -- branches the checklist TEXT the user is shown — but the mode was never PERSISTED, so
8
+ -- the control-plane runner had no way to know. scripts/gds/provision.js therefore ran
9
+ -- the greenfield scaffold unconditionally: `git init` a fresh tree, commit, and push
10
+ -- `HEAD:main`. Against a repo that already has history that is an unrelated-history
11
+ -- non-fast-forward and git rejects it, so adopting an existing repo could never
12
+ -- succeed. This column is what lets the runner take the layering path instead.
13
+ --
14
+ -- DEFAULT 'greenfield' is the correct backfill: every instance provisioned before this
15
+ -- migration went through the greenfield scaffold by construction (there was no other
16
+ -- path), so no existing row is mislabelled.
17
+ --
18
+ -- Widening/adding is ADDITIVE — a new nullable-with-default column plus a CHECK that
19
+ -- only constrains the new column. No existing row changes and no data is lost, so this
20
+ -- is safe under the module-owned-migration rule (ADR 0083 §Decision #5). Idempotent:
21
+ -- safe to re-run.
22
+ ALTER TABLE provisioning_instances
23
+ ADD COLUMN IF NOT EXISTS onboard_mode text NOT NULL DEFAULT 'greenfield';
24
+
25
+ ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_onboard_mode_check;
26
+ ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_onboard_mode_check
27
+ CHECK (onboard_mode IN ('greenfield', 'adopt'));
28
+
29
+ INSERT INTO schema_migrations (version) VALUES ('provisioning_004_onboard_mode')
30
+ ON CONFLICT (version) DO NOTHING;