@bongos/core 1.18.19 → 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 +514 -194
  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 +5 -5
  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 -13
  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 +1 -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
@@ -682,21 +682,48 @@ function landConfirmed(status) {
682
682
  // it must be an ancestor of main. NOTE claims.head_sha is the claim-TIME base — for a
683
683
  // task whose base is already on main (e.g. an R03 base) an ancestry check on it
684
684
  // false-positives — so we deliberately key on the published tip, never the base.
685
- // When no tip was recorded (a legacy row, or a publish in flight across the deploy
686
- // that introduced the column), fall back to verifying the matched PR references this
687
- // task id. Returns { proven, reason }. The compare call is the only network hop and
688
- // is injected-deps-pure for tests.
685
+ //
686
+ // task 1002615 (idea 1000613) — a land made OUT-OF-BAND must still prove. A squash-
687
+ // or rebase-merge (e.g. the owner landing the PR through the GitHub web UI) REWRITES
688
+ // the landed commit, so the recorded tip is never an ancestor of main even though the
689
+ // work is live — the old early-return stranded exactly that shape, unpaid. When the
690
+ // tip check misses, fall through to a second git-history proof: the matched PR is
691
+ // MERGED, names THIS task, and its merge_commit_sha is contained in main (that sha is
692
+ // the main-side commit under all three merge methods). Reference match PLUS ancestry
693
+ // is strictly stronger than the legacy no-tip fallback, so it cannot widen the
694
+ // branch-reuse false-ship this function guards.
695
+ //
696
+ // When no tip was recorded at all (a legacy row, or a publish in flight across the
697
+ // deploy that introduced the column), fall back to the matched PR referencing this
698
+ // task id — now requiring merged:true (task 1002615), because the reconciler consults
699
+ // this proof while a PR is still in flight and an OPEN PR that merely references the
700
+ // task must never count as a land. Returns { proven, reason }. The compare calls are
701
+ // the only network hops and are injected-deps-pure for tests.
689
702
  async function landProvenForTask({ taskId, publishedSha, status }, deps = {}) {
690
- if (publishedSha && SAFE_SHA_RE.test(String(publishedSha))) {
703
+ const hasTip = !!(publishedSha && SAFE_SHA_RE.test(String(publishedSha)));
704
+ if (hasTip) {
691
705
  const inMain = await isAncestorOfDefaultBranch({ sha: publishedSha }, deps);
692
- return inMain
693
- ? { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` }
694
- : { proven: false, reason: `published tip ${publishedSha.slice(0, 8)} not in main (branch-reuse/stale PR)` };
706
+ if (inMain) return { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` };
707
+ }
708
+ if (status && status.merged === true && prReferencesTask(status, taskId)
709
+ && status.merge_sha && SAFE_SHA_RE.test(String(status.merge_sha))) {
710
+ let mergeInMain = false;
711
+ try {
712
+ mergeInMain = await isAncestorOfDefaultBranch({ sha: status.merge_sha }, deps);
713
+ } catch (_) {
714
+ /* transient compare miss → fall through; the next sweep retries */
715
+ }
716
+ if (mergeInMain) {
717
+ return { proven: true, reason: `merged PR for task ${taskId} — merge ${String(status.merge_sha).slice(0, 8)} in main (out-of-band/squash land)` };
718
+ }
719
+ }
720
+ if (hasTip) {
721
+ return { proven: false, reason: `published tip ${String(publishedSha).slice(0, 8)} not in main (branch-reuse/stale PR)` };
695
722
  }
696
- if (prReferencesTask(status, taskId)) {
723
+ if (status && status.merged === true && prReferencesTask(status, taskId)) {
697
724
  return { proven: true, reason: `matched PR references task ${taskId} (no published tip recorded)` };
698
725
  }
699
- return { proven: false, reason: `no published tip recorded and matched PR does not reference task ${taskId}` };
726
+ return { proven: false, reason: `no published tip recorded and no MERGED PR references task ${taskId}` };
700
727
  }
701
728
 
702
729
  // task 1313 — proof-of-land that does NOT depend on the PR/branch still existing.
@@ -40,6 +40,34 @@
40
40
  // An 8-strand backlog (incl. a Xenos's onboarding ships that never counted) is what
41
41
  // surfaced this.
42
42
  //
43
+ // task 1002615 (idea 1000613) — recognise a land made OUT-OF-BAND. Two residual
44
+ // blind spots stranded a task whose work was verifiably on main: the sweep only
45
+ // consulted the per-task land proof when the branch's PR reported fully landed
46
+ // (prNotLanded suppressed it), and the proof itself gave up when the recorded
47
+ // published tip wasn't an ancestor of main. A human landing the PR by hand is
48
+ // completely normal — a web-UI squash/rebase REWRITES the landed sha (tip ancestry
49
+ // can never pass), and a manual `git merge` straight to main leaves the PR open or
50
+ // closed-unmerged (prNotLanded suppressed the proof forever). Both shapes left the
51
+ // builder unpaid for landed work (the 2026-07-29 reproduction on task 1002525). Now
52
+ // the proof answers from git history on main — tip ancestry, else the merged PR's
53
+ // merge_commit_sha ancestry — the same authoritative-source principle as task 879
54
+ // (git is the collision detector, not the bookkeeping). One deliberate exception:
55
+ // a MERGED PR still mid-deploy keeps governing — on a deploy-enabled instance
56
+ // 'shipped' means merged AND deployed, and the merge puts the tip in main minutes
57
+ // before the deploy finishes.
58
+ //
59
+ // task 1002606 (finding P-3 — the DETECTION half; 1002615 fixed the cause half) —
60
+ // a strand must reach its builder when it is DETECTED, not at the 30-minute stale
61
+ // WARN. Two shapes are definitive the moment the sweep sees them: a hand-written
62
+ // merge conflict (the ADR 0082 driver can never resolve it) and a PR closed
63
+ // without merging (auto-merge is dead; the land proof already refused it).
64
+ // Both now flag needs_rebase immediately — which surfaces in /builder-start's
65
+ // rebase warning and gates the builder's new claims (ADR 0120 part 3) — and
66
+ // every NEWLY-flagged strand additionally emits `task.strand-flagged` on the
67
+ // kernel seam, giving strands the push channel that previously only
68
+ // `task.shipped` had (the Discord module listens and posts a notice). Ambiguous
69
+ // shapes (open PR, CI still running, no proof yet) keep the 30-min grace.
70
+ //
43
71
  // Mirrors the in-process poller precedent (src/bongos/uptime-poller.js) and the
44
72
  // Discord reconcile timers (src/bongos/discord-bot.js): unref'd timers (never hold
45
73
  // the process open), best-effort (a GitHub/DB hiccup logs and waits for the next
@@ -145,7 +173,7 @@ async function runSweep(deps = {}) {
145
173
  `[publish-reconciler] WARN ${describeTarget({ configured: false, repo: resolveTarget(deps) })} — confirmed→shipped can NEVER land until a credential is set (GITHUB_APP_ID + GITHUB_APP_INSTALLATION_ID + GITHUB_APP_PRIVATE_KEY, or GITHUB_PUSH_TOKEN). Subsequent sweeps stay quiet until this changes.`
146
174
  );
147
175
  }
148
- return { skipped: 'unconfigured', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 0 };
176
+ return { skipped: 'unconfigured', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 0, strandsFlagged: 0 };
149
177
  }
150
178
  warn.unconfigured = false; // re-arm: a later credential LOSS must warn again
151
179
 
@@ -154,7 +182,7 @@ async function runSweep(deps = {}) {
154
182
  rows = await db.listConfirmedTasksForReconcile({ limit: deps.limit || SWEEP_LIMIT });
155
183
  } catch (e) {
156
184
  errFn(`[publish-reconciler] could not list confirmed tasks: ${e && e.message}`);
157
- return { skipped: 'list_failed', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 1 };
185
+ return { skipped: 'list_failed', checked: 0, reconciled: 0, merged: 0, resolvedConflicts: 0, errors: 1, strandsFlagged: 0 };
158
186
  }
159
187
 
160
188
  // Safety: a worktree_name can be reused across sequential (released) claims, so
@@ -175,9 +203,43 @@ async function runSweep(deps = {}) {
175
203
  let errors = 0;
176
204
  let unprovable = 0; // confirmed tasks the sweep could not prove landed this pass
177
205
  let staleStrands = 0; // of those, the ones older than STALE_CONFIRMED_MS (WARN'd)
206
+ let strandsFlagged = 0; // task 1002606: needs_rebase flags NEWLY set this sweep (any site)
178
207
  const attempts = deps.healAttempts || healAttempts; // injectable for deterministic tests
179
208
  const branchesSeen = new Set(); // to prune the attempts map post-sweep
180
209
  const nowMs = Date.now();
210
+
211
+ // task 1002606 — the ONE exit for every strand signal: flag needs_rebase
212
+ // (idempotent — a re-sweep is a no-op), WARN with the concrete cause, and on a
213
+ // NEW flag emit `task.strand-flagged` so a listening module (the Discord
214
+ // mirror) can push the strand to its builder the moment it is detected instead
215
+ // of leaving it to be found at the next session start. Best-effort on every
216
+ // leg — a DB or listener hiccup logs and never breaks the sweep.
217
+ const announceStrand = async (row, reason, describe) => {
218
+ let flagged = false;
219
+ try {
220
+ flagged = await db.flagTaskNeedsRebase(row.task_id, reason);
221
+ } catch (e) {
222
+ errFn(`[publish-reconciler] flagNeedsRebase task ${row.task_id} (non-blocking): ${e && e.message}`);
223
+ }
224
+ errFn(
225
+ `[publish-reconciler] STRAND task ${row.task_id}: ${describe}; ${flagged ? 'flagged needs_rebase for its builder' : 'needs_rebase already set'}`
226
+ );
227
+ if (flagged) {
228
+ strandsFlagged++;
229
+ try {
230
+ await emit('task.strand-flagged', {
231
+ id: row.task_id,
232
+ builder_id: row.builder_id,
233
+ title: row.title,
234
+ security_sensitive: row.security_sensitive,
235
+ reason,
236
+ });
237
+ } catch (e) {
238
+ errFn(`[publish-reconciler] strand broadcast for task ${row.task_id} (non-blocking): ${e && e.message}`);
239
+ }
240
+ }
241
+ return flagged;
242
+ };
181
243
  for (const r of rows) {
182
244
  const branch = r.worktree_name ? `claude/${r.worktree_name}` : null;
183
245
  if (branch) branchesSeen.add(branch);
@@ -192,9 +254,12 @@ async function runSweep(deps = {}) {
192
254
  let landed = false;
193
255
  let landNote = '';
194
256
  let branchStatus = null;
195
- // task 1002472 — set when a PR exists but has not fully landed. Suppresses the
196
- // land proof (an open PR must never ship) WITHOUT suppressing the observability
197
- // below, which the old bare `continue` did.
257
+ // task 1002472 — set when a PR exists but has not fully landed. Classifies the
258
+ // unprovable reason for the observability below (which the old bare `continue`
259
+ // skipped). task 1002615: it no longer suppresses the land proof — the proof
260
+ // itself refuses an un-merged PR (its reference fallback requires merged:true),
261
+ // so an open PR still can't ship by reference, while a task whose WORK is
262
+ // already on main (out-of-band merge) can now prove it by ancestry.
198
263
  let prNotLanded = false;
199
264
 
200
265
  if (branchUsable) {
@@ -232,8 +297,16 @@ async function runSweep(deps = {}) {
232
297
  `[publish-reconciler] task ${r.task_id} (${branch}) PR #${m.number} conflicted only on generated/index files — server-resolved + pushed; checks re-run, lands next sweep`
233
298
  );
234
299
  } else if (rr && rr.skipped === 'unresolvable_conflict') {
235
- logFn(
236
- `[publish-reconciler] task ${r.task_id} (${branch}) PR #${m.number} has a hand-written conflict (${(rr.conflicted || []).join(', ') || 'unknown'}) — left for a human`
300
+ // task 1002606 (P-3 detection): a hand-written conflict is DEFINITIVE —
301
+ // the driver can never resolve it and the PR sits red until a human
302
+ // acts — so the builder is told NOW, not at the 30-min stale WARN with
303
+ // a vague reason. The file list rides in the reason so /builder-start's
304
+ // rebase warning says WHERE to look.
305
+ const files = (rr.conflicted || []).slice(0, 5).join(',');
306
+ await announceStrand(
307
+ r,
308
+ `strand:unresolvable_conflict${files ? `:${files}` : ''}`,
309
+ `PR #${m.number} (${branch}) has a hand-written conflict (${files || 'unknown files'}) only its builder can resolve`
237
310
  );
238
311
  }
239
312
  } catch (e) {
@@ -263,13 +336,12 @@ async function runSweep(deps = {}) {
263
336
  // is exhausted and the PR is still red — a real check failure or a
264
337
  // hand-written conflict only the builder can resolve. Flag needs_rebase
265
338
  // so their queue is gated until they fix + re-land (or release it).
266
- try {
267
- if (await db.flagTaskNeedsRebase(r.task_id, `failing_check_capped:${m.reason}`)) {
268
- logFn(`[publish-reconciler] task ${r.task_id} flagged needs_rebase (regen-heal cap) — its builder must fix + re-land`);
269
- }
270
- } catch (e) {
271
- errFn(`[publish-reconciler] flagNeedsRebase (cap) task ${r.task_id} (non-blocking): ${e && e.message}`);
272
- }
339
+ // task 1002606: announceStrand also pushes task.strand-flagged.
340
+ await announceStrand(
341
+ r,
342
+ `failing_check_capped:${m.reason}`,
343
+ `PR #${m.number} (${branch}) still red after ${n} regen-heal attempt(s) (${m.reason}) — a real check failure or conflict only its builder can fix`
344
+ );
273
345
  } else {
274
346
  try {
275
347
  const rr = await resolver.refreshStaleGenerated({ branch });
@@ -307,14 +379,24 @@ async function runSweep(deps = {}) {
307
379
  }
308
380
  if (!push.landConfirmed(status) && status && status.pr_state !== 'none') {
309
381
  // A PR exists but hasn't fully landed yet (open / merged-but-deploy-pending /
310
- // blocked). Respect it — wait for the deploy — rather than risk shipping early.
382
+ // blocked). Respect the PR path — wait for the deploy — rather than merge-ship
383
+ // early on it.
311
384
  //
312
385
  // task 1002472: this used to be a bare `continue`, which jumped PAST the
313
386
  // unprovable counter, the STALE WARN and flagTaskNeedsRebase below — so this
314
387
  // strand shape was completely invisible (tasks 1002427/1002428 sat 50h at
315
- // 'confirmed' reporting needs_rebase:false). Flag it instead and fall through
316
- // to the observability block. The flag still suppresses the land proof, so an
317
- // un-landed PR is no more shippable than it was.
388
+ // 'confirmed' reporting needs_rebase:false). Flag it instead and fall through.
389
+ //
390
+ // task 1002615: an UN-merged PR no longer suppresses the land proof — an
391
+ // out-of-band land (manual git merge to main with the PR left open or
392
+ // closed-unmerged) makes the PR report "not landed" while the WORK is on
393
+ // main, and suppressing the proof stranded exactly that shape, unpaid. The
394
+ // proof refuses reference-only claims on an un-merged PR, so a genuinely
395
+ // in-flight PR stays exactly as un-shippable as before. A MERGED PR still
396
+ // mid-deploy DOES keep governing (prMidDeploy below): on a deploy-enabled
397
+ // instance 'shipped' means merged AND deployed, and the merge puts the tip
398
+ // in main minutes before the deploy finishes — the tip proof must not
399
+ // short-circuit that window.
318
400
  prNotLanded = true;
319
401
  }
320
402
  branchStatus = status;
@@ -330,7 +412,11 @@ async function runSweep(deps = {}) {
330
412
  // references this task id. landProvenForTask still cures the task-1313 strand class
331
413
  // (PR deleted post-merge / no worktree_name / ambiguous branch) — now keyed on the
332
414
  // unambiguous published tip rather than the base.
333
- if (!landed && !prNotLanded && typeof push.landProvenForTask === 'function') {
415
+ // task 1002615: only a MERGED PR that is still deploying suppresses the proof
416
+ // (the deploy gate governs); an un-merged PR — open, or closed without merging —
417
+ // cannot veto what git history on main already shows.
418
+ const prMidDeploy = prNotLanded && !!(branchStatus && branchStatus.merged === true);
419
+ if (!landed && !prMidDeploy && typeof push.landProvenForTask === 'function') {
334
420
  try {
335
421
  const proof = await push.landProvenForTask({
336
422
  taskId: r.task_id, publishedSha: r.published_head_sha, status: branchStatus,
@@ -355,9 +441,18 @@ async function runSweep(deps = {}) {
355
441
  // task 1002472: 'pr_not_landed' distinguishes "a PR exists and we are waiting on
356
442
  // it" from "we looked for proof and found none" — the two need different
357
443
  // remediation (wait/unblock the PR vs. push or rebase the branch).
358
- const reason = prNotLanded
359
- ? 'pr_not_landed'
360
- : (!branch && !r.published_head_sha) ? 'no_branch_no_tip' : 'land_not_proven';
444
+ // task 1002606: 'pr_closed_unmerged' is carved out of it as DEFINITIVE — a PR
445
+ // closed without merging will never land on its own (auto-merge is dead and the
446
+ // land proof above already refused it), so it must not enjoy the 30-min grace
447
+ // that exists for CI legitimately still running. (A MERGED PR mid-deploy has
448
+ // merged=true and stays on the graced 'pr_not_landed' path.)
449
+ const prClosedUnmerged = prNotLanded && !!branchStatus &&
450
+ branchStatus.pr_state === 'closed' && branchStatus.merged !== true;
451
+ const reason = prClosedUnmerged
452
+ ? 'pr_closed_unmerged'
453
+ : prNotLanded
454
+ ? 'pr_not_landed'
455
+ : (!branch && !r.published_head_sha) ? 'no_branch_no_tip' : 'land_not_proven';
361
456
  const ageMs = r.confirmed_at ? (nowMs - new Date(r.confirmed_at).getTime()) : null;
362
457
  if (ageMs != null && ageMs > STALE_CONFIRMED_MS) {
363
458
  staleStrands++;
@@ -369,14 +464,16 @@ async function runSweep(deps = {}) {
369
464
  // NEW claims until they land or release it. Since the reward now lands only
370
465
  // on ship (part 1), they are motivated to clear it. NOTE it is no longer
371
466
  // "credited" — part 1 moved the payout from confirm to the land.
372
- let flagged = false;
373
- try {
374
- flagged = await db.flagTaskNeedsRebase(r.task_id, `strand:${reason}`);
375
- } catch (e) {
376
- errFn(`[publish-reconciler] flagNeedsRebase (strand) task ${r.task_id} (non-blocking): ${e && e.message}`);
377
- }
378
- errFn(
379
- `[publish-reconciler] STRAND task ${r.task_id}: confirmed ~${Math.round(ageMs / 3_600_000)}h ago, unprovable (${reason}) — no landed proof; ${flagged ? 'flagged needs_rebase for its builder' : 'needs_rebase already set'}`
467
+ await announceStrand(
468
+ r,
469
+ `strand:${reason}`,
470
+ `confirmed ~${Math.round(ageMs / 3_600_000)}h ago, unprovable (${reason}) — no landed proof`
471
+ );
472
+ } else if (prClosedUnmerged) {
473
+ await announceStrand(
474
+ r,
475
+ `strand:${reason}`,
476
+ `its PR was closed without merging — it will never land on its own`
380
477
  );
381
478
  }
382
479
  continue;
@@ -423,7 +520,7 @@ async function runSweep(deps = {}) {
423
520
  `[publish-reconciler] ${staleStrands} stale strand(s): confirmed >${Math.round(STALE_CONFIRMED_MS / 60_000)}min with no provable land — credited but unlanded; see STRAND lines above`
424
521
  );
425
522
  }
426
- return { checked: rows.length, reconciled, merged, resolvedConflicts, refreshedStale, errors, unprovable, staleStrands };
523
+ return { checked: rows.length, reconciled, merged, resolvedConflicts, refreshedStale, errors, unprovable, staleStrands, strandsFlagged };
427
524
  }
428
525
 
429
526
  // task 1366 — server-side session-cost reconcile, piggybacked on the same 5-min
@@ -37,7 +37,7 @@ function resolveCreatorSessionId(bindSession, sessionId) {
37
37
  function taskNotReadyHint(taskId, currentStatus) {
38
38
  const idNum = Number(taskId);
39
39
  return currentStatus === 'backlog'
40
- ? `Task #${idNum} is in 'backlog' — promote it first: POST /api/bongos/tasks/${idNum}/promote (Archon), then claim.`
40
+ ? `Task #${idNum} is in 'backlog' — promote it first: POST /api/bongos/tasks/${idNum}/promote (Metic+), then claim.`
41
41
  : `Task #${idNum} is '${currentStatus}', not 'ready' — only 'ready' tasks are claimable (promote applies to 'backlog': POST /api/bongos/tasks/${idNum}/promote).`;
42
42
  }
43
43
 
@@ -259,6 +259,10 @@ module.exports = function buildClaimsRouter() {
259
259
  // tasks (already 'ready' at migration 177) are exempt and never reach here.
260
260
  err.code === 'REWARD_REQUIRED' ? 409 :
261
261
  err.code === 'ALREADY_CLAIMED' ? 409 :
262
+ // task 1002589: migration 180's per-session index tripped at the DB
263
+ // layer (claimTask maps the 23505 by err.constraint). Same 409 family
264
+ // as the route gate's session_already_has_active_claim above.
265
+ err.code === 'session_already_has_bound_claim' ? 409 :
262
266
  err.code === 'BUILDER_INACTIVE' ? 403 :
263
267
  // V3.R15 #239: rank_too_low_for_task is the canonical Xenos-gate code;
264
268
  // XENOS_RESTRICTED is the pre-rename alias still honored by callers
@@ -306,8 +310,10 @@ module.exports = function buildClaimsRouter() {
306
310
  // task 1802: omitting the flag (the real-CLI-session default) no longer
307
311
  // binds every row — db.claimTasksBatch only binds the FIRST inserted
308
312
  // claim to the caller's session and leaves the rest unbound, so a real
309
- // session can batch-claim 2+ tasks without tripping
310
- // uniq_active_claim_per_session (migration 180) on the 2nd+ INSERT. A
313
+ // session can batch-claim 2+ tasks without tripping the per-session
314
+ // backstop index on the 2nd+ INSERT (migration 180; re-keyed per
315
+ // (session, worktree) by core_204, where a prefix-less batch's NULL
316
+ // worktrees still fold into one group — first-row binding stays). A
311
317
  // single-task batch call is unaffected (first row IS the only row).
312
318
  bind_session: { type: 'boolean' },
313
319
  })) return;
@@ -502,7 +508,7 @@ module.exports = function buildClaimsRouter() {
502
508
  // grep can find every third-party release.
503
509
  router.post('/claims/:id/release-on-behalf',
504
510
  auth.requireBuilder,
505
- auth.requireRank('archon'),
511
+ auth.requirePermission('claim.release.any'),
506
512
  async (req, res) => {
507
513
  if (validateOrRespond(req, res, {
508
514
  reason: { required: true, type: 'string', maxLength: LIMITS.NOTES },
@@ -53,7 +53,7 @@ module.exports = function buildDependenciesRouter() {
53
53
 
54
54
  // POST /dependencies — create an edge "from is blocked until to is done".
55
55
  // rank: archon.
56
- router.post('/dependencies', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
56
+ router.post('/dependencies', auth.requireBuilder, auth.requirePermission('dependency.cross_tier.manage'), async (req, res) => {
57
57
  const edge = parseEdge(req, res);
58
58
  if (!edge) return;
59
59
  try {
@@ -66,7 +66,7 @@ module.exports = function buildDependenciesRouter() {
66
66
 
67
67
  // DELETE /dependencies — remove an edge (body carries the 4-tuple).
68
68
  // rank: archon.
69
- router.delete('/dependencies', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
69
+ router.delete('/dependencies', auth.requireBuilder, auth.requirePermission('dependency.cross_tier.manage'), async (req, res) => {
70
70
  const edge = parseEdge(req, res);
71
71
  if (!edge) return;
72
72
  try {
@@ -116,7 +116,7 @@ module.exports = function buildDoneWhenRouter() {
116
116
  // bad_goal_id so the route is not an existence oracle (ADR 0112).
117
117
  // Pinned in route-rank-check.MODULE_EXPECTED_RANKS.
118
118
  // rank: metic+archon — criterion create, walled by goal ownership (ADR 0154).
119
- router.post('/versions/:id/done-when', auth.requireBuilder, auth.requireRank('metic', 'archon'), asyncHandler('POST /versions/:id/done-when', async (req, res) => {
119
+ router.post('/versions/:id/done-when', auth.requireBuilder, auth.requirePermission('criterion.create'), asyncHandler('POST /versions/:id/done-when', async (req, res) => {
120
120
  const versionId = String(req.params.id || '').trim();
121
121
  if (!versionId) return res.fail('version_id_required', 400);
122
122
  if (validateOrRespond(req, res, {
@@ -176,7 +176,7 @@ module.exports = function buildDoneWhenRouter() {
176
176
  // match the satisfy route below — this is the planner's closing queue, not a
177
177
  // public surface.
178
178
  // rank: metic+archon — the planner review queue (mirrors the satisfy gate).
179
- router.get('/done-when/pending-review', auth.requireBuilder, auth.requireRank('metic', 'archon'), asyncHandler('GET /done-when/pending-review', async (req, res) => {
179
+ router.get('/done-when/pending-review', auth.requireBuilder, auth.requirePermission('criterion.review'), asyncHandler('GET /done-when/pending-review', async (req, res) => {
180
180
  const versionId = String(req.query.version || '').trim() || null;
181
181
  const criteria = await doneWhen.listPendingReviewCriteria({ versionId });
182
182
  res.json({ criteria, count: criteria.length });
@@ -190,7 +190,7 @@ module.exports = function buildDoneWhenRouter() {
190
190
  // pinned in route-rank-check.MODULE_EXPECTED_RANKS.lifecycle so a further
191
191
  // downgrade is caught.
192
192
  // rank: metic+archon — criterion confirmation + goal auto-achieve (the closing loop).
193
- router.post('/done-when/:criterionId/satisfy', auth.requireBuilder, auth.requireRank('metic', 'archon'), asyncHandler('POST /done-when/:criterionId/satisfy', async (req, res) => {
193
+ router.post('/done-when/:criterionId/satisfy', auth.requireBuilder, auth.requirePermission('criterion.satisfy'), asyncHandler('POST /done-when/:criterionId/satisfy', async (req, res) => {
194
194
  if (validateOrRespond(req, res, {
195
195
  satisfied_by_task_id: { type: 'integer', min: 1 },
196
196
  })) return;
@@ -213,7 +213,7 @@ module.exports = function buildDoneWhenRouter() {
213
213
 
214
214
  // Auth: flip a criterion back to unsatisfied (rare; for corrections).
215
215
  // rank: archon — un-flip a criterion; same gate as satisfy.
216
- router.post('/done-when/:criterionId/unsatisfy', auth.requireBuilder, auth.requireRank('archon'), asyncHandler('POST /done-when/:criterionId/unsatisfy', async (req, res) => {
216
+ router.post('/done-when/:criterionId/unsatisfy', auth.requireBuilder, auth.requirePermission('criterion.unsatisfy'), asyncHandler('POST /done-when/:criterionId/unsatisfy', async (req, res) => {
217
217
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
218
218
  if (id === null) return;
219
219
  const existing = await doneWhen.getCriterion(id);
@@ -64,8 +64,14 @@ module.exports = function buildGateApprovalsRouter() {
64
64
  const router = express.Router();
65
65
 
66
66
  // GET /gate-approvals — open PRs awaiting owner approval (gate-review red), with the
67
- // verdict + per-file reasons + changed files + diff link. rank: archon.
68
- router.get('/gate-approvals', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
67
+ // verdict + per-file reasons + changed files + diff link. rank: metic+archon.
68
+ // BV1.R105 left this on requireRank because `gate_approval.decide` was declared over
69
+ // the /approve WRITE only and no atom named the read. ADR 0157 extends the catalog
70
+ // with exactly that atom — `gate_approval.read`, floor metic — so the mapping is now
71
+ // one the catalog makes. The split is load-bearing, not cosmetic: `page.view.governance`
72
+ // opens /gate to Metic, and without a Metic-readable data endpoint that page would
73
+ // render nothing but a 403. Approving a held PR stays archon (`.decide`, system).
74
+ router.get('/gate-approvals', auth.requireBuilder, auth.requirePermission('gate_approval.read'), async (req, res) => {
69
75
  if (!githubPush.isConfigured()) {
70
76
  return res.fail('publish_unconfigured', { status: 503, message: 'No server GitHub credential configured — cannot read pull requests.' });
71
77
  }
@@ -130,7 +136,7 @@ module.exports = function buildGateApprovalsRouter() {
130
136
  // POST /gate-approvals/:pr/approve — record owner approval for a stuck gate PR and
131
137
  // re-run the gate-review check so it clears. rank: archon (clears the hard floor —
132
138
  // pinned in route-rank-check.EXPECTED_RANKS for downgrade protection).
133
- router.post('/gate-approvals/:pr/approve', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
139
+ router.post('/gate-approvals/:pr/approve', auth.requireBuilder, auth.requirePermission('gate_approval.decide'), async (req, res) => {
134
140
  const number = parseId(req, res, { param: 'pr', code: 'bad_pr', positiveInt: true });
135
141
  if (number == null) return;
136
142
  if (!githubPush.isConfigured()) {
@@ -379,7 +379,7 @@ module.exports = function buildGoalsRouter() {
379
379
  // POST /goals — create a goal.
380
380
  // rank: metic+archon — a Metic or Archon may create a goal; creation of a goal
381
381
  // whose scope_modules includes a PROTECTED module is Archon-only (in-handler).
382
- router.post('/goals', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
382
+ router.post('/goals', auth.requireBuilder, auth.requirePermission('goal.create'), async (req, res) => {
383
383
  if (validateOrRespond(req, res, {
384
384
  version_id: { required: true, type: 'string', minLength: 1, maxLength: 64 },
385
385
  title: { required: true, type: 'string', minLength: 1, maxLength: LIMITS.TITLE },
@@ -457,7 +457,7 @@ module.exports = function buildGoalsRouter() {
457
457
  // Mounted BEFORE GET /goals/:id so 'reorder' is never captured as an id.
458
458
  // rank: archon — priority is an owner decision, never self-granted by a
459
459
  // goal's own member/manager (unlike the goal-authority routes above).
460
- router.post('/goals/reorder', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
460
+ router.post('/goals/reorder', auth.requireBuilder, auth.requirePermission('goal.reorder'), async (req, res) => {
461
461
  if (validateOrRespond(req, res, {
462
462
  version_id: { required: true, type: 'string', minLength: 1, maxLength: 64 },
463
463
  ordered_ids: { required: true, type: 'array', maxItems: 500, itemsType: 'integer' },
@@ -1232,7 +1232,7 @@ module.exports = function buildGoalsRouter() {
1232
1232
  // goals are re-openable (below). This is the lifecycle disposition the /goal-review
1233
1233
  // skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64).
1234
1234
  // rank: metic+archon — a goal member (Metic+) or an Archon dispositions the goal.
1235
- router.post('/goals/:id/archive', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1235
+ router.post('/goals/:id/archive', auth.requireBuilder, auth.requirePermission('goal.archive'), async (req, res) => {
1236
1236
  const id = parseId(req, res, { code: 'bad_goal_id' });
1237
1237
  if (id === null) return;
1238
1238
  try {
@@ -1255,7 +1255,7 @@ module.exports = function buildGoalsRouter() {
1255
1255
  // POST /goals/:id/reopen — bring an archived goal back to open (ADR 0086 §6 /
1256
1256
  // BV1.R64). Only archived → open; an already open/achieved goal is a 409.
1257
1257
  // rank: metic+archon — same gate as archive.
1258
- router.post('/goals/:id/reopen', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1258
+ router.post('/goals/:id/reopen', auth.requireBuilder, auth.requirePermission('goal.archive'), async (req, res) => {
1259
1259
  const id = parseId(req, res, { code: 'bad_goal_id' });
1260
1260
  if (id === null) return;
1261
1261
  try {
@@ -1283,7 +1283,7 @@ module.exports = function buildGoalsRouter() {
1283
1283
  // ask a Metic+/Archon out-of-band, exactly as joining a protected goal works (R57).
1284
1284
  // The goal must be open. Body: { add: ["module-key", ...] }; idempotent (set-union).
1285
1285
  // rank: metic+archon — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.
1286
- router.post('/goals/:id/scope', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1286
+ router.post('/goals/:id/scope', auth.requireBuilder, auth.requirePermission('goal.scope.widen'), async (req, res) => {
1287
1287
  const id = parseId(req, res, { code: 'bad_goal_id' });
1288
1288
  if (id === null) return;
1289
1289
  if (validateOrRespond(req, res, {
@@ -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 },
@@ -977,7 +977,7 @@ module.exports = function buildTasksRouter() {
977
977
  // PATCH refuses `status` outright). A restore lands in **backlog**, never straight
978
978
  // to ready: the task was retired for a reason, so it re-enters as un-workable and
979
979
  // takes a second, deliberate promote to become claimable again.
980
- router.post('/tasks/:id/promote', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
980
+ router.post('/tasks/:id/promote', auth.requireBuilder, auth.requirePermission('task.promote'), async (req, res) => {
981
981
  const id = Number(req.params.id);
982
982
  const task = await db.getTask(id);
983
983
  if (!task) return res.fail('task_not_found', 404);
@@ -1020,7 +1020,7 @@ module.exports = function buildTasksRouter() {
1020
1020
  // task out from under its claim holder. No reward side-effects (the reward gate
1021
1021
  // is a promote-only concern; backlog is the pre-workable state).
1022
1022
  // 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) => {
1023
+ router.post('/tasks/:id/demote', auth.requireBuilder, auth.requirePermission('task.demote'), async (req, res) => {
1024
1024
  const id = Number(req.params.id);
1025
1025
  const task = await db.getTask(id);
1026
1026
  if (!task) return res.fail('task_not_found', 404);
@@ -1091,7 +1091,7 @@ module.exports = function buildTasksRouter() {
1091
1091
  // GRADER_BYPASS_ENABLED==='1'; archon-only otherwise).
1092
1092
  const confirmRankGate = (req, res, next) => {
1093
1093
  if (graderBypassEnabled()) return next();
1094
- return auth.requireRank('archon')(req, res, next);
1094
+ return auth.requirePermission('task.confirm.grade_bypass')(req, res, next);
1095
1095
  };
1096
1096
  router.post('/tasks/:id/confirm', auth.requireBuilder, confirmRankGate, async (req, res) => {
1097
1097
  if (validateOrRespond(req, res, {
@@ -1148,7 +1148,7 @@ module.exports = function buildTasksRouter() {
1148
1148
  // triage they already delegated. Not one-way any more — /promote restores an
1149
1149
  // abandoned task to backlog at the same rank — and the guards below (shipped
1150
1150
  // 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) => {
1151
+ router.post('/tasks/:id/abandon', auth.requireBuilder, auth.requirePermission('task.abandon'), async (req, res) => {
1152
1152
  if (validateOrRespond(req, res, {
1153
1153
  reason: { type: 'string' }, // free-text retire reason (sliced to 2000 below)
1154
1154
  })) return;
@@ -1228,7 +1228,7 @@ module.exports = function buildTasksRouter() {
1228
1228
  // reaches it via the doorway api.tallyPeerVotes — BV1.R83) share one code path
1229
1229
  // and can never diverge. This route keeps the Archon gate (the HTTP surface
1230
1230
  // 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) => {
1231
+ router.post('/tasks/peer-votes/tally', auth.requireBuilder, auth.requirePermission('task.peer_votes.tally'), async (req, res) => {
1232
1232
  try {
1233
1233
  const result = await db.tallyPeerVotes();
1234
1234
  res.json(result);
@@ -1244,7 +1244,7 @@ module.exports = function buildTasksRouter() {
1244
1244
  // - 409 if the task isn't votable, or the vote was already folded into karma
1245
1245
  // - vote: 0 clears (deletes the row, only while still untallied)
1246
1246
  // rank: metic+archon — a trusted-builder reputation action.
1247
- router.post('/tasks/:id/vote', auth.requireBuilder, auth.requireRank('metic', 'archon'), async (req, res) => {
1247
+ router.post('/tasks/:id/vote', auth.requireBuilder, auth.requirePermission('task.vote'), async (req, res) => {
1248
1248
  if (validateOrRespond(req, res, {
1249
1249
  vote: { required: true, type: 'integer', enum: [-1, 0, 1] },
1250
1250
  })) return;
@@ -1685,7 +1685,7 @@ module.exports = function buildTasksRouter() {
1685
1685
  // GET /override-requests?status=open — Archon queue. Status defaults to open;
1686
1686
  // pass ?status=approved or ?status=denied for the resolved history.
1687
1687
  // rank: archon — same gate as the audit log + Archon view.
1688
- router.get('/override-requests', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
1688
+ router.get('/override-requests', auth.requireBuilder, auth.requirePermission('override_request.decide'), async (req, res) => {
1689
1689
  const status = req.query.status || 'open';
1690
1690
  if (!['open', 'approved', 'denied'].includes(status)) {
1691
1691
  return res.fail('bad_status', 400);
@@ -1705,7 +1705,7 @@ module.exports = function buildTasksRouter() {
1705
1705
  // updates; the task stays at completed and the builder must release or
1706
1706
  // re-ship after fixing whatever the grader caught.
1707
1707
  // 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) => {
1708
+ router.post('/override-requests/:id/decide', auth.requireBuilder, auth.requirePermission('override_request.decide'), async (req, res) => {
1709
1709
  const id = parseId(req, res, { code: 'bad_request_id' });
1710
1710
  if (id === null) return;
1711
1711
  if (validateOrRespond(req, res, {